From e9344fcfedc00ade413946ecc094ad3116c66863 Mon Sep 17 00:00:00 2001 From: os-dev Date: Sat, 12 Sep 2026 04:13:14 +0000 Subject: [PATCH 1/6] 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/6] 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 * From df84ded6f42c0914b588791c7557df00e2a991f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 10:05:35 +0000 Subject: [PATCH 3/6] perf(components,app-shell,console): answer icon-name membership from lucide's `icons` record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#9204, maintainer decision batch #125 item 3 (2026-09-13), option A. lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, so importing the icon NAMES imports the dynamic-import map with them. Four modules did, and the map sat in the console's eager `ui-components` chunk on every page load — 8,253 B gzipped, the row this card exists to pay down. The names leave the eager path by being answered from the `icons` RECORD, which `renderers/action/resolve-icon.ts` already puts in that same chunk. Zero new eager bytes, one vocabulary instead of two. A generated mirror of the names was measured first and rejected: the map's keys ARE the names, so a bare catalogue of them costs 9,176 B gz against the 8,253 B map it replaces. MEASURED, two console builds in one container: ui-components 397,090 -> 388,494 gz (-8,596) aggregate 3,180,420 -> 3,171,783 (-8,637) The two deltas agree within 41 bytes, so these are bytes LEAVING the page load, not bytes moving between columns. `check:eager-closure` goes exit 2 -> exit 0 and the row's headroom goes 1,910 B (0.02x) -> 10,506 B (0.12x). ⚠️ Behaviour change, which is the cost the ruling took: membership narrows from lucide's DYNAMIC vocabulary to its RECORD, so 254 retired spellings (`smile`, `edit`, `filter`, `alert-triangle`, `sort-desc`, …) stop resolving. Ruling item 2 makes that loud rather than silent — a refused spelling now names itself, lucide's current name for it, and the spelling to write instead, all derived from the installed lucide at runtime rather than from a list that would age. Gate constants: `EXHAUSTED_HEADROOM_ALLOWANCES` loses its `ui-components` entry — REMOVED, not lowered, because the row now clears the 0.10x floor on its own, which is the only way out of that table and the end state its own docblock names. `PER_CHUNK_BASELINE['ui-components']` moves with it in this commit, since the gate's unit fixtures are built from it. ⛔ No ceiling moved. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .changeset/9204-icon-names-from-the-record.md | 39 + .../9204-lucide-dynamic-map-deferred.md | 27 - package.json | 1 - .../src/views/metadata-admin/widgets.tsx | 40 +- .../icon-name-vocabulary-9204.test.ts | 204 ++ .../lucide-icon-names-mirror-9204.test.ts | 67 - packages/components/src/index.ts | 8 +- packages/components/src/lib/lazy-icon.tsx | 234 +- .../components/src/lib/lucide-icon-names.ts | 2064 ----------------- .../src/renderers/action/resolve-icon.ts | 54 + .../check-eager-closure-budget.test.ts | 71 +- .../check-lucide-icon-record-names.test.ts | 20 - .../__tests__/gen-lucide-icon-names.test.ts | 56 - scripts/check-eager-closure-budget.mjs | 64 +- scripts/check-lucide-icon-record-names.mjs | 70 +- scripts/gen-lucide-icon-names.mjs | 130 -- 16 files changed, 667 insertions(+), 2482 deletions(-) create mode 100644 .changeset/9204-icon-names-from-the-record.md delete mode 100644 .changeset/9204-lucide-dynamic-map-deferred.md create mode 100644 packages/components/src/__tests__/icon-name-vocabulary-9204.test.ts delete mode 100644 packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts delete mode 100644 packages/components/src/lib/lucide-icon-names.ts delete mode 100644 scripts/__tests__/gen-lucide-icon-names.test.ts delete mode 100644 scripts/gen-lucide-icon-names.mjs diff --git a/.changeset/9204-icon-names-from-the-record.md b/.changeset/9204-icon-names-from-the-record.md new file mode 100644 index 0000000000..fda7cb320d --- /dev/null +++ b/.changeset/9204-icon-names-from-the-record.md @@ -0,0 +1,39 @@ +--- +'@object-ui/components': minor +'@object-ui/app-shell': minor +'@object-ui/console': minor +--- + +Icon-name membership answers from lucide's `icons` record, and a retired +spelling is refused instead of silently degraded (objectui#9204). + +⚠️ **Behaviour change, on purpose.** lucide publishes two icon vocabularies: the +runtime `icons` record it actually ships, and the larger dynamic-import list +that still carries spellings lucide has retired. `getLazyIcon`, `LazyIcon` and +`isLucideIconName` used to judge names against the second one; they now judge +against the record, which is what every other resolver in this package already +read. Measured against the installed lucide, that retires **254 spellings** — +`smile`, `edit`, `filter`, `alert-triangle`, `sort-desc` and the rest. + +- **A retired spelling is now REFUSED OUT LOUD.** It used to become the + `Database` glyph (or, through `isLucideIconName`, a notification's severity + icon) with nothing logged — a page that still rendered and a glyph that looked + deliberate. The console now carries a diagnostic, once per spelling, naming + the spelling, lucide's current name for it, and the exact spelling to write + instead. Both halves are derived from the installed lucide at runtime; there + is no list of retired names in this repo to go stale. +- **`isLucideIconName` keeps its signature and its synchronous contract.** Only + the vocabulary behind it moved. Spellings the two lists agree on — kebab-case, + snake_case, space-separated and PascalCase alike — resolve exactly as before. +- **New export `loadLucideIconNames()`** — the renderable icon vocabulary, + `Promise`-returning, for a picker that needs the whole list to search. The + metadata designer's icon picker loads it when it opens. + +**Why:** importing the icon NAMES imports lucide's dynamic-import map with them, +because lucide derives the names as that map's keys. Four modules did, and the +map sat in the console's eager `ui-components` chunk on every page load. Sourcing +membership from the record — which the same chunk already carries — takes +**8,596 gzipped bytes** off that chunk and **8,637** off the whole eager closure, +measured on two console builds in one container. The `ui-components` row goes +from 1,910 B of headroom (0.02x, red) to 10,506 B (0.12x, green), which pays off +the declared allowance that row has been carrying. diff --git a/.changeset/9204-lucide-dynamic-map-deferred.md b/.changeset/9204-lucide-dynamic-map-deferred.md deleted file mode 100644 index ff34a0c660..0000000000 --- a/.changeset/9204-lucide-dynamic-map-deferred.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@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/package.json b/package.json index 02cc6ffb4e..cdc05fa084 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "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/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index fb1edc09ff..6f39fafdd3 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -30,8 +30,9 @@ import { Button, Label, Switch, + isLucideIconName, LazyIcon, - LUCIDE_ICON_NAMES, + loadLucideIconNames, toKebabIconName, Popover, PopoverTrigger, @@ -1553,15 +1554,21 @@ function FieldRefMultiWidget({ value, onChange, readOnly, context, ariaLabelledB /* icon — searchable Lucide icon picker */ /* -------------------------------------------------------------------------- */ -// `LUCIDE_ICON_NAMES` is the shared catalogue `@object-ui/components` publishes -// as DATA. Read from there rather than from `lucide-react/dynamic.mjs`, whose -// `iconNames` is `Object.keys(dynamicIconImports)` — importing the names -// imports the 2,025-entry map with them, onto the eager path (objectui#9204). -// Freeze the membership Set once for O(1) reuse. -const LUCIDE_ICON_SET: Set = new Set(LUCIDE_ICON_NAMES); +// ⛔ No module-scope catalogue. The vocabulary arrives from +// `loadLucideIconNames()` (@object-ui/components) when the dialog opens: it is +// the LIVE `icons` record intersected with lucide's dynamic spellings, derived +// from the installed lucide rather than kept as a list. Importing `iconNames` +// from `lucide-react/dynamic.mjs` to get the same strings drags lucide's +// 2,039-entry dynamic-import map onto the console's eager path, and a generated +// mirror of those strings measured DEARER than the map it replaced +// (objectui#9204). Membership of a single name is `isLucideIconName`, which +// reads the record synchronously and needs nothing loaded. // 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. const ICON_RESULT_LIMIT = 120; +// A stable empty array, so the `useMemo` below does not see a new identity on +// every render before the catalogue lands. +const EMPTY_CATALOGUE: readonly string[] = []; /** * Searchable icon picker for `widget: 'icon'` string fields (page/app/object @@ -1585,12 +1592,25 @@ export function IconPickerWidget({ id, value, onChange, readOnly }: WidgetProps) const [query, setQuery] = React.useState(''); const currentKebab = current ? toKebabIconName(current) : ''; - const inCatalog = !current || LUCIDE_ICON_SET.has(currentKebab); + const inCatalog = !current || isLucideIconName(current); + + // Fetched when the dialog first opens, then kept — `loadLucideIconNames` + // memoises the underlying `import()`, so re-opening costs nothing. + const [catalogue, setCatalogue] = React.useState(EMPTY_CATALOGUE); + React.useEffect(() => { + if (!open || catalogue.length) return undefined; + let alive = true; + loadLucideIconNames().then( + (names) => { if (alive) setCatalogue(names); }, + (error) => { console.error('[metadata-admin] failed to load the lucide icon catalogue', error); }, + ); + return () => { alive = false; }; + }, [open, catalogue.length]); const q = toKebabIconName(query.trim()); const allMatches = React.useMemo( - () => (q ? LUCIDE_ICON_NAMES.filter((n) => n.includes(q)) : LUCIDE_ICON_NAMES), - [q], + () => (q ? catalogue.filter((n) => n.includes(q)) : catalogue), + [q, catalogue], ); const results = allMatches.slice(0, ICON_RESULT_LIMIT); const truncated = allMatches.length > results.length; diff --git a/packages/components/src/__tests__/icon-name-vocabulary-9204.test.ts b/packages/components/src/__tests__/icon-name-vocabulary-9204.test.ts new file mode 100644 index 0000000000..d10cb077ad --- /dev/null +++ b/packages/components/src/__tests__/icon-name-vocabulary-9204.test.ts @@ -0,0 +1,204 @@ +/** + * 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-name membership answers from the `icons` RECORD, and a retired spelling + * is REFUSED out loud rather than silently degraded (objectui#9204, maintainer + * decision batch #125 item 3, 2026-09-13). + * + * ## What this file pins, and what it deliberately does not + * + * The byte claim — that lucide's dynamic-import map left the console's eager + * path — is enforced where bytes are decided: `check-eager-closure-budget.mjs` + * on the emitted chunk, and `check-lucide-icon-record-names.mjs`'s empty + * `DECLARED_EAGER_DYNAMIC_IMPORTERS` on the source shape. ⛔ Neither is this + * file's subject; a render test cannot tell a static import from a deferred one. + * + * What IS this file's subject is the behaviour the ruling bought those bytes + * with: the vocabulary narrowed from lucide's DYNAMIC list to its RECORD, so + * the spellings the two disagree about stop resolving — and the author has to + * find out at once instead of reading a plausible fallback glyph. + * + * ## Every population here is DERIVED from the installed lucide + * + * ⛔ No list of retired spellings is written down. `check-lucide-icon-record-names.mjs` + * refuses one 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" — and a test is not exempt from its own gate's rule. The two + * vocabularies are read here and the difference between them IS the population, + * so this file cannot go stale against a lucide bump: it can only change what + * it reports. + */ + +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; +import { icons } from 'lucide-react'; +// The refusal diagnostic derives the replacement name through +// `import('lucide-react/dynamic.mjs')`. Importing it at MODULE scope pays for +// it in the import phase, which is under no test or hook timeout — the +// `await import()` inside a bounded assertion window is the flaky shape +// AGENTS.md's testing section is about. +import { dynamicIconImports } from 'lucide-react/dynamic.mjs'; + +import { getLazyIcon, isLucideIconName, loadLucideIconNames } from '../lib/lazy-icon'; +import { describeIconLookup, liveIconNameOf } from '../renderers/action/resolve-icon'; + +/** Names lucide can still LOAD but no longer publishes in its `icons` record. */ +const RETIRED = Object.keys(dynamicIconImports).filter( + (name) => !Object.prototype.hasOwnProperty.call(icons, describeIconLookup(name).key), +); +/** Names both vocabularies agree on. */ +const LIVE = Object.keys(dynamicIconImports).filter( + (name) => Object.prototype.hasOwnProperty.call(icons, describeIconLookup(name).key), +); + +/** + * `console.error` is mocked for the WHOLE file, not only where it is asserted. + * The sweeps below refuse every retired spelling on purpose, and each refusal + * resolves asynchronously — left unmocked they would land in another test's + * output, after this file's assertions had finished. + */ +let errorSpy: ReturnType; +beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** + * Wait for the refusal's own `import()` chain to settle. + * + * ⛔ Not a fixed sleep and ⛔ not one tick: deriving the replacement name walks + * a module import and then an icon import, so the number of turns is lucide's + * to decide, not this file's. Polling for the observable effect is the shape + * that does not encode a guess about it. + */ +async function settle(predicate: () => boolean, turns = 200): Promise { + for (let i = 0; i < turns && !predicate(); i += 1) { + await new Promise((resolve) => { setTimeout(resolve, 1); }); + } +} + +describe('a retired spelling is refused OUT LOUD', () => { + // ⚠️ This block runs FIRST, and the ordering is load-bearing: a spelling is + // refused once per process, so the population sweeps below — which ask about + // every retired name — would spend these specimens' one refusal before the + // assertions could see it. Vitest runs describes in file order. + + it('names the spelling, lucide’s current name for it, and what to write', async () => { + // `smile` is a specimen, ⛔ not a pinned fact: the sweep below judges the + // whole retired population, and this row checks the MESSAGE. Its premise is + // asserted rather than assumed. + expect(RETIRED).toContain('smile'); + + expect(isLucideIconName('smile')).toBe(false); + await settle(() => errorSpy.mock.calls.length > 0); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const message = String(errorSpy.mock.calls[0]?.[0]); + expect(message).toContain('"smile"'); + expect(message).toContain('RETIRED'); + // lucide's current NAME (the live record key)… + expect(message).toContain('"FaceSlightlySmiling"'); + // …and the spelling that actually renders, which is not always its kebab. + expect(message).toContain('"face-slightly-smiling"'); + }); + + it('suggests a spelling `getLazyIcon` accepts, for every retired name', async () => { + // ⭐ The half a single specimen cannot show. 16 of the 243 live replacements + // are digit-bearing (`Grid2x2`, `Axis3d`, `Rows2`…) and their record key + // kebabs to a name lucide does not carry — so a diagnostic that echoed the + // key would send an author to a name that passes membership and then fails + // to load. This walks the whole retired population rather than trusting the + // one above. + const suggestions = await Promise.all( + RETIRED.map(async (retired) => { + const live = await dynamicIconImports[retired as keyof typeof dynamicIconImports](); + return liveIconNameOf((live as { default?: unknown }).default); + }), + ); + const unrenderable = suggestions.filter((key) => key !== null && !isLucideIconName(key)); + // Derived, so it reports rather than pins: these are the keys whose OWN + // spelling would not render, i.e. exactly why the message carries both. + expect(unrenderable.length).toBeGreaterThan(0); + const offered = await loadLucideIconNames(); + for (const key of suggestions) { + if (key === null) continue; + expect(offered.some((name) => describeIconLookup(name).key === key)).toBe(true); + } + }); + + it('says so for a name that was never lucide’s, WITHOUT inventing a replacement', async () => { + // The control for the first row: "RETIRED" has to be a claim this + // diagnostic can decline to make, or it means nothing when it does. + expect(isLucideIconName('box-open-from-another-library')).toBe(false); + await settle(() => errorSpy.mock.calls.length > 0); + + const message = String(errorSpy.mock.calls[0]?.[0]); + expect(message).toContain('"box-open-from-another-library"'); + expect(message).toContain('No live lucide icon answers to it'); + expect(message).not.toContain('RETIRED'); + }); + + it('says it ONCE per spelling, not once per render', async () => { + expect(RETIRED).toContain('sort-desc'); + for (let i = 0; i < 5; i += 1) getLazyIcon('sort-desc'); + await settle(() => errorSpy.mock.calls.length > 0); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('stays silent for a live name — the volume is the signal', async () => { + expect(isLucideIconName('circle-check')).toBe(true); + getLazyIcon('circle-check'); + await settle(() => errorSpy.mock.calls.length > 0, 20); + expect(errorSpy).not.toHaveBeenCalled(); + }); +}); + +describe('the vocabulary is the `icons` record', () => { + it('has a non-empty disagreement to judge — the control for everything below', () => { + // Without this the two rows underneath would pass on an empty population + // and read exactly like a satisfied assertion. A lucide release that + // retired nothing would make this file vacuous, and this is where that + // shows up. + expect(RETIRED.length).toBeGreaterThan(0); + expect(LIVE.length).toBeGreaterThan(0); + }); + + it('refuses EVERY spelling the record has dropped', () => { + const accepted = RETIRED.filter((name) => isLucideIconName(name)); + expect(accepted).toEqual([]); + }); + + it('accepts every spelling the record still carries', () => { + const rejected = LIVE.filter((name) => !isLucideIconName(name)); + expect(rejected).toEqual([]); + }); + + it('still answers PascalCase, snake_case and space-separated spellings', () => { + // The tokeniser did not change with the vocabulary. `Home` is the seam's one + // rename entry and has to keep resolving, which is also a control on the + // row above: it is a name the record does NOT carry. + expect(Object.prototype.hasOwnProperty.call(icons, 'Home')).toBe(false); + expect(isLucideIconName('home')).toBe(true); + expect(isLucideIconName('CircleCheck')).toBe(true); + expect(isLucideIconName('circle_check')).toBe(true); + expect(isLucideIconName('circle check')).toBe(true); + expect(isLucideIconName('no-such-glyph-xyz')).toBe(false); + }); + + it('offers pickers only the live half, in lucide’s own spelling', async () => { + const offered = await loadLucideIconNames(); + expect(offered.length).toBe(LIVE.length); + expect(offered).not.toContain(RETIRED[0]); + // …and every offered name is one `getLazyIcon` will accept, which is the + // property a picker actually depends on. + expect(offered.filter((name) => !isLucideIconName(name))).toEqual([]); + }); +}); + 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 deleted file mode 100644 index d1800de7da..0000000000 --- a/packages/components/src/__tests__/lucide-icon-names-mirror-9204.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * `LUCIDE_ICON_NAMES` is the installed lucide's DYNAMIC vocabulary, not a - * second opinion about it (objectui#9204). - * - * `lazy-icon.tsx` answers `isLucideIconName` from a generated mirror instead of - * importing `iconNames` from `lucide-react/dynamic.mjs`, because lucide derives - * those names as `Object.keys(dynamicIconImports)` — importing them imports the - * 2,025-entry dynamic-import map, which is what put that map on the console's - * eager path. - * - * The mirror buys that with an ageing risk, and it is the risk - * `scripts/check-lucide-icon-record-names.mjs` names in its own header: "a - * hand-kept vocabulary is the same defect one level up — it ages the moment - * lucide retires the next name, and it ages SILENTLY." This file is what makes - * it not silent. It re-derives the list from the SAME install the renderer - * resolves against and fails on any drift, in either direction. - * - * ⛔ The repair for a red here is `pnpm gen:lucide-icon-names`, never an edit to - * the catalogue. - */ - -import { describe, expect, it } from 'vitest'; -import { iconNames } from 'lucide-react/dynamic.mjs'; - -import { LUCIDE_ICON_NAMES } from '../lib/lucide-icon-names'; - -describe('the lucide icon-name catalogue', () => { - /** - * The blind-probe control, first. Every assertion below is an equality - * between two lists; two EMPTY lists are equal, and a comparison that can - * only ever pass reads exactly like a fresh mirror. - */ - it('is comparing two real vocabularies', () => { - expect(Array.isArray(iconNames)).toBe(true); - expect(iconNames.length).toBeGreaterThan(500); - expect(LUCIDE_ICON_NAMES.length).toBeGreaterThan(500); - // A name lucide has carried for years, spelled the way the dynamic surface - // spells it — so "the list is long" is not the only thing checked. - expect(LUCIDE_ICON_NAMES).toContain('database'); - expect(LUCIDE_ICON_NAMES).not.toContain('no-such-glyph-xyz'); - }); - - it('is exactly what the installed lucide ships, in order', () => { - expect([...LUCIDE_ICON_NAMES]).toEqual([...(iconNames as readonly string[])]); - }); - - /** - * Stated separately from the deep-equal above because the two fail for - * different reasons and a reader of the failure needs to know which: a count - * mismatch is a lucide bump nobody regenerated, a same-length mismatch is a - * renamed spelling. - */ - it('carries every name and no extras', () => { - const installed = new Set(iconNames as readonly string[]); - const mirrored = new Set(LUCIDE_ICON_NAMES); - expect([...installed].filter((name) => !mirrored.has(name))).toEqual([]); - expect([...mirrored].filter((name) => !installed.has(name))).toEqual([]); - }); -}); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 75ff7bb756..6dbe6b6773 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -40,11 +40,11 @@ export { cn } from './lib/utils'; export { renderChildren, renderNodeSlot, isEmptyNodeSlot } from './lib/utils'; export { cva } from 'class-variance-authority'; export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/lazy-icon'; -// lucide's DYNAMIC icon vocabulary as data. Published because the metadata -// designer's icon picker needs the whole list to search, and importing +// The renderable icon vocabulary, loaded on demand. Published because the +// metadata designer's icon picker needs the whole list to search, and reading // `iconNames` from `lucide-react/dynamic.mjs` to get it drags lucide's -// 2,025-entry dynamic-import map in with them (objectui#9204). -export { LUCIDE_ICON_NAMES } from './lib/lucide-icon-names'; +// 2,039-entry dynamic-import map onto the eager path with them (objectui#9204). +export { loadLucideIconNames } from './lib/lazy-icon'; // The member-action visibility gate — "did this action DECLARE a `visible` gate // at all?", the single definition objectui#3492 established and PR #3816 / diff --git a/packages/components/src/lib/lazy-icon.tsx b/packages/components/src/lib/lazy-icon.tsx index 0cf85366dd..43a16b49c2 100644 --- a/packages/components/src/lib/lazy-icon.tsx +++ b/packages/components/src/lib/lazy-icon.tsx @@ -18,44 +18,66 @@ * 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 2,025-entry dynamic-import map into whatever - * chunk holds this module — the console's eager `ui-components` chunk, where it - * 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: - * - * - 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. + * ## ONE vocabulary: the record that actually ships (objectui#9204) * + * Membership — "is this a real icon name?" — is answered by the seam in + * `../renderers/action/resolve-icon`, which reads lucide's runtime `icons` + * record. ⛔ It is NOT answered from `iconNames`, and the reason is both + * behavioural and measured: + * + * - BEHAVIOURAL. lucide publishes two vocabularies. The `icons` RECORD is + * what ships and what every other resolver in this repo reads; the DYNAMIC + * list is a strict superset that still carries 254 spellings lucide has + * retired (`smile`, `sort-desc`, `alarm-check`, `arrow-down-az`, measured + * against the installed lucide). Judging membership on the superset blesses + * exactly the names `scripts/check-lucide-icon-record-names.mjs` exists to + * keep out of authored metadata. + * - MEASURED. lucide derives `iconNames` as `Object.keys(dynamicIconImports)`, + * so a static import of EITHER name drags the dynamic-import map into + * whatever chunk holds this module — the console's eager `ui-components` + * chunk, where it cost 8,253 B gzipped. Shipping the names as a generated + * list instead costs MORE (9,176 B gz spliced into that same chunk, with a + * front-coded encoding probed and rejected at 8,397 B): the map's KEYS are + * the names, so a list of them is dearer than lucide's map of the same + * names. Sourcing membership from the record costs zero NEW eager bytes, + * because `resolve-icon.ts` already pays for that record. + * + * The maintainer ruled this on 2026-09-13 (objectui#9204, decision batch #125 + * item 3), with the retirement of the 254 aliases as the stated cost and the + * loud refusal below as its condition. + * + * ## What still loads lazily, and why it is only the MAP + * + * `DynamicIcon` and the import map behind it are reached through `import()`. * ⛔ 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. + * or anywhere else: `scripts/check-lucide-icon-record-names.mjs` fails on one + * (its `DECLARED_EAGER_DYNAMIC_IMPORTERS` is empty and that emptiness IS the + * assertion), because it puts the map back on the first payload with nothing + * else 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. + * + * ## ⚠️ One measured seam between membership and rendering + * + * Membership reads the RECORD (PascalCase keys); `DynamicIcon` reads the + * DYNAMIC map (kebab spellings). Measured against the installed lucide, every + * live record key has a dynamic spelling (0 of 1,781 do not), so nothing that + * passes membership is unrenderable when it is authored in lucide's own + * kebab spelling. The 95 record keys that carry a DIGIT are the exception, and + * only when authored in PascalCase: `Building2` kebabs to `building2` while + * lucide's spelling is `building-2`. Those 95 spellings answered `false` before + * this change too — they degraded to the fallback glyph silently, and they + * degrade to it loudly now (lucide's own "Name in Lucide DynamicIcon not found" + * on the render leg). ⛔ Not repaired here: a digit-aware tokeniser would change + * what resolves for names nobody has asked for, and `axis-3d` vs the retired + * `axis-3-d` shows the two spellings can BOTH exist with only one live. */ import React from 'react'; import { Database } from 'lucide-react'; -import { LUCIDE_ICON_NAMES } from './lucide-icon-names'; +import { describeIconLookup, liveIconNameOf, resolveIcon } from '../renderers/action/resolve-icon'; /** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */ export function toKebabIconName(name: string): string { @@ -66,25 +88,134 @@ export function toKebabIconName(name: string): string { .toLowerCase(); } -// 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. */ +/** + * Returns true when `kebab` names a LIVE glyph in lucide's `icons` record. + * + * Silent on its own — the refusal is raised by the call sites below, which know + * whether a `false` is a probe (`isLucideIconName`, whose whole job is to be + * asked) or an authored name that will not render. + */ function isLucideIcon(kebab: string): boolean { - return VALID_ICON_NAMES.has(kebab); + return resolveIcon(kebab) !== null; } +/* -------------------------------------------------------------------------- */ +/* The loud refusal */ +/* -------------------------------------------------------------------------- */ + +/** + * Spellings already refused, so one bad name in a render loop says its piece + * once rather than once per frame. + */ +const refused = new Set(); + /** - * Whether `name` (kebab-case or PascalCase) resolves to a real Lucide icon. + * Say, once per spelling, that a name does not resolve — and say what to write + * instead when lucide renamed it. + * + * ## Why a refusal and not the old silent degrade + * + * Until objectui#9204 an unresolvable name became the `Database` glyph here and + * the severity glyph in `../notifications/severity.ts`, with nothing logged. + * That is survivable for a name from another icon library — the case the old + * comment was written for — and it is NOT survivable for a spelling lucide + * retired: the author wrote a name that used to work, the page still renders, + * and the only signal is a glyph that looks deliberate. Narrowing membership to + * the record retires 254 such spellings at once, so the maintainer's ruling + * made ending that silence its condition: "a retired spelling is refused, not + * silently degraded", with "a diagnostic naming the spelling and its current + * name, so an author learns at once". + * + * ## Why the replacement is DERIVED and why that makes this async + * + * The retired export and its live spelling are the same object, so the current + * name is a reverse lookup in the record — but the retired name only reaches a + * component through the dynamic map, which this module deliberately does not + * hold eagerly. So the diagnostic awaits the same `import()` the render path + * uses (already in flight whenever any icon is on the page) and prints once it + * can name the replacement. ⛔ The alternative — a retired-to-live table in this + * file — is the hand-kept vocabulary `scripts/check-lucide-icon-record-names.mjs` + * refuses in its own header, and it would put ~5 KB gzipped back on the very + * path this card is emptying. + * + * ⚠️ It stays a console diagnostic rather than a thrown error: a single bad + * icon name in server-driven metadata must not take the page down, and + * `isLucideIconName` is a predicate callers ask BEFORE choosing what to draw. + * The refusal is in the volume and in the named replacement, not in a crash. + */ +function refuseIconName(name: string): void { + if (refused.has(name)) return; + refused.add(name); + const kebab = toKebabIconName(name); + const { key } = describeIconLookup(kebab); + void currentNameFor(kebab).then((current) => { + const head = `[@object-ui/components] icon name ${JSON.stringify(name)} does not resolve` + + ` (looked up as ${JSON.stringify(key)} in lucide's runtime icons record)`; + console.error( + current + ? `${head}. lucide RETIRED that spelling; its current name is ${JSON.stringify(current.key)}` + + ` — write ${JSON.stringify(current.spelling)} in the metadata.` + : `${head}. No live lucide icon answers to it.`, + ); + }); +} + +/** + * What a retired kebab spelling is called today, or `null` when the spelling is + * not lucide's at all. Diagnostics only; never on a render path. + * + * Returns BOTH halves because they can differ and only one of them is safe to + * copy into metadata: + * + * - `key` is lucide's own current name — the live `icons` record key, derived + * by identity from the module the retired spelling still loads. + * - `spelling` is a name this module can actually RENDER: the record key's + * kebab spelling as lucide itself spells it, found in the map rather than + * computed from the key. ⚠️ Computing it would be wrong for 16 of the 243 + * live replacements, all of them digit-bearing — `Grid2x2` kebabs to + * `grid2x2` while lucide's spelling is `grid-2x2`, so an author told to + * "write Grid2x2" would land on a name that passes membership and then + * fails to load. + */ +async function currentNameFor(kebab: string): Promise<{ key: string; spelling: string } | null> { + try { + const module = await loadLucideDynamic(); + const map = module.dynamicIconImports as Record Promise> | undefined; + const loader = map?.[kebab]; + if (typeof loader !== 'function') return null; + const icon = (await loader()) as { default?: unknown }; + const key = liveIconNameOf(icon?.default); + if (!key) return null; + const spelling = Object.keys(map!).find( + (candidate) => candidate !== kebab && describeIconLookup(candidate).key === key, + ); + return { key, spelling: spelling ?? key }; + } catch { + // A diagnostic that cannot be completed is still not a failure of the + // render it describes. Say what is known rather than nothing. + return null; + } +} + +/** + * Whether `name` (kebab-case or PascalCase) resolves to a live Lucide icon. * * Exported because `getLazyIcon` degrades an unknown name to the `Database` * icon, which is the right default for a data-shaped schema slot but wrong * where a caller has a BETTER fallback of its own — a notification, for * instance, would rather show its severity icon than a stray database glyph. * Ask first, then choose. + * + * A non-empty name that answers `false` is REFUSED out loud (see + * `refuseIconName`): choosing the severity glyph over an authored icon is one + * of the two silent degrades objectui#9204's ruling ended, and the caller's + * better fallback is exactly what used to hide it. */ export function isLucideIconName(name?: string): boolean { - return !!name && isLucideIcon(toKebabIconName(name)); + if (!name) return false; + if (isLucideIcon(toKebabIconName(name))) return true; + refuseIconName(name); + return false; } /* -------------------------------------------------------------------------- */ @@ -106,6 +237,25 @@ function loadLucideDynamic(): Promise { return dynamicRequest; } +/** + * Every icon name this module can RENDER, in lucide's own kebab spelling. + * + * Two vocabularies meet here and neither answers alone: the `icons` record says + * which glyphs are LIVE, and the dynamic map says how each is SPELLED for + * `DynamicIcon`. The intersection is the answer — the live glyphs, spelled the + * way `getLazyIcon` can actually load them — and it is derived on demand from + * the installed lucide rather than kept as a list. ⛔ A generated catalogue was + * measured and rejected: it costs more gzipped than the map it replaces + * (objectui#9204). + * + * Async because it needs the deferred map, which is the point: a picker asks + * for it when it opens, so nothing about a name PICKER reaches the eager path. + */ +export async function loadLucideIconNames(): Promise { + const module = await loadLucideDynamic(); + return Object.keys(module.dynamicIconImports).filter((name) => isLucideIcon(name)); +} + /** * `DynamicIcon` behind an `import()`, with the caller's fallback showing until * it lands. @@ -152,10 +302,10 @@ const cache = new Map(); /** * Resolve a Lucide icon by name (kebab-case or PascalCase). * Returns a memoised React component that lazily loads the SVG on mount. - * Falls back to the `Database` icon when no `name` is provided or when the - * requested name is not a valid Lucide icon (server-driven schemas often - * reference icons from other libraries — we silently degrade rather than - * letting Lucide log "Name in Lucide DynamicIcon not found"). + * Falls back to the `Database` icon when no `name` is provided, or when the + * requested name is not a live Lucide icon — server-driven schemas do reference + * icons from other libraries, so the slot still renders something, but the name + * is REFUSED OUT LOUD rather than silently degraded (objectui#9204). */ export function getLazyIcon(name?: string): React.ElementType { if (!name) return Database; @@ -163,6 +313,7 @@ export function getLazyIcon(name?: string): React.ElementType { if (cached) return cached; const kebab = toKebabIconName(name); if (!isLucideIcon(kebab)) { + refuseIconName(name); cache.set(name, Database); return Database; } @@ -177,7 +328,10 @@ export function getLazyIcon(name?: string): React.ElementType { export const LazyIcon: React.FC<{ name?: string } & Record> = ({ name, ...rest }) => { if (!name) return React.createElement(Database, rest); const kebab = toKebabIconName(name); - if (!isLucideIcon(kebab)) return React.createElement(Database, rest); + if (!isLucideIcon(kebab)) { + refuseIconName(name); + return React.createElement(Database, rest); + } return React.createElement(DeferredLucideIcon, { name: kebab, fallback: Database, diff --git a/packages/components/src/lib/lucide-icon-names.ts b/packages/components/src/lib/lucide-icon-names.ts deleted file mode 100644 index 6c8fbfb890..0000000000 --- a/packages/components/src/lib/lucide-icon-names.ts +++ /dev/null @@ -1,2064 +0,0 @@ -/** - * 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 - * 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 - * - * `../__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/packages/components/src/renderers/action/resolve-icon.ts b/packages/components/src/renderers/action/resolve-icon.ts index 1fecd0e57d..e26da736d2 100644 --- a/packages/components/src/renderers/action/resolve-icon.ts +++ b/packages/components/src/renderers/action/resolve-icon.ts @@ -130,3 +130,57 @@ export function resolveIcon(name: string | undefined): LucideIcon | null { if (!name) return null; return (icons as Record)[describeIconLookup(name).key] ?? null; } + +/** + * The live record key whose component is `icon`, for DIAGNOSTICS only. + * + * ## Why this exists, and why it is a search rather than a table + * + * lucide retires a spelling by dropping it from the runtime `icons` record + * while keeping the deprecated named export — and the retired export and its + * live spelling are THE SAME OBJECT (`Smile === FaceSlightlySmiling` is + * `true`). So the replacement for a retired name is derivable by identity, and + * `scripts/check-lucide-icon-record-names.mjs` already derives it that way at + * gate time, in as many words: "When it has to name a replacement it derives + * one, by identity: the retired export and its live spelling are the same + * object, so the live key is looked up in the record rather than remembered." + * + * objectui#9204 needs the same answer at RUNTIME, because a retired spelling is + * now refused rather than silently degraded and the refusal has to name the + * current spelling. ⛔ A hand-kept retired-to-live table is not the way to get + * it: it is the same defect one level up — it ages the moment lucide retires + * the next name, it ages SILENTLY, and in this package it would also land on + * the eager path the same card is emptying. This searches the record the caller + * already pays for. + * + * ## ⚠️ Identity is the FIRST answer, not the only one — measured + * + * Identity holds only while both halves came from the same module instance, and + * that is not guaranteed: reached through `lucide-react/dynamic.mjs` the icon + * module can resolve into a DIFFERENT graph from the one `icons` came from, and + * then `===` is false for two components that are the same icon. Measured under + * this repo's vitest: `icons.FaceSlightlySmiling === (await + * dynamicIconImports['smile']()).default` is `false`, while both carry + * `displayName: 'FaceSlightlySmiling'`. + * + * So the fallback is lucide's own `displayName`, which `createLucideIcon` sets + * from the icon's file name — still DERIVED from lucide and still not a table. + * ⛔ It is not trusted blindly: a name is returned only after it is confirmed to + * be a live key of the record, so an unknown component cannot name itself into + * an answer. + * + * ⛔ It decides nothing. Nothing resolves differently because of it; `null` here + * only means the diagnostic omits a replacement. + */ +export function liveIconNameOf(icon: unknown): string | null { + if (!icon) return null; + const record = icons as Record; + for (const [key, component] of Object.entries(record)) { + if (component === icon) return key; + } + const declared = (icon as { displayName?: unknown }).displayName; + if (typeof declared === 'string' && Object.prototype.hasOwnProperty.call(record, declared)) { + return declared; + } + return null; +} diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index cfa8dc27f0..b0c261ea1f 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -854,7 +854,20 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { */ describe('a declared row', () => { const CEILING = PER_CHUNK_GZIP_CEILINGS['ui-components']; - const ALLOWANCE = EXHAUSTED_HEADROOM_ALLOWANCES['ui-components']; + /** + * ⭐ A FIXTURE allowance, not a live one — `EXHAUSTED_HEADROOM_ALLOWANCES` + * has been empty since objectui#9204 paid `ui-components` off, and an + * empty table would make every row below vacuous. A mechanism nobody + * exercises is a mechanism nobody can trust the next time a row has to be + * declared, and this describe block is the only place the ratchet's + * hinge is taken at the byte. + * + * 4,289 is kept deliberately: it is the figure `ui-components` actually + * carried, so these rows still sit on a measurement rather than on a + * round number invented for a test. + */ + const ALLOWANCE = 4_289; + const DECLARED = { 'ui-components': ALLOWANCE }; const GRAIN = REGRESSION_THIS_GATE_MUST_CATCH_BYTES * EXHAUSTED_HEADROOM_ALLOWANCE_GRANULARITY_MULTIPLE; @@ -862,6 +875,7 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { const atHeadroom = (headroom: number) => evaluateHeadroomSensitivity({ report: sensitivityReport(BASELINE.gzipBytes, { 'ui-components': CEILING - headroom }), + allowances: DECLARED, }); it('is held open at its pinned figure', () => { @@ -930,7 +944,7 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { const at = (headroom: number, allowance: number) => evaluateHeadroomSensitivity({ report: sensitivityReport(BASELINE.gzipBytes, { 'ui-components': CEILING - headroom }), - allowances: { ...EXHAUSTED_HEADROOM_ALLOWANCES, 'ui-components': allowance }, + allowances: { 'ui-components': allowance }, }).status; expect(at(Math.floor(paidDown - GRAIN), paidDown)).toBe('error'); @@ -941,17 +955,34 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { it('names every declared row in the PASSING verdict, not only when one fires', () => { // A debt list that is only legible on the run that reds is the parenthetical - // this card is about: noticing stays manual, and it already failed twice. + // objectui#8554 is about: noticing stays manual, and it already failed twice. + // Driven from a FIXTURE table because the live one is empty (objectui#9204) + // — the renderer's job does not go away with the last declared row. + const declared = { 'ui-components': 4_289 }; const result = evaluateHeadroomSensitivity({ report: sensitivityReport(BASELINE.gzipBytes), + allowances: declared, }); expect(result.status).toBe('pass'); - for (const [name, allowance] of Object.entries(EXHAUSTED_HEADROOM_ALLOWANCES)) { + for (const [name, allowance] of Object.entries(declared)) { expect(result.message).toContain(`chunk \`${name}\``); expect(result.message).toContain(`declared ${allowance}-byte allowance`); } }); + it('says nothing about allowances when the table is empty — the live state', () => { + // The control for the row above, and the assertion the empty table earns: + // with nothing declared, no row may carry the declared-allowance clause, + // because that clause asserts a row is under the floor. + const result = evaluateHeadroomSensitivity({ + report: sensitivityReport(BASELINE.gzipBytes), + allowances: EXHAUSTED_HEADROOM_ALLOWANCES, + }); + expect(result.status).toBe('pass'); + expect(result.message).not.toContain('declared'); + expect(result.message).not.toContain('held open by'); + }); + /** * The allowance table is a RATCHET, and the whole of its ratchet-ness is * that these numbers can only be paid down. Nothing in the runtime can @@ -959,23 +990,31 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { * enforcement: an edit in either direction has to come here and be argued. */ describe('the allowance table is a ratchet, pinned', () => { - it('holds exactly the rows measured under the floor on the day it landed', () => { - // ⚠️ `i18n-locales: 8_804` was here until objectui#7479 and is REMOVED, - // not lowered: its chunk ceased to exist when nine of the ten - // catalogues became `import()`ed, and the one that stays is budgeted - // under `i18n-locale-en` at a headroom ABOVE the floor, needing no - // allowance. That is the only way a row leaves this table. - expect(EXHAUSTED_HEADROOM_ALLOWANCES).toEqual({ - 'ui-components': 4_289, - }); + it('is EMPTY — both rows it carried were paid off, never lowered', () => { + // ⚠️ Two removals, one rule. `i18n-locales: 8_804` left in objectui#7479 + // when nine of its ten catalogues became `import()`ed and the one that + // stays cleared the floor under `i18n-locale-en`. `ui-components: 4_289` + // left in objectui#9204 when lucide's dynamic-import map left the eager + // path and the row went 397,090 -> 388,494 gzipped, headroom 0.02x -> + // 0.12x. Neither figure was LOWERED — a lowered figure is headroom + // supplied to a row that still needs it, which is the one edit this + // table forbids. Clearing the floor on its own is the only way out. + expect(EXHAUSTED_HEADROOM_ALLOWANCES).toEqual({}); }); it('every entry is real debt — strictly under the floor it excuses', () => { // An allowance at or above the floor is not debt, it is a second floor - // for one row, and the row should simply have been dropped from here. + // for one row — and a WEAKER one, which is why `ui-components` had to + // leave rather than be re-pinned upward once it cleared the floor. for (const allowance of Object.values(EXHAUSTED_HEADROOM_ALLOWANCES)) { expect(allowance).toBeLessThan(FLOOR); } + // ⭐ The control. The loop above is vacuous while the table is empty, and + // a vacuous loop reads exactly like a satisfied one. This states the + // predicate on the figure the table actually carried and on the one it + // could not: 4,289 was admissible, the floor itself never is. + expect(4_289).toBeLessThan(FLOOR); + expect(FLOOR).not.toBeLessThan(FLOOR); }); it('is compared at the coarser of the two grids this gate renders on', () => { @@ -999,10 +1038,12 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { expect(grain).toBeGreaterThan(1); expect(grain).toBeLessThan(floor); // Every declared row must still have a reachable trip point above zero, - // or its entry would be decorative. + // or its entry would be decorative. Vacuous while the table is empty, so + // the figure the table last carried stands as the control. for (const allowance of Object.values(EXHAUSTED_HEADROOM_ALLOWANCES)) { expect(allowance - grain).toBeGreaterThan(0); } + expect(4_289 - grain).toBeGreaterThan(0); }); it('every entry names a ceiling that exists', () => { diff --git a/scripts/__tests__/check-lucide-icon-record-names.test.ts b/scripts/__tests__/check-lucide-icon-record-names.test.ts index e464c3a5f5..8f943e19ce 100644 --- a/scripts/__tests__/check-lucide-icon-record-names.test.ts +++ b/scripts/__tests__/check-lucide-icon-record-names.test.ts @@ -766,26 +766,6 @@ describe('the surface census is re-derived on every run', () => { 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 whole map', () => { // lucide derives `iconNames` from `dynamicIconImports`, so this import puts // the 2,025-entry map in the importer's chunk. Nothing else in the tree diff --git a/scripts/__tests__/gen-lucide-icon-names.test.ts b/scripts/__tests__/gen-lucide-icon-names.test.ts deleted file mode 100644 index 2687086b7a..0000000000 --- a/scripts/__tests__/gen-lucide-icon-names.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 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-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 895a2c64ca..1208140dd2 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -1182,7 +1182,27 @@ export const PER_CHUNK_BASELINE = Object.freeze({ // BASELINE's. Moved with the ceiling in the same commit, per the maintainer // ruling of 2026-09-08 and the rule stated under "Raising one". framework: 72_245, - 'ui-components': 391_095, + // ⭐ RE-PINNED DOWN by objectui#9204, on its OWN console build at `0ebb1bf1c` + // — ⛔ not with a ceiling move, and that exception is the whole reason this + // line has a comment. It supersedes `2c8474c04`'s 391,095 (objectui#5490). + // + // The ceiling did NOT move and must not: 399,000 still stands over this row, + // unchanged since #5490. What moved is the PAYLOAD — lucide's dynamic-import + // map left the eager path when icon-name membership moved onto the `icons` + // record this chunk already carries (maintainer ruling of 2026-09-13). The + // row went 397,090 -> 388,494 gzipped, measured on two console builds in one + // container, and the aggregate fell 8,637 against the row's 8,596, which is + // what makes it bytes LEAVING the page load rather than moving between + // columns. + // + // ⚠️ Re-pinning was not optional here, and ⛔ not cosmetic. This constant is + // what the unit test builds its sensitivity reports from, so leaving it at + // 391,095 while `ui-components` left {@link EXHAUSTED_HEADROOM_ALLOWANCES} + // would have made the gate's own fixtures assert a row 1,209 bytes UNDER the + // floor that the live build clears — the allowance and this figure are the + // pair that has to move in one commit, exactly as a ceiling and its baseline + // do under "Raising one". + 'ui-components': 388_494, }); /** @@ -1290,16 +1310,38 @@ export const EXHAUSTED_HEADROOM_FLOOR_MULTIPLE = 0.1; * the comparison is made in and the reason a red here is a red a reader can see. */ export const EXHAUSTED_HEADROOM_ALLOWANCES = Object.freeze({ - // ⭐ `i18n-locales: 8_804` stood here until objectui#7479. It is REMOVED, not - // lowered, and the distinction is the whole of why that is allowed: the rule - // above forbids LOWERING a figure, because a lowered figure is headroom - // supplied to a row that still exists. This row's chunk does not exist any - // more — nine of the ten catalogues it weighed are `import()`ed on demand, and - // the one that stays is budgeted under its own key at a headroom of 0.11x, - // ABOVE the floor and needing no allowance at all. That is the debt PAID, in - // the only currency this table takes: the row cleared the floor on its own. - // ⛔ Re-adding a locale row here would mean the catalogues came back. - 'ui-components': 4_289, + // ⭐ EMPTY, and both rows that stood here left the same way — REMOVED, never + // lowered. The rule above forbids lowering a figure because a lowered figure + // is headroom supplied to a row that still exists; a removal is the opposite, + // because the row it excused now clears the floor on its own and goes back to + // being judged at 0.10x like every other. + // + // `i18n-locales: 8_804` left in objectui#7479: nine of the ten catalogues it + // weighed became `import()`ed on demand, and the one that stays is budgeted + // under `i18n-locale-en` at 0.11x. ⛔ Re-adding a locale row would mean the + // catalogues came back. + // + // `ui-components: 4_289` left in objectui#9204, and this is the case the + // paragraph above was written for — the debt PAID in the only currency this + // table takes. lucide's dynamic-import map left the console's eager path when + // icon-name membership moved onto the `icons` record that chunk already + // carries (maintainer ruling of 2026-09-13), taking the row from 397,090 to + // 388,494 gzipped and its headroom from 1,910 B (0.02x) to 10,506 B (0.12x), + // measured on two console builds in one container at `0ebb1bf1c`. + // + // ⚠️ ⭐ Removal was the ONLY legal move once that landed, and the unit test + // says so rather than this comment: "every entry is real debt — strictly + // under the floor it excuses". A figure at or above the floor is not debt, it + // is a second floor for one row — and a WEAKER one, since this row's declared + // trip point was 3,378 B against the floor's 9,114 B. Paying the row down and + // leaving the entry would have LOOSENED the gate on the chunk the payment was + // for, while the verdict text kept printing "under the 0.10x floor" about a + // row at 0.12x. + // + // ⛔ Nothing here was raised, ⛔ no ceiling moved, and ⛔ the table is not a + // supply of headroom now that it is empty: an undeclared row reds at 0.10x, + // which is what `ui-components` is judged at from here on — with 1,392 B of + // margin over the floor, a figure this card reports rather than pads. }); /** diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index 0b03eee351..d258a95ddf 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -43,15 +43,23 @@ * * ── HOW a site reaches DYNAMIC is also censused (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 - * 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 + * the names imports the 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. All four are gone: two were transcriptions of + * `getLazyIcon` and are now delegations, the icon picker asks + * `@object-ui/components` for the vocabulary, and the one module left 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: + * ⚠️ A GENERATED MIRROR OF THE NAMES IS NOT THE FIX, and the number is why: the + * map's KEYS are the names, so a bare catalogue of them measured 9,176 B gzipped + * spliced into that same chunk against the 8,253 B map it replaced (a + * front-coded encoding probed and rejected at 8,397 B). The names left the eager + * path by being ANSWERED FROM THE RECORD instead — the maintainer ruling of + * 2026-09-13, which also retires the 254 spellings the two vocabularies disagree + * about. + * + * That makes two spellings discovery has to see — a static import and an + * `import()` — 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. @@ -312,24 +320,19 @@ export const DECLARED_RECORD_READERS = [ 'packages/components/src/renderers/action/resolve-icon.ts', ]; -export const DECLARED_DYNAMIC_READERS = [ - '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. + * ⭐ ONE entry, and the one is the module that OWNS the deferred map. * - * - `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. + * Until objectui#9204 this list had four. Two were transcriptions of + * `getLazyIcon` and are now delegations to it; the fourth, the metadata + * designer's icon picker, asks `@object-ui/components` for the vocabulary + * instead of importing `iconNames` itself. What is left reaches the surface + * only through `import('lucide-react/dynamic.mjs')`, and it reads it for the + * SPELLINGS — which glyph is LIVE is answered by the record, above. */ -export const DYNAMIC_CATALOGUE_BINDING = 'LUCIDE_ICON_NAMES'; +export const DECLARED_DYNAMIC_READERS = [ + 'packages/components/src/lib/lazy-icon.tsx', +]; /** `lucide-react/dynamic`, `lucide-react/dynamic.mjs`, `…/dynamic.js`. */ export const isDynamicEntrySpecifier = (specifier) => specifier.startsWith('lucide-react/dynamic'); @@ -339,11 +342,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 2,025-entry dynamic-import map in the importer's chunk + * EITHER export puts the whole 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. + * membership now comes from the `icons` record 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 @@ -839,10 +842,7 @@ export function discoverResolvers(root, files) { for (const file of files) { if (isTestPath(file)) continue; const text = readFileSync(join(root, file), 'utf8'); - // 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; + if (!text.includes('lucide-react')) continue; const sf = parseSource(root, file); let recordLocal = null; let readsDynamic = false; @@ -858,11 +858,6 @@ export function discoverResolvers(root, files) { if (!bindings || !ts.isNamedImports(bindings)) return; for (const element of bindings.elements) { 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; } }); @@ -1158,9 +1153,10 @@ 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' - + ' 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.', + + ' whole dynamic-import map in this module\'s chunk — 8,253 B gzipped on the console\'s eager\n' + + ' path (objectui#9204). Ask `isLucideIconName` / `loadLucideIconNames` (@object-ui/components) instead:\n' + + ' membership comes from the `icons` record and the map loads through `import()`, the way\n' + + ' `packages/components/src/lib/lazy-icon.tsx` does.', ); } diff --git a/scripts/gen-lucide-icon-names.mjs b/scripts/gen-lucide-icon-names.mjs deleted file mode 100644 index cdd8dd73ed..0000000000 --- a/scripts/gen-lucide-icon-names.mjs +++ /dev/null @@ -1,130 +0,0 @@ -#!/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 - * 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 - * 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 - * 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 - * - * \`../__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 d43f51ee17e7d5fa70273142704a616db063c56b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 10:50:51 +0000 Subject: [PATCH 4/6] fix(app-shell): let the icon picker say it is LOADING, not "no matching icons" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#9204 follow-through. The picker's vocabulary became asynchronous when the icon names left the eager path, so the grid is empty for one frame after the dialog opens. The existing empty state said "No matching icons." — a false answer to a query nobody had run yet. It now distinguishes the two states with literal i18n keys on both arms, reusing `engine.form.loadingOptions` rather than adding a key that would owe every locale a translation. Its tests follow the same split: the trigger stays synchronous (`isLucideIconName` reads the `icons` record and needs nothing loaded) so it is asserted synchronously; the grid is awaited. `lucide-react/dynamic.mjs` is imported at module scope so the fetch is paid for in the import phase rather than out of a `findBy` budget under a saturated transform pipeline. Adds one row the old file could not have: the picker offers `list-filter` and NOT `filter`, so the grid and `getLazyIcon` are pinned to one vocabulary. A pickable name the renderer then refuses would be the worst shape this card could ship. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../metadata-admin/IconPickerWidget.test.tsx | 60 ++++++++++++++++--- .../src/views/metadata-admin/widgets.tsx | 24 +++++++- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/IconPickerWidget.test.tsx b/packages/app-shell/src/views/metadata-admin/IconPickerWidget.test.tsx index ffe05e9900..1ad4b5db07 100644 --- a/packages/app-shell/src/views/metadata-admin/IconPickerWidget.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/IconPickerWidget.test.tsx @@ -1,7 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, afterEach, vi } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +// The grid's vocabulary arrives through `import('lucide-react/dynamic.mjs')` +// (objectui#9204). Importing that module here, at module scope, pays for it in +// the import phase — which is under no test or hook timeout — instead of +// letting a saturated transform pipeline spend a `findBy` budget on it. +import 'lucide-react/dynamic.mjs'; import { WIDGETS } from './widgets'; afterEach(cleanup); @@ -11,9 +16,15 @@ const Icon = WIDGETS['icon']; /** * `icon` widget — a searchable Lucide icon picker for page/app/object `icon` * fields (replaces the raw text input). Built inline (no Radix portal) so the - * trigger, search box and result grid all render eagerly in jsdom. The icon - * previews lazy-load their SVG chunk and degrade to a fallback glyph, so these - * tests assert on the catalog wiring rather than the rendered . + * trigger, search box and result grid all render without a portal in jsdom. The + * icon previews lazy-load their SVG chunk and degrade to a fallback glyph, so + * these tests assert on the catalog wiring rather than the rendered . + * + * ⚠️ The GRID is asynchronous since objectui#9204 — the vocabulary is lucide's + * dynamic-import map, fetched when the dialog opens rather than held on the + * eager path. The trigger is not: it asks `isLucideIconName`, which reads the + * `icons` record and needs nothing loaded. That split is why the rows below + * await the options but not the combobox. */ describe('icon widget', () => { it('is registered in the WIDGETS map', () => { @@ -27,34 +38,67 @@ describe('icon widget', () => { expect(trigger).toHaveTextContent('calendar'); }); - it('opens a search box and filters the icon grid by query', () => { + it('says it is loading before the catalogue lands, not "no matching icons"', () => { + // The synchronous frame. An empty grid mid-fetch must not read as a query + // that found nothing — that sentence would be a false answer to a question + // nobody has asked yet. + render( {}} schema={{ type: 'string' }} />); + fireEvent.click(screen.getByRole('combobox')); + expect(screen.queryAllByRole('option')).toHaveLength(0); + expect(screen.getByText('Loading options…')).toBeInTheDocument(); + expect(screen.queryByText('No matching icons.')).toBeNull(); + }); + + it('opens a search box and filters the icon grid by query', async () => { render( {}} schema={{ type: 'string' }} />); fireEvent.click(screen.getByRole('combobox')); const search = screen.getByLabelText('Search icons…'); - const before = screen.getAllByRole('option').length; + const before = (await screen.findAllByRole('option')).length; expect(before).toBeGreaterThan(0); fireEvent.change(search, { target: { value: 'ampersand' } }); + await waitFor(() => { + expect(screen.getAllByRole('option').length).toBeLessThan(before); + }); const after = screen.getAllByRole('option'); expect(after.length).toBeGreaterThan(0); - expect(after.length).toBeLessThan(before); // Every surviving option matches the query. for (const opt of after) { expect(opt.getAttribute('title')).toContain('ampersand'); } }); - it('writes the selected icon name through onChange', () => { + it('writes the selected icon name through onChange', async () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByRole('combobox')); + await screen.findAllByRole('option'); fireEvent.change(screen.getByLabelText('Search icons…'), { target: { value: 'ampersand' } }); + await waitFor(() => { + expect(screen.getAllByRole('option')[0]?.getAttribute('title')).toContain('ampersand'); + }); fireEvent.click(screen.getAllByRole('option')[0]); expect(onChange).toHaveBeenCalledTimes(1); expect(String(onChange.mock.calls[0][0])).toContain('ampersand'); }); + it('offers only names the renderer will resolve — the vocabulary is the record', async () => { + // objectui#9204: the picker and `getLazyIcon` read ONE vocabulary now. A + // retired spelling in this grid would be an author-facing trap — pickable, + // then refused at render time. + render( {}} schema={{ type: 'string' }} />); + fireEvent.click(screen.getByRole('combobox')); + fireEvent.change(screen.getByLabelText('Search icons…'), { target: { value: 'filter' } }); + const options = await screen.findAllByRole('option'); + const titles = options.map((o) => o.getAttribute('title')); + // `filter` is a spelling lucide still LOADS but has dropped from the record + // (it is `funnel` now). ⭐ The control is `list-filter`: the query DOES match + // live names, so the absence below is a refusal rather than an empty grid. + expect(titles).toContain('list-filter'); + expect(titles).not.toContain('filter'); + }); + it('preserves an out-of-catalog value (renders it, offers a keep option)', () => { render( {}} schema={{ type: 'string' }} />); const trigger = screen.getByRole('combobox'); diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index 6f39fafdd3..b4266f824e 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -1582,8 +1582,15 @@ const EMPTY_CATALOGUE: readonly string[] = []; * plain text (LazyIcon degrades to a fallback glyph) and is offered as the first * "keep" option so re-opening the picker never silently drops it. * - * Built inline (no Radix portal) so the search + grid render eagerly — the same - * jsdom-friendly choice the other pickers' tests rely on. + * Built inline (no Radix portal) so the search + grid render without a portal — + * the same jsdom-friendly choice the other pickers' tests rely on. + * + * ⚠️ The GRID is asynchronous since objectui#9204: the icon vocabulary arrives + * from `loadLucideIconNames()` when the dialog opens, because the only list of + * renderable spellings lucide publishes is the dynamic-import map, and holding + * that eagerly is what put 8,253 gzipped bytes on the console's first payload. + * The trigger stays synchronous — `isLucideIconName` reads the `icons` record, + * which needs nothing loaded. */ export function IconPickerWidget({ id, value, onChange, readOnly }: WidgetProps) { const locale = useMetadataLocale(); @@ -1713,11 +1720,22 @@ export function IconPickerWidget({ id, value, onChange, readOnly }: WidgetProps) ); })} - {results.length === 0 && ( + {results.length === 0 && catalogue.length > 0 && (

{t('engine.form.noMatchingIcons', locale)}

)} + {/* ⚠️ The two empty states are NOT the same sentence. An empty grid + while the catalogue is still in flight is not "no matching + icons" — that reading would tell an author their query found + nothing when nothing had been searched yet. Literal keys on both + arms, because the i18n call-site gate reads the key, not the + expression that chose it. */} + {results.length === 0 && catalogue.length === 0 && ( +

+ {t('engine.form.loadingOptions', locale)} +

+ )} {truncated && ( From 72764a803655cba4320c03e76b7e49dafd53da27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 10:57:04 +0000 Subject: [PATCH 5/6] chore(scripts): re-pin the `ui-components` baseline to the final measured build objectui#9204. The picker's loading state moved the row 81 bytes after the previous pin, so the constant, the changeset and the gate test's comment all name the build that actually produced them: `d43f51ee1`, 388,575 gzipped. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .changeset/9204-icon-names-from-the-record.md | 4 ++-- .../__tests__/check-eager-closure-budget.test.ts | 4 ++-- scripts/check-eager-closure-budget.mjs | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.changeset/9204-icon-names-from-the-record.md b/.changeset/9204-icon-names-from-the-record.md index fda7cb320d..9268c1ea3f 100644 --- a/.changeset/9204-icon-names-from-the-record.md +++ b/.changeset/9204-icon-names-from-the-record.md @@ -33,7 +33,7 @@ read. Measured against the installed lucide, that retires **254 spellings** — because lucide derives the names as that map's keys. Four modules did, and the map sat in the console's eager `ui-components` chunk on every page load. Sourcing membership from the record — which the same chunk already carries — takes -**8,596 gzipped bytes** off that chunk and **8,637** off the whole eager closure, +**8,515 gzipped bytes** off that chunk and **8,520** off the whole eager closure, measured on two console builds in one container. The `ui-components` row goes -from 1,910 B of headroom (0.02x, red) to 10,506 B (0.12x, green), which pays off +from 1,910 B of headroom (0.02x, red) to 10,425 B (0.11x, green), which pays off the declared allowance that row has been carrying. diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index b0c261ea1f..37b2d551d0 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -995,8 +995,8 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { // when nine of its ten catalogues became `import()`ed and the one that // stays cleared the floor under `i18n-locale-en`. `ui-components: 4_289` // left in objectui#9204 when lucide's dynamic-import map left the eager - // path and the row went 397,090 -> 388,494 gzipped, headroom 0.02x -> - // 0.12x. Neither figure was LOWERED — a lowered figure is headroom + // path and the row went 397,090 -> 388,575 gzipped, headroom 0.02x -> + // 0.11x. Neither figure was LOWERED — a lowered figure is headroom // supplied to a row that still needs it, which is the one edit this // table forbids. Clearing the floor on its own is the only way out. expect(EXHAUSTED_HEADROOM_ALLOWANCES).toEqual({}); diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 1208140dd2..eac9d6b0ef 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -1182,7 +1182,7 @@ export const PER_CHUNK_BASELINE = Object.freeze({ // BASELINE's. Moved with the ceiling in the same commit, per the maintainer // ruling of 2026-09-08 and the rule stated under "Raising one". framework: 72_245, - // ⭐ RE-PINNED DOWN by objectui#9204, on its OWN console build at `0ebb1bf1c` + // ⭐ RE-PINNED DOWN by objectui#9204, on its OWN console build at `d43f51ee1` // — ⛔ not with a ceiling move, and that exception is the whole reason this // line has a comment. It supersedes `2c8474c04`'s 391,095 (objectui#5490). // @@ -1190,8 +1190,8 @@ export const PER_CHUNK_BASELINE = Object.freeze({ // unchanged since #5490. What moved is the PAYLOAD — lucide's dynamic-import // map left the eager path when icon-name membership moved onto the `icons` // record this chunk already carries (maintainer ruling of 2026-09-13). The - // row went 397,090 -> 388,494 gzipped, measured on two console builds in one - // container, and the aggregate fell 8,637 against the row's 8,596, which is + // row went 397,090 -> 388,575 gzipped, measured on two console builds in one + // container, and the aggregate fell 8,520 against the row's 8,515, which is // what makes it bytes LEAVING the page load rather than moving between // columns. // @@ -1199,10 +1199,10 @@ export const PER_CHUNK_BASELINE = Object.freeze({ // what the unit test builds its sensitivity reports from, so leaving it at // 391,095 while `ui-components` left {@link EXHAUSTED_HEADROOM_ALLOWANCES} // would have made the gate's own fixtures assert a row 1,209 bytes UNDER the - // floor that the live build clears — the allowance and this figure are the + // floor that the live build clears by 1,311 — the allowance and this figure are the // pair that has to move in one commit, exactly as a ceiling and its baseline // do under "Raising one". - 'ui-components': 388_494, + 'ui-components': 388_575, }); /** @@ -1326,8 +1326,8 @@ export const EXHAUSTED_HEADROOM_ALLOWANCES = Object.freeze({ // table takes. lucide's dynamic-import map left the console's eager path when // icon-name membership moved onto the `icons` record that chunk already // carries (maintainer ruling of 2026-09-13), taking the row from 397,090 to - // 388,494 gzipped and its headroom from 1,910 B (0.02x) to 10,506 B (0.12x), - // measured on two console builds in one container at `0ebb1bf1c`. + // 388,575 gzipped and its headroom from 1,910 B (0.02x) to 10,425 B (0.11x), + // measured on two console builds in one container at `d43f51ee1`. // // ⚠️ ⭐ Removal was the ONLY legal move once that landed, and the unit test // says so rather than this comment: "every entry is real debt — strictly @@ -1340,7 +1340,7 @@ export const EXHAUSTED_HEADROOM_ALLOWANCES = Object.freeze({ // // ⛔ Nothing here was raised, ⛔ no ceiling moved, and ⛔ the table is not a // supply of headroom now that it is empty: an undeclared row reds at 0.10x, - // which is what `ui-components` is judged at from here on — with 1,392 B of + // which is what `ui-components` is judged at from here on — with 1,311 B of // margin over the floor, a figure this card reports rather than pads. }); From 5fc5e75c2f1cc57e726557c43dd9ef767ffcfb1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 11:14:31 +0000 Subject: [PATCH 6/6] fix(scripts): declare the two types this round's edits made wrong, at the declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#9204. `pnpm type-check:scripts` (`tsc -p tsconfig.scripts.json`) was red on two files in this branch's diff. Both errors landed on a TEST, and in both cases the thing that was actually wrong was a DECLARATION in a `.mjs` that this project reads for inference but does not check (`checkJs: false`) — so the error surfaced at the one place the defect was not. 1. `check-lucide-icon-record-names.mjs` — `AnalyzeOptions` did not list `declaredEagerDynamicImporters`, which `analyze()` destructures, defaults and uses. The census exists to be substituted, exactly like the two siblings the typedef does list: the unit test's fixture row for the eager-import rule has to declare a fixture importer. ⇒ the PRODUCTION signature carries it; the test was right to pass it. Dropping it from the test instead would have left one census non-substitutable and made that row assert against production state rather than a fixture. 2. `check-eager-closure-budget.mjs` — `EXHAUSTED_HEADROOM_ALLOWANCES` is empty since this branch paid `ui-components` off, and `Object.freeze({})` infers `Readonly<{}>`, so `Object.values` of it went from `number[]` to `unknown[]`. A reader doing arithmetic on an allowance stopped compiling because the table was EMPTY, not because anything about it was wrong. The element type is a property of what the table holds, not of how many rows it holds today, so it is annotated. `DECLARED_EAGER_DYNAMIC_IMPORTERS` gets the same treatment for the same reason — its emptiness IS its assertion, and `never[]` would erase the element type of a list that is declared empty on purpose. ⛔ No `as any`, no `@ts-ignore`, no `@ts-expect-error`, and no behaviour changed: both edits are JSDoc on declarations. `pnpm type-check:scripts` exit 2 -> exit 0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- scripts/check-eager-closure-budget.mjs | 11 +++++++++++ scripts/check-lucide-icon-record-names.mjs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index eac9d6b0ef..625c509272 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -1309,6 +1309,17 @@ export const EXHAUSTED_HEADROOM_FLOOR_MULTIPLE = 0.1; * {@link EXHAUSTED_HEADROOM_ALLOWANCE_GRANULARITY_MULTIPLE}, which is the unit * the comparison is made in and the reason a red here is a red a reader can see. */ +/** + * ⚠️ Annotated rather than inferred, and objectui#9204 is why. While this table + * carried a row, `Object.values` of it was `number[]`; the moment the last row + * was paid off, `Object.freeze({})` inferred `Readonly<{}>` and that same call + * became `unknown[]` — so a reader doing arithmetic on an allowance stopped + * compiling because the table was EMPTY, not because anything about it was + * wrong. The element type is a property of what this table holds, not of how + * many rows it holds today. + * + * @type {Readonly>} + */ export const EXHAUSTED_HEADROOM_ALLOWANCES = Object.freeze({ // ⭐ EMPTY, and both rows that stood here left the same way — REMOVED, never // lowered. The rule above forbids lowering a figure because a lowered figure diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index d258a95ddf..bf846285cd 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -353,6 +353,15 @@ export const isDynamicEntrySpecifier = (specifier) => specifier.startsWith('luci * 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. */ +/** + * ⚠️ Annotated rather than inferred. An empty array literal infers `never[]`, + * so the element type of this census would be erased by the very emptiness that + * IS its assertion — and the next reader of it would get `never` instead of a + * path. The declared type is the one this list carries when a row has to be + * added, not the one it happens to have while it is empty. + * + * @type {readonly string[]} + */ export const DECLARED_EAGER_DYNAMIC_IMPORTERS = []; /** @@ -1108,10 +1117,19 @@ function judgeAnchoredMaps(root, anchors) { * * @typedef {{ paths: string[], resolver: string, descendants?: boolean, min?: number }} RecordReadingType * + * ⚠️ EVERY census this function reads has to appear here, and objectui#9204 + * proved that the hard way: `declaredEagerDynamicImporters` was implemented, + * defaulted and used below while this typedef did not list it, so the unit + * test's fixture census was a type error at the CALL SITE and the declaration + * that was actually wrong sat in a `.mjs` that `tsconfig.scripts.json` does not + * check (`checkJs: false`). The error therefore lands on the caller, which is + * the one place the omission is not. + * * @typedef {{ * anchors?: readonly any[], * declaredRecordReaders?: readonly string[], * declaredDynamicReaders?: readonly string[], + * declaredEagerDynamicImporters?: readonly string[], * negativeControl?: string, * recordReadingTypes?: Record, * }} AnalyzeOptions