diff --git a/.changeset/9204-lucide-dynamic-map-off-the-eager-path.md b/.changeset/9204-lucide-dynamic-map-off-the-eager-path.md new file mode 100644 index 0000000000..00df7cf075 --- /dev/null +++ b/.changeset/9204-lucide-dynamic-map-off-the-eager-path.md @@ -0,0 +1,43 @@ +--- +'@object-ui/components': minor +'@object-ui/app-shell': minor +--- + +Take lucide's dynamic-import map off the eager path (objectui#9204). + +`lucide-react/dynamic.mjs` publishes `iconNames` as `Object.keys(dynamicIconImports)` +— a list derived at module init from a 120,683-byte import map. Four modules imported +that specifier, three of them for the names alone, so every page load paid for the map. +Beside it, `renderers/action/resolve-icon.ts` indexes lucide's `icons` record, and a +namespace object has no dead members: every icon module was already eager in the same +chunk. `DynamicIcon` was therefore `import()`-ing modules that were already loaded — +laziness that bought nothing and cost the map. + +The forgiving icon vocabulary is now REBUILT from the record's own keys, plus a +generated table of the 264 names no key can produce (retired spellings such as +`alert-triangle`, and lucide's alternate digit spellings such as `arrow-down-0-1`), +each mapped to its live record key by object identity. `LazyIcon` / `getLazyIcon` / +`isLucideIconName` accept **exactly** the same 2,039 names as before — asserted in both +directions against the installed lucide by a drift test, so a lucide bump cannot narrow +what the renderer draws in silence. + +Measured on one pair of console builds at `69aa9c017`, read from +`apps/console/dist/eager-closure.json`: + + chunk `ui-components` 397,090 -> 353,658 gzipped -43,432 + eager closure 3,180,382 -> 3,136,585 -43,797 + eager / total chunks 52/528 -> 51/527 + +**Behaviour.** Icon resolution is now synchronous: `getLazyIcon(name)` returns lucide's +own component rather than a `DynamicIcon` wrapper, so an icon paints on first render +instead of after a chunk fetch, and there are no per-icon micro-chunks to request. The +exported API is unchanged, and unknown names still degrade to the `Database` glyph. + +**New export.** `lucideIconNames()` on `@object-ui/components` returns the vocabulary as +a sorted string array — the list `app-shell`'s metadata-admin icon picker used to take +from `lucide-react/dynamic.mjs`. + +**Consolidation.** `@object-ui/app-shell`'s `utils/getIcon` and the console's were +transcriptions of the same resolver, each with its own tokeniser, its own `Set` over +lucide's map and its own memo. Both are now re-exports of `getLazyIcon`: one resolver, +one vocabulary, one memo. diff --git a/apps/console/src/utils/getIcon.ts b/apps/console/src/utils/getIcon.ts index 5ecf906246..75505fc955 100644 --- a/apps/console/src/utils/getIcon.ts +++ b/apps/console/src/utils/getIcon.ts @@ -1,41 +1,18 @@ /** * Icon utilities * - * Synchronous accessor that returns a lazy-loaded Lucide icon React - * component. Wraps lucide-react's `DynamicIcon` so we don't bloat the - * vendor bundle by statically importing the entire icon namespace. - */ - -import React from 'react'; -import { Database } from 'lucide-react'; -import { DynamicIcon } from 'lucide-react/dynamic'; - -function toKebab(name: string): string { - if (name.includes('-')) return name.toLowerCase(); - return name - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') - .toLowerCase(); -} - -const cache = new Map(); - -/** - * Resolve a Lucide icon component by name. + * ⛔ NOT a resolver. This is a re-export of `@object-ui/components`' + * `getLazyIcon`, kept only so the call sites in this app can go on saying + * `getIcon(name)`. * - * The result is memoised per name in the module-level `cache`, so call sites - * get a *stable* component reference across renders — nothing is created during - * render. `react-hooks/static-components` cannot see through the call, so the - * JSX sites that render the result carry a targeted disable pointing back here. + * It used to wrap `lucide-react/dynamic.mjs`'s `DynamicIcon` directly, with no + * membership check at all, which put that module's 120,683-byte import map on + * the eager path for a lookup the shared resolver already does — and did it + * against a vocabulary this copy never consulted (objectui#9204). + * + * The result is still memoised per name in the shared resolver, so call sites + * still get a stable component reference across renders; the targeted + * `react-hooks/static-components` disables at those sites point back here. */ -export function getIcon(name?: string): React.ElementType { - if (!name) return Database; - const cached = cache.get(name); - if (cached) return cached; - const kebab = toKebab(name); - const Wrapped: React.FC = (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 cdc05fa084..98c453f486 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "check:action-forward-parity": "node scripts/check-action-forward-parity.mjs", "check:designer-field-key-parity": "node scripts/check-designer-field-key-parity.mjs", "check:icon-record-names": "node scripts/check-lucide-icon-record-names.mjs", + "gen:lucide-aliases": "node scripts/gen-lucide-dynamic-name-aliases.mjs", + "check:lucide-aliases": "node scripts/gen-lucide-dynamic-name-aliases.mjs --check", "check:phantom-deps": "node scripts/check-phantom-dependencies.mjs", "check:unused-deps": "node scripts/check-unused-dependencies.mjs", "check:self-import": "node scripts/check-package-self-import.mjs", diff --git a/packages/app-shell/src/utils/getIcon.ts b/packages/app-shell/src/utils/getIcon.ts index e98ed6ec50..fe1466ef82 100644 --- a/packages/app-shell/src/utils/getIcon.ts +++ b/packages/app-shell/src/utils/getIcon.ts @@ -1,65 +1,15 @@ /** * Icon utilities * - * Helpers for resolving Lucide icons by name. - * - * Implementation: instead of statically importing every icon (~1500 - * components, ~568 KB raw / 140 KB gz), we wrap lucide-react's built-in - * `DynamicIcon` so each icon is fetched as its own tiny chunk on first use. - * - * The exported `getIcon(name)` API stays synchronous and returns a React - * component, preserving call sites that do `const Icon = getIcon(name); `. - */ - -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. - * - * The returned component is memoised per `name` so repeated calls with the - * same name yield the same component reference (stable for React.memo). + * ⛔ NOT a resolver. This is a re-export of `@object-ui/components`' + * `getLazyIcon`, kept only so the call sites in this package can go on saying + * `getIcon(name)`. + * + * It used to be a third transcription of the same twenty lines — its own + * `toKebab`, its own `Set(iconNames)` over `lucide-react/dynamic.mjs`, its own + * memo cache. Three copies meant three vocabularies that could drift, and each + * `Set(iconNames)` paid for lucide's 120,683-byte import map on the eager path + * (objectui#9204). One resolver, one vocabulary, one memo. */ -export function getIcon(name?: string): React.ElementType { - if (!name) return Database; - const cached = cache.get(name); - if (cached) return cached; - - const kebab = toKebab(name); - if (!VALID_ICON_NAMES.has(kebab)) { - cache.set(name, Database); - return Database; - } - const Wrapped: React.FC = (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..b0b3b3f7a5 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, + lucideIconNames, 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 ships ~2000 kebab-case icon names. `lucideIconNames()` rebuilds that +// vocabulary from the `icons` record the bundle already carries — this module +// used to import `lucide-react/dynamic.mjs` for the list alone and dragged its +// 120,683-byte import map onto every page load with it (objectui#9204). +const LUCIDE_ICON_NAMES: readonly string[] = lucideIconNames(); const LUCIDE_ICON_SET: Set = 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/cli/src/__tests__/workspace-vite.test.ts b/packages/cli/src/__tests__/workspace-vite.test.ts index fd704fc121..ac177ba435 100644 --- a/packages/cli/src/__tests__/workspace-vite.test.ts +++ b/packages/cli/src/__tests__/workspace-vite.test.ts @@ -194,18 +194,65 @@ describe('lucide-react alias (objectui#3890)', () => { expect(manifest.name).toBe('lucide-react'); }); - it('keeps the subpath the component library imports resolvable', () => { - // The specifier is read out of the importer instead of being written here, - // so this pins the real consumer rather than a copy of it. With the entry - // file as the alias target, this rewrite produced `/` and - // `packages/components/src/lib/lazy-icon.tsx` answered 500 once the - // platform aliases made it reachable at all. - const importer = readFileSync(join(REPO_ROOT, 'packages/components/src/lib/lazy-icon.tsx'), 'utf-8'); - const match = /['"]lucide-react\/([^'"]+)['"]/.exec(importer); - expect(match, 'lazy-icon.tsx no longer imports a lucide-react subpath').not.toBeNull(); - - const rewritten = join(lucide as string, (match as RegExpExecArray)[1]); + /** + * Every first-party bundled source file that imports a `lucide-react/` + * specifier, rediscovered rather than remembered. The subpath this test + * rewrites is taken from here when the repo has a consumer, so it goes on + * pinning the real one rather than a copy of it. + */ + function firstPartySubpathImporters(): { file: string; subpath: string }[] { + const found: { file: string; subpath: string }[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name === 'dist') continue; + walk(full); + } else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) { + const match = /from ['"]lucide-react\/([^'"]+)['"]/.exec(readFileSync(full, 'utf-8')); + if (match) found.push({ file: full, subpath: match[1] }); + } + } + }; + for (const pkg of ['packages', 'apps']) { + const root = join(REPO_ROOT, pkg); + if (existsSync(root)) walk(root); + } + return found; + } + + it('keeps a lucide subpath resolvable through the rewrite', () => { + // With the entry file as the alias target this rewrite produced + // `/`, and `packages/components/src/lib/lazy-icon.tsx` + // answered 500 once the platform aliases made it reachable at all. The + // property is about the alias TARGET being a directory, and it holds for any + // subpath the package publishes. + // + // ⚠️ objectui#9204 removed the last first-party BUNDLED importer of one: + // `lazy-icon.tsx` read `lucide-react/dynamic.mjs` for a list of icon names + // and dragged its 120,683-byte import map onto every page load with it. The + // specifier used to be read out of that file, "so this pins the real + // consumer rather than a copy of it" — so the discovery above still looks + // for a consumer first, and only falls back to a subpath lucide's own + // manifest publishes when the repo has none. An app built on `@object-ui/*` + // may still import one, which is why the alias itself is not retired. + const importers = firstPartySubpathImporters(); + const subpath = importers[0]?.subpath ?? 'dynamic.mjs'; + + const rewritten = join(lucide as string, subpath); expect(existsSync(rewritten), `${rewritten} must exist for the aliased subpath to resolve`).toBe(true); + + // The control that keeps the line above from passing on anything: the same + // rewrite against the package's RESOLVED ENTRY — the shape objectui#3890 + // fixed — must NOT resolve. The entry is read off the manifest, so the + // contrast survives lucide moving its build output. + const manifest = JSON.parse(readFileSync(join(lucide as string, 'package.json'), 'utf-8')) as { + module?: string; + main?: string; + }; + const entry = join(lucide as string, (manifest.module ?? manifest.main) as string); + expect(existsSync(entry), 'the entry file this test contrasts with must exist').toBe(true); + expect(existsSync(join(entry, subpath))).toBe(false); }); }); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index a05a471bd5..de19baa5c9 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -39,7 +39,7 @@ import './renderers'; export { cn } from './lib/utils'; export { renderChildren, renderNodeSlot, isEmptyNodeSlot } from './lib/utils'; export { cva } from 'class-variance-authority'; -export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/lazy-icon'; +export { getLazyIcon, isLucideIconName, LazyIcon, lucideIconNames, toKebabIconName } from './lib/lazy-icon'; // The member-action visibility gate — "did this action DECLARE a `visible` gate // at all?", the single definition objectui#3492 established and PR #3816 / diff --git a/packages/components/src/lib/__tests__/lucide-dynamic-name-aliases.test.ts b/packages/components/src/lib/__tests__/lucide-dynamic-name-aliases.test.ts new file mode 100644 index 0000000000..cbef0309d4 --- /dev/null +++ b/packages/components/src/lib/__tests__/lucide-dynamic-name-aliases.test.ts @@ -0,0 +1,99 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { icons, Database } from 'lucide-react'; +import { iconNames } from 'lucide-react/dynamic.mjs'; +import { getLazyIcon, isLucideIconName, lucideIconNames } from '../lazy-icon'; +import { + LUCIDE_DYNAMIC_NAME_ALIASES, + LUCIDE_DERIVED_NAME_EXCLUSIONS, +} from '../lucide-dynamic-name-aliases'; + +/** + * The drift guard for objectui#9204. + * + * `lazy-icon.tsx` no longer imports `lucide-react/dynamic.mjs`. Its 120,683-byte + * import map was eager in the `ui-components` chunk for four modules that wanted + * only the NAMES it is keyed by, while the `icons` record those names index was + * eager beside it — two encodings of one catalogue, both paid for on every page + * load. The vocabulary is now REBUILT from the record's keys plus a generated + * table of the names no key can produce. + * + * ⚠️ A generated mirror with no drift guard is a worse defect than the one it + * fixes, and its failure direction is the silent one: a lucide bump that adds, + * retires or respells a name would narrow what `LazyIcon` draws with nothing + * going red — the same class `scripts/check-lucide-icon-record-names.mjs` exists + * for, one level out. So this file consults lucide's OWN map, which is still + * installed and is still what the gate judges against, and compares the + * reconstruction to it in BOTH directions. The map is a devDependency of the + * test, never of the bundle. + */ +describe('the rebuilt lucide dynamic vocabulary', () => { + it('is EXACTLY what lucide publishes — no name added, none lost', () => { + // Both directions. A superset silently answers `isLucideIconName` yes for an + // invented spelling and then paints the fallback glyph; a subset silently + // stops drawing an icon that used to render. + expect(lucideIconNames()).toEqual([...iconNames].sort()); + }); + + it('is not vacuously equal — the comparison is over a real population', () => { + // The control for the assertion above: two empty lists are also equal. + expect(iconNames.length).toBeGreaterThan(2_000); + expect(lucideIconNames().length).toBe(iconNames.length); + expect(Object.keys(icons).length).toBeGreaterThan(1_700); + // And the two vocabularies really are different, which is the whole reason + // this surface is rebuilt rather than read off the record alone. + expect(iconNames.length).toBeGreaterThan(Object.keys(icons).length); + }); + + it('draws every published name from the record, not the fallback glyph', () => { + // Membership is worth nothing if the name resolves to `Database` anyway — + // that is precisely the defect objectui#7593 caught on `CheckCircle2`. + // `database` is the one name that draws the fallback component legitimately + // — it IS that icon. Derived from lucide rather than assumed, so a release + // that adds a second spelling for it does not read as a regression. + const drawsDatabase = iconNames.filter((name: string) => getLazyIcon(name) === Database); + const namesOfDatabase = Object.keys(icons).filter( + (key) => icons[key as keyof typeof icons] === Database, + ); + expect(namesOfDatabase).toEqual(['Database']); + expect(drawsDatabase).toEqual(['database']); + // Firing control: a name lucide does not publish must still degrade. + expect(getLazyIcon('not-a-lucide-icon-name')).toBe(Database); + expect(isLucideIconName('not-a-lucide-icon-name')).toBe(false); + }); + + it('keeps the RETIRED spellings the forgiving surface exists for', () => { + // `record-alert`'s severity glyphs. These are ABSENT from the `icons` record + // and present in the dynamic vocabulary, which is the entire reason the two + // surfaces are distinct (objectui#7593, plugin-detail's severityIcons pin). + for (const retired of ['AlertTriangle', 'AlertCircle', 'Edit', 'Smile']) { + expect(isLucideIconName(retired)).toBe(true); + expect(icons).not.toHaveProperty(retired); + } + }); + + it('every generated alias names a LIVE record key', () => { + const live = new Set(Object.keys(icons)); + const entries = Object.entries(LUCIDE_DYNAMIC_NAME_ALIASES); + expect(entries.length).toBeGreaterThan(0); + for (const [name, key] of entries) { + expect(live.has(key)).toBe(true); + expect(iconNames).toContain(name); + } + }); + + it('every exclusion is a spelling lucide does NOT publish', () => { + // An exclusion that IS a published name would delete a working icon. + expect(LUCIDE_DERIVED_NAME_EXCLUSIONS.length).toBeGreaterThan(0); + for (const name of LUCIDE_DERIVED_NAME_EXCLUSIONS) { + expect(iconNames).not.toContain(name); + } + }); +}); diff --git a/packages/components/src/lib/lazy-icon.tsx b/packages/components/src/lib/lazy-icon.tsx index 5ce39b479f..621dc390f0 100644 --- a/packages/components/src/lib/lazy-icon.tsx +++ b/packages/components/src/lib/lazy-icon.tsx @@ -7,23 +7,55 @@ */ /** - * Lazy Lucide icon resolver. + * The FORGIVING lucide surface. * - * Replaces the wildcard `import * as LucideIcons from 'lucide-react'` pattern - * which forced ~1500 icons (~568 KB raw / 140 KB gz) into the vendor bundle. - * Each icon is fetched as its own micro-chunk on first use via - * `lucide-react`'s built-in `DynamicIcon`. + * This repo resolves icon names against two vocabularies, and the split is + * deliberate (`scripts/check-lucide-icon-record-names.mjs` censuses both): * - * The exported `getLazyIcon(name)` API stays synchronous and returns a - * React component, preserving call-sites that do - * `const Icon = getLazyIcon(name); `. + * RECORD `renderers/action/resolve-icon.ts` — the strict seam. A RETIRED + * spelling resolves to nothing, and the caller decides what to draw. + * DYNAMIC this module — accepts everything lucide still publishes a name + * for, including retired spellings such as `alert-triangle`, and + * degrades an unknown name to the `Database` glyph, because + * server-driven schemas reference icons from other libraries. + * + * `record-alert`'s severity glyphs depend on that forgiveness at the byte: + * `AlertTriangle` and `AlertCircle` are ABSENT from the record and present + * here, pinned by `plugin-detail`'s `record-alert.severityIcons.test.ts`. + * + * ## Where the vocabulary comes from, and why it is no longer lucide's map + * + * It used to be `iconNames` from `lucide-react/dynamic.mjs`. That list is + * `Object.keys(dynamicIconImports)` — derived at module init from a + * 120,683-byte import map — so a module that wanted only the NAMES paid for the + * whole map, eagerly, and four modules did. The map was 8,253 gzipped bytes of + * the `ui-components` chunk measured in the chunk (objectui#9250), for a + * catalogue this bundle already carries twice over: `resolve-icon.ts` indexes + * lucide's `icons` record, and a namespace object has no dead members, so every + * icon module is eager regardless. `DynamicIcon` was therefore `import()`-ing + * modules that were already in the same chunk — laziness that bought nothing + * and cost the map (objectui#9204). + * + * So the vocabulary is REBUILT from the record's keys, which are bytes already + * paid for, plus the 264 names no record key can produce — generated into + * `lucide-dynamic-name-aliases.ts` and re-derived from the installed lucide by + * `lucide-dynamic-name-aliases.test.ts`, in both directions, so a lucide bump + * cannot narrow what this module draws in silence. + * + * ⇒ resolution is now SYNCHRONOUS. `getLazyIcon` returns lucide's own component + * rather than a `DynamicIcon` wrapper, there is no per-icon chunk to fetch on + * first paint, and the exported API is unchanged. */ import React from 'react'; import { Database } from 'lucide-react'; -import { DynamicIcon, iconNames } from 'lucide-react/dynamic.mjs'; +import { resolveIcon, listIconRecordKeys } from '../renderers/action/resolve-icon'; +import { + LUCIDE_DYNAMIC_NAME_ALIASES, + LUCIDE_DERIVED_NAME_EXCLUSIONS, +} from './lucide-dynamic-name-aliases'; -/** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */ +/** Convert PascalCase / camelCase / mixed names to kebab-case for lookup. */ export function toKebabIconName(name: string): string { if (name.includes('-')) return name.toLowerCase(); return name @@ -32,61 +64,90 @@ 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[]); +/** + * A record KEY in lucide's own kebab spelling (`ArrowDownUp` -> `arrow-down-up`, + * `Building2` -> `building-2`). + * + * ⚠️ Kept byte-identical to the copy in + * `scripts/gen-lucide-dynamic-name-aliases.mjs`: that script computes which + * names this cannot produce, so the two disagreeing would leave holes in the + * vocabulary. The drift test compares the FINAL vocabulary against lucide, which + * is what actually proves they still agree. + */ +function toDynamicIconName(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/([A-Za-z])([0-9])/g, '$1-$2') + .toLowerCase(); +} -/** Returns true when `kebab` matches a real Lucide icon. */ -function isLucideIcon(kebab: string): boolean { - return VALID_ICON_NAMES.has(kebab); +/** + * lucide's dynamic name -> the live record key that draws it. + * + * Built on first use rather than at module init: a page that renders no icon by + * name should not pay for 2,039 map entries, and the eager-closure budget is the + * reason this module exists in its current shape. + */ +let vocabulary: Map | null = null; +function dynamicVocabulary(): Map { + if (vocabulary) return vocabulary; + const excluded = new Set(LUCIDE_DERIVED_NAME_EXCLUSIONS); + const built = new Map(); + for (const key of listIconRecordKeys()) { + const name = toDynamicIconName(key); + if (!excluded.has(name)) built.set(name, key); + } + for (const [alias, key] of Object.entries(LUCIDE_DYNAMIC_NAME_ALIASES)) built.set(alias, key); + vocabulary = built; + return built; } /** - * Whether `name` (kebab-case or PascalCase) resolves to a real Lucide icon. + * Every name this surface draws, in lucide's kebab spelling, sorted. * - * 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. + * Replaces `iconNames` from `lucide-react/dynamic.mjs` for the one caller that + * needs the list itself — `app-shell`'s metadata-admin icon picker — which used + * to drag the whole import map for a string array. */ +export function lucideIconNames(): string[] { + return [...dynamicVocabulary().keys()].sort(); +} + +/** Whether `name` (kebab-case or PascalCase) names a real Lucide icon. */ export function isLucideIconName(name?: string): boolean { - return !!name && isLucideIcon(toKebabIconName(name)); + return !!name && dynamicVocabulary().has(toKebabIconName(name)); } 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"). + * letting an unresolvable name throw). + * + * Memoised per `name`, so call sites get a stable component reference across + * renders (`react-hooks/static-components` cannot see through the call, which is + * why the JSX sites that render the result carry a targeted disable pointing + * back here). + * + * ⛔ Callers that have a BETTER fallback than a stray database glyph — a + * notification would rather show its severity icon — must ask + * `isLucideIconName` first. Ask, then choose. */ export function getLazyIcon(name?: string): React.ElementType { if (!name) return Database; const cached = cache.get(name); if (cached) return cached; - const kebab = toKebabIconName(name); - if (!isLucideIcon(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; + const key = dynamicVocabulary().get(toKebabIconName(name)); + const resolved = (key ? resolveIcon(key) : null) ?? Database; + cache.set(name, resolved); + return resolved; } /** Direct ready-to-render component. */ -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); - return React.createElement(DynamicIcon as any, { - name: kebab, - fallback: Database, - ...rest, - }); -}; +export const LazyIcon: React.FC<{ name?: string } & Record> = ({ name, ...rest }) => + React.createElement(getLazyIcon(name) as any, rest); diff --git a/packages/components/src/lib/lucide-dynamic-name-aliases.ts b/packages/components/src/lib/lucide-dynamic-name-aliases.ts new file mode 100644 index 0000000000..1434e8550a --- /dev/null +++ b/packages/components/src/lib/lucide-dynamic-name-aliases.ts @@ -0,0 +1,309 @@ +/** + * 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. + */ + +/** + * GENERATED by `scripts/gen-lucide-dynamic-name-aliases.mjs` from + * lucide-react@1.35.0. ⛔ Do not hand-edit — run `pnpm gen:lucide-aliases`. + * + * The part of lucide's DYNAMIC icon vocabulary that the `icons` record this + * bundle already carries cannot produce on its own. Deriving the rest is what + * takes `lucide-react/dynamic.mjs` (a 120,683-byte import map whose only + * job is to be a list of names) off the eager path without narrowing what + * `LazyIcon` will draw. See the generator's header for the mechanism, and + * `lucide-dynamic-name-aliases.test.ts` for the drift guard that keeps this + * file honest across a lucide bump. + * + * Counts at generation time: 1781 record keys, 2039 dynamic names, + * 264 aliases, 6 exclusions. + */ + +/** + * Dynamic names with no record key of their own, each mapped to the LIVE record + * key that draws it. Found by object identity against lucide's own exports, so + * a rename is read off the installed package rather than remembered here. + */ +export const LUCIDE_DYNAMIC_NAME_ALIASES: Readonly> = Object.freeze({ + 'alarm-check': 'AlarmClockCheck', + 'alarm-minus': 'AlarmClockMinus', + 'alarm-plus': 'AlarmClockPlus', + 'arrow-down-0-1': 'ArrowDown01', + 'arrow-down-1-0': 'ArrowDown10', + 'arrow-down-a-z': 'ArrowDownAZ', + 'sort-desc': 'ArrowDownWideNarrow', + 'arrow-down-z-a': 'ArrowDownZA', + 'arrow-up-0-1': 'ArrowUp01', + 'arrow-up-1-0': 'ArrowUp10', + 'arrow-up-a-z': 'ArrowUpAZ', + 'sort-asc': 'ArrowUpNarrowWide', + 'arrow-up-z-a': 'ArrowUpZA', + 'axis-3-d': 'Axis3d', + 'verified': 'BadgeCheck', + 'badge-help': 'BadgeQuestionMark', + 'between-horizonal-end': 'BetweenHorizontalEnd', + 'between-horizonal-start': 'BetweenHorizontalStart', + 'book-template': 'BookDashed', + 'curly-braces': 'Braces', + 'subtitles': 'Captions', + 'area-chart': 'ChartArea', + 'bar-chart-horizontal-big': 'ChartBarBig', + 'bar-chart-horizontal': 'ChartBar', + 'candlestick-chart': 'ChartCandlestick', + 'bar-chart-big': 'ChartColumnBig', + 'bar-chart-4': 'ChartColumnIncreasing', + 'bar-chart-3': 'ChartColumn', + 'line-chart': 'ChartLine', + 'bar-chart': 'ChartNoAxesColumnIncreasing', + 'bar-chart-2': 'ChartNoAxesColumn', + 'gantt-chart': 'ChartNoAxesGantt', + 'pie-chart': 'ChartPie', + 'scatter-chart': 'ChartScatter', + 'alert-circle': 'CircleAlert', + 'arrow-down-circle': 'CircleArrowDown', + 'arrow-left-circle': 'CircleArrowLeft', + 'arrow-down-left-from-circle': 'CircleArrowOutDownLeft', + 'arrow-down-right-from-circle': 'CircleArrowOutDownRight', + 'arrow-up-left-from-circle': 'CircleArrowOutUpLeft', + 'arrow-up-right-from-circle': 'CircleArrowOutUpRight', + 'arrow-right-circle': 'CircleArrowRight', + 'arrow-up-circle': 'CircleArrowUp', + 'check-circle': 'CircleCheckBig', + 'check-circle-2': 'CircleCheck', + 'chevron-down-circle': 'CircleChevronDown', + 'chevron-left-circle': 'CircleChevronLeft', + 'chevron-right-circle': 'CircleChevronRight', + 'chevron-up-circle': 'CircleChevronUp', + 'divide-circle': 'CircleDivide', + 'gauge-circle': 'CircleGauge', + 'minus-circle': 'CircleMinus', + 'parking-circle-off': 'CircleParkingOff', + 'parking-circle': 'CircleParking', + 'pause-circle': 'CirclePause', + 'percent-circle': 'CirclePercent', + 'play-circle': 'CirclePlay', + 'plus-circle': 'CirclePlus', + 'power-circle': 'CirclePower', + 'help-circle': 'CircleQuestionMark', + 'circle-help': 'CircleQuestionMark', + 'circle-slashed': 'CircleSlash2', + 'stop-circle': 'CircleStop', + 'user-circle-2': 'CircleUserRound', + 'user-circle': 'CircleUser', + 'x-circle': 'CircleX', + 'clipboard-signature': 'ClipboardPenLine', + 'clipboard-edit': 'ClipboardPen', + 'download-cloud': 'CloudDownload', + 'upload-cloud': 'CloudUpload', + 'code-2': 'CodeXml', + 'columns': 'Columns2', + 'columns-settings': 'Columns3Cog', + 'table-config': 'Columns3Cog', + 'panels-left-right': 'Columns3', + 'contact-2': 'ContactRound', + 'percent-diamond': 'DiamondPercent', + 'globe-2': 'Earth', + 'more-vertical': 'EllipsisVertical', + 'more-horizontal': 'Ellipsis', + 'angry': 'FaceAngry', + 'annoyed': 'FaceExpressionless', + 'laugh': 'FaceGrinning', + 'meh': 'FaceNeutral', + 'frown': 'FaceSlightlyFrowning', + 'smile-plus': 'FaceSlightlySmilingPlus', + 'smile': 'FaceSlightlySmiling', + 'file-axis-3-d': 'FileAxis3d', + 'file-badge-2': 'FileBadge', + 'file-json-2': 'FileBracesCorner', + 'file-json': 'FileBraces', + 'file-bar-chart': 'FileChartColumnIncreasing', + 'file-bar-chart-2': 'FileChartColumn', + 'file-line-chart': 'FileChartLine', + 'file-pie-chart': 'FileChartPie', + 'file-check-2': 'FileCheckCorner', + 'file-code-2': 'FileCodeCorner', + 'file-cog-2': 'FileCog', + 'file-warning': 'FileExclamationPoint', + 'file-audio': 'FileHeadphone', + 'file-audio-2': 'FileHeadphone', + 'file-key-2': 'FileKey', + 'file-lock-2': 'FileLock', + 'file-minus-2': 'FileMinusCorner', + 'file-signature': 'FilePenLine', + 'file-edit': 'FilePen', + 'file-video': 'FilePlay', + 'file-plus-2': 'FilePlusCorner', + 'file-question': 'FileQuestionMark', + 'file-search-2': 'FileSearchCorner', + 'file-volume-2': 'FileSignal', + 'file-type-2': 'FileTypeCorner', + 'file-video-2': 'FileVideoCamera', + 'file-x-2': 'FileXCorner', + 'fingerprint': 'FingerprintPattern', + 'folder-cog-2': 'FolderCog', + 'folder-edit': 'FolderPen', + 'filter-x': 'FunnelX', + 'filter': 'Funnel', + 'git-commit': 'GitCommitHorizontal', + 'grid-2x2-check': 'Grid2x2Check', + 'grid-2-x-2-check': 'Grid2x2Check', + 'grid-2x2-plus': 'Grid2x2Plus', + 'grid-2-x-2-plus': 'Grid2x2Plus', + 'grid-2x2-x': 'Grid2x2X', + 'grid-2-x-2-x': 'Grid2x2X', + 'grid-2x2': 'Grid2x2', + 'grid-2-x-2': 'Grid2x2', + 'grid-3x2': 'Grid3x2', + 'grid-3x3': 'Grid3x3', + 'grid': 'Grid3x3', + 'grid-3-x-3': 'Grid3x3', + 'grab': 'HandGrab', + 'helping-hand': 'HandHelping', + 'home': 'House', + 'ice-cream-2': 'IceCreamBowl', + 'ice-cream': 'IceCreamCone', + 'laptop-2': 'LaptopMinimal', + 'layers-3': 'Layers', + 'outdent': 'ListIndentDecrease', + 'indent-decrease': 'ListIndentDecrease', + 'indent': 'ListIndentIncrease', + 'indent-increase': 'ListIndentIncrease', + 'loader-2': 'LoaderCircle', + 'unlock-keyhole': 'LockKeyholeOpen', + 'unlock': 'LockOpen', + 'mail-question': 'MailQuestionMark', + 'location-edit': 'MapPinPen', + 'message-circle-question': 'MessageCircleQuestionMark', + 'podcast': 'MicSignal', + 'mic-2': 'MicVocal', + 'move-3-d': 'Move3d', + 'alert-octagon': 'OctagonAlert', + 'pause-octagon': 'OctagonPause', + 'x-octagon': 'OctagonX', + 'paintbrush-2': 'PaintbrushVertical', + 'panel-bottom-inactive': 'PanelBottomDashed', + 'sidebar-close': 'PanelLeftClose', + 'panel-left-inactive': 'PanelLeftDashed', + 'sidebar-open': 'PanelLeftOpen', + 'sidebar': 'PanelLeft', + 'panel-right-inactive': 'PanelRightDashed', + 'panel-top-inactive': 'PanelTopDashed', + 'layout': 'PanelsTopLeft', + 'edit-3': 'PenLine', + 'edit-2': 'Pen', + 'plug-zap-2': 'PlugZap', + 'form-input': 'RectangleEllipsis', + 'rotate-3-d': 'Rotate3d', + 'history': 'RotateCcwClock', + 'rows': 'Rows2', + 'panels-top-bottom': 'Rows3', + 'scale-3-d': 'Scale3d', + 'send-horizonal': 'SendHorizontal', + 'shield-question': 'ShieldQuestionMark', + 'shield-close': 'ShieldX', + 'sliders': 'SlidersVertical', + 'stars': 'Sparkles', + 'activity-square': 'SquareActivity', + 'arrow-down-left-square': 'SquareArrowDownLeft', + 'arrow-down-right-square': 'SquareArrowDownRight', + 'arrow-down-square': 'SquareArrowDown', + 'arrow-left-square': 'SquareArrowLeft', + 'arrow-down-left-from-square': 'SquareArrowOutDownLeft', + 'arrow-down-right-from-square': 'SquareArrowOutDownRight', + 'arrow-up-left-from-square': 'SquareArrowOutUpLeft', + 'arrow-up-right-from-square': 'SquareArrowOutUpRight', + 'arrow-right-square': 'SquareArrowRight', + 'arrow-up-left-square': 'SquareArrowUpLeft', + 'arrow-up-right-square': 'SquareArrowUpRight', + 'arrow-up-square': 'SquareArrowUp', + 'asterisk-square': 'SquareAsterisk', + 'scissors-square-dashed-bottom': 'SquareBottomDashedScissors', + 'flip-horizontal': 'SquareCenterlineDashedHorizontal', + 'flip-vertical': 'SquareCenterlineDashedVertical', + 'gantt-chart-square': 'SquareChartGantt', + 'square-gantt-chart': 'SquareChartGantt', + 'check-square': 'SquareCheckBig', + 'check-square-2': 'SquareCheck', + 'chevron-down-square': 'SquareChevronDown', + 'chevron-left-square': 'SquareChevronLeft', + 'chevron-right-square': 'SquareChevronRight', + 'chevron-up-square': 'SquareChevronUp', + 'code-square': 'SquareCode', + 'kanban-square-dashed': 'SquareDashedKanban', + 'mouse-pointer-square-dashed': 'SquareDashedMousePointer', + 'text-selection': 'SquareDashedText', + 'text-select': 'SquareDashedText', + 'box-select': 'SquareDashed', + 'divide-square': 'SquareDivide', + 'dot-square': 'SquareDot', + 'equal-square': 'SquareEqual', + 'function-square': 'SquareFunction', + 'kanban-square': 'SquareKanban', + 'library-square': 'SquareLibrary', + 'm-square': 'SquareM', + 'menu-square': 'SquareMenu', + 'minus-square': 'SquareMinus', + 'inspect': 'SquareMousePointer', + 'parking-square-off': 'SquareParkingOff', + 'parking-square': 'SquareParking', + 'pen-box': 'SquarePen', + 'edit': 'SquarePen', + 'pen-square': 'SquarePen', + 'percent-square': 'SquarePercent', + 'pi-square': 'SquarePi', + 'pilcrow-square': 'SquarePilcrow', + 'play-square': 'SquarePlay', + 'plus-square': 'SquarePlus', + 'power-square': 'SquarePower', + 'scissors-square': 'SquareScissors', + 'sigma-square': 'SquareSigma', + 'slash-square': 'SquareSlash', + 'split-square-horizontal': 'SquareSplitHorizontal', + 'split-square-vertical': 'SquareSplitVertical', + 'terminal-square': 'SquareTerminal', + 'user-square-2': 'SquareUserRound', + 'user-square': 'SquareUser', + 'x-square': 'SquareX', + 'test-tube-2': 'TestTubeDiagonal', + 'align-center': 'TextAlignCenter', + 'align-right': 'TextAlignEnd', + 'align-justify': 'TextAlignJustify', + 'text': 'TextAlignStart', + 'align-left': 'TextAlignStart', + 'letter-text': 'TextInitial', + 'wrap-text': 'TextWrap', + 'train': 'TramFront', + 'palmtree': 'TreePalm', + 'alert-triangle': 'TriangleAlert', + 'tv-2': 'TvMinimal', + 'school-2': 'University', + 'user-check-2': 'UserRoundCheck', + 'user-cog-2': 'UserRoundCog', + 'user-minus-2': 'UserRoundMinus', + 'user-plus-2': 'UserRoundPlus', + 'user-x-2': 'UserRoundX', + 'user-2': 'UserRound', + 'users-2': 'UsersRound', + 'fork-knife-crossed': 'UtensilsCrossed', + 'fork-knife': 'Utensils', + 'wallet-2': 'WalletMinimal', + 'wand-2': 'WandSparkles', + 'waves': 'WavesHorizontal', +}); + +/** + * Spellings the runtime derivation produces that lucide does not publish. Listed + * so the reconstructed vocabulary is EXACTLY lucide's, not a superset that would + * accept an invented name and answer `isLucideIconName` with a false yes. + */ +export const LUCIDE_DERIVED_NAME_EXCLUSIONS: readonly string[] = Object.freeze([ + 'grid-2x-2', + 'grid-2x-2-check', + 'grid-2x-2-plus', + 'grid-2x-2-x', + 'grid-3x-2', + 'grid-3x-3', +]); diff --git a/packages/components/src/renderers/action/resolve-icon.ts b/packages/components/src/renderers/action/resolve-icon.ts index 1fecd0e57d..2cb1c060da 100644 --- a/packages/components/src/renderers/action/resolve-icon.ts +++ b/packages/components/src/renderers/action/resolve-icon.ts @@ -119,6 +119,29 @@ export function describeIconLookup(name: string): { pascal: string; key: string return { pascal, key: iconNameMap[pascal] || pascal }; } +/** + * The record's live keys, for the ONE caller that must enumerate them. + * + * ⛔ Not a second lookup and not a widening of this seam's contract: it returns + * names, never components, and answers nothing about what a given authored + * string resolves to. It exists so the forgiving DYNAMIC surface + * (`lib/lazy-icon.tsx`) can rebuild lucide's kebab-spelled vocabulary from the + * record this bundle already carries, instead of importing + * `lucide-react/dynamic.mjs` for a list of names and dragging its 120,683-byte + * import map onto every page load (objectui#9204). + * + * Keeping that read HERE is the point. The 2026-08-31 ruling (objectui#5935, + * point 4) is that no new container brings its own resolver, and + * `scripts/check-lucide-icon-record-names.mjs` enforces it by failing when a + * second module named-imports `icons` and indexes it. A `lazy-icon.tsx` that + * reached for the record directly would turn that gate red on the commit that + * added it, and rightly: there would then be two modules holding the record. + * There is still one. + */ +export function listIconRecordKeys(): string[] { + return Object.keys(icons); +} + /** * Resolve an authored Lucide icon name to its component. * diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index 6d2639f900..311bc26a53 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -859,7 +859,19 @@ 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, deliberately, and no longer a read of + * {@link EXHAUSTED_HEADROOM_ALLOWANCES}. 4,289 is the figure + * `ui-components` carried until objectui#9204 paid it off, and the + * mechanics below — held open at the pin, blind to one byte, red at one + * grain, the DEBT remedy rather than the find-the-bytes one — are about + * the RATCHET, not about which rows happen to owe today. Reading the live + * table here is what turned this block vacuous the moment the last row + * cleared the floor; injecting the pin keeps every assertion firing while + * the table below stays empty. + */ + const ALLOWANCE = 4_289; + const DECLARED = { 'ui-components': ALLOWANCE }; const GRAIN = REGRESSION_THIS_GATE_MUST_CATCH_BYTES * EXHAUSTED_HEADROOM_ALLOWANCE_GRANULARITY_MULTIPLE; @@ -867,6 +879,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', () => { @@ -935,7 +948,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: { ...DECLARED, 'ui-components': allowance }, }).status; expect(at(Math.floor(paidDown - GRAIN), paidDown)).toBe('error'); @@ -947,14 +960,31 @@ 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. + // + // ⚠️ Injected rather than read off {@link EXHAUSTED_HEADROOM_ALLOWANCES}: + // that table is EMPTY since objectui#9204, and a loop over it would assert + // nothing while reading exactly like a test. What is under test is the + // RENDERER — that a declared row is named on a green run — so the row it + // renders is supplied here. + 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`); } + + // The firing control for the line above: with no declared row there is no + // allowance clause to render, so a green board says nothing about debt. + const none = evaluateHeadroomSensitivity({ + report: sensitivityReport(BASELINE.gzipBytes), + allowances: {}, + }); + expect(none.status).toBe('pass'); + expect(none.message).not.toContain('-byte allowance'); }); /** @@ -970,9 +1000,20 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { // 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, - }); + // + // ⚠️ `ui-components: 4_289` left the same way at objectui#9204, and the + // distinction matters exactly as much the second time: it is REMOVED, + // not lowered. `lucide-react/dynamic.mjs`'s 120,683-byte import map came + // off the eager path — its vocabulary is rebuilt from the `icons` record + // the chunk already carried — and the row went from 1,910 bytes of + // headroom (0.02x) to 45,342 (0.50x) in one console build. The row + // cleared the floor on its own, which is the only currency this table + // takes. + // + // ⛔ An EMPTY table is not a licence to re-add a row. The rule above is + // unchanged: a ceiling that cannot clear the floor is a ceiling set at + // the wrong number, and the answer is the bytes or an authorised re-pin. + expect(EXHAUSTED_HEADROOM_ALLOWANCES).toEqual({}); }); it('every entry is real debt — strictly under the floor it excuses', () => { @@ -981,6 +1022,11 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { for (const allowance of Object.values(EXHAUSTED_HEADROOM_ALLOWANCES)) { expect(allowance).toBeLessThan(FLOOR); } + // The control, because the loop above is vacuous while the table is + // empty: the predicate it applies must still reject a row that is not + // debt, or this test would go on passing after it stopped checking. + expect(FLOOR).toBeGreaterThan(0); + expect(FLOOR).not.toBeLessThan(FLOOR); }); it('is compared at the coarser of the two grids this gate renders on', () => { @@ -1008,6 +1054,10 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { for (const allowance of Object.values(EXHAUSTED_HEADROOM_ALLOWANCES)) { expect(allowance - grain).toBeGreaterThan(0); } + // Control for the empty table: the figure this rule was written about + // still has a reachable trip point, so the rule is about the numbers and + // not about there happening to be none. + expect(4_289 - grain).toBeGreaterThan(0); }); it('every entry names a ceiling that exists', () => { @@ -1017,6 +1067,10 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { for (const key of Object.keys(EXHAUSTED_HEADROOM_ALLOWANCES)) { expect(judged).toContain(key); } + // Control for the empty table, in both directions: the membership test + // this loop applies accepts a budgeted chunk and rejects one that is not. + expect(judged).toContain('ui-components'); + expect(judged).not.toContain('vendor-markdown'); }); }); }); diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 907ab61c7f..4115da764b 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -1186,7 +1186,26 @@ 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-MEASURED by objectui#9204 on its own console build at `69aa9c017`, and + // moved in the same commit as the `EXHAUSTED_HEADROOM_ALLOWANCES` row it + // retired — the pair is what makes that retirement legible rather than a + // number that drifted. It used to read `391_095`. + // + // ui-components 397,090 -> 353,658 gz -43,432 (ablation-restored + // baseline reproduced + // 397,090 to the byte) + // + // The bytes are `lucide-react/dynamic.mjs`: a 120,683-byte import map, eager + // for four modules that wanted only the NAMES it is keyed by, beside an + // `icons` record carrying the same catalogue. ⛔ The CEILING above did not + // move. Headroom against it is now 45,342 = 0.50x REGRESSION_THIS_GATE_MUST_ + // CATCH_BYTES — inside this file's [0.10x, 1.00x] convention and beside + // `framework`'s 0.61x, so the row is neither exhausted nor blind. Tightening + // the ceiling onto this measurement the way objectui#7479 tightened + // `i18n-locales` is a defensible NEXT act and a deliberate one; it would put + // this chunk back to being the tightest line on the board, which is the + // condition this card was filed about, so it is ⛔ not taken here. + 'ui-components': 353_658, }); /** @@ -1303,7 +1322,25 @@ export const EXHAUSTED_HEADROOM_ALLOWANCES = Object.freeze({ // 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, + // + // ⭐ `ui-components: 4_289` stood here until objectui#9204 and left the SAME + // way — REMOVED, not lowered. Its chunk still exists; what left it is + // `lucide-react/dynamic.mjs`. That module's 120,683-byte import map was eager + // for four modules that wanted only the NAMES it is keyed by, and the `icons` + // record those names index was eager beside it the whole time, so the map was + // a second copy of a catalogue already paid for. The vocabulary is now rebuilt + // from the record's own keys (`components/src/lib/lazy-icon.tsx`, with the 264 + // names no key can produce generated into `lucide-dynamic-name-aliases.ts` and + // re-derived from the installed lucide by a drift test). Measured across one + // pair of console builds on `69aa9c017`: + // + // ui-components 397,090 -> 353,658 gz -43,432 + // headroom 1,910 -> 45,342 0.02x -> 0.50x + // + // ⇒ the row cleared the floor on its own, by 36,228 bytes, which is the only + // currency this table takes. ⛔ The ceiling did NOT move: the maintainer's + // 2026-09-13 authorisation to raise it for this card went unused, and an empty + // table is ⛔ not a licence to re-add a row that cannot clear the floor. }); /** diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index 3d38830368..6c36e38864 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -32,8 +32,17 @@ * ── The two surfaces, and why picking the wrong one is worse than no gate ── * This repo resolves icon names against TWO different lucide vocabularies: * - * RECORD — `icons` from 'lucide-react' (1767 keys, measured) - * DYNAMIC — `iconNames` from 'lucide-react/dynamic.mjs' (2025 names) + * RECORD — `icons` from 'lucide-react' (1781 keys, measured) + * DYNAMIC — the vocabulary `lucide-react/dynamic.mjs` publishes (2039 names) + * + * ⚠️ Both counts are read from the INSTALLED lucide on every run and printed in + * the verdict; the figures above are lucide-react@1.35.0 and are prose, not the + * judgement. ⛔ Do not compare one across a version bump with the other across + * it — they are two vocabularies, and the gap between them (258) dwarfs the gap + * between releases. Measured across the bump this repo actually took, 1.31.0 -> + * 1.35.0: RECORD 1767 -> 1781 and DYNAMIC 2025 -> 2039, fourteen icons added and + * none retired. Reading 1767 (RECORD, 1.31.0) against 2039 (DYNAMIC, 1.35.0) + * gives "+272 icons" and is the confusion this very paragraph exists to stop. * * DYNAMIC is a strict superset: it still carries `edit`, `smile`, `filter`, * `alert-triangle`. So a gate that checked the dynamic list would BLESS every @@ -41,6 +50,16 @@ * judged here; the dynamic sites are censused (below) precisely so that the * split stays declared and a site cannot move between surfaces unnoticed. * + * ⭐ Since objectui#9204 the DYNAMIC surface is no longer BACKED by + * `lucide-react/dynamic.mjs` — that module's 120,683-byte import map was eager + * on every page load for a list of names, and is now rebuilt from the RECORD's + * own keys plus a generated table of the 264 names no key can produce + * (`components/src/lib/lucide-dynamic-name-aliases.ts`). The vocabulary is + * unchanged at the name; only where it comes from moved. Discovery therefore + * matches BOTH spellings, and this gate still loads lucide's own map — as the + * independent judge, exactly so a mirror that drifts cannot also move the + * judgement it is checked against. + * * ── 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 @@ -297,10 +316,16 @@ export const DECLARED_RECORD_READERS = [ 'packages/components/src/renderers/action/resolve-icon.ts', ]; +// objectui#9204 took this list from four modules to one, and the three that left +// did not stop resolving names — they stopped having their OWN resolver. +// `app-shell`'s and `apps/console`'s `utils/getIcon.ts` were transcriptions of +// `lazy-icon.tsx` (each with its own tokeniser, its own `Set` over lucide's +// import map and its own memo) and are now re-exports of it; +// `metadata-admin/widgets.tsx` wanted the name LIST alone and takes it from this +// package's published `lucideIconNames()`. Consuming the one resolver is the +// conforming shape and is deliberately NOT censused — every other `LazyIcon` +// call site in the repo does the same and none is listed here. 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', ]; @@ -798,6 +823,12 @@ export function discoverResolvers(root, files) { if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return; const specifier = node.moduleSpecifier.text; if (specifier.startsWith('lucide-react/dynamic')) readsDynamic = true; + // The same surface, reached through the generated mirror instead of + // lucide's import map (objectui#9204). Without this line, moving the + // vocabulary off `dynamic.mjs` would read as "this module stopped + // resolving names" and would quietly retire the census entry that keeps + // the two surfaces declared. + if (specifier.includes('lucide-dynamic-name-aliases')) readsDynamic = true; if (specifier !== 'lucide-react') return; const bindings = node.importClause?.namedBindings; if (!bindings || !ts.isNamedImports(bindings)) return; @@ -1073,7 +1104,7 @@ export function analyze(root, { censusDiff('record-reading resolver', discovered.record, declaredRecordReaders, 'It resolves an icon NAME through lucide\'s runtime `icons` record, where a retired spelling resolves to nothing and NOTHING goes red.'); 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.'); + 'It resolves names through the DYNAMIC vocabulary — lucide\'s `dynamic.mjs` import map, or the generated mirror of it in `components/src/lib/lucide-dynamic-name-aliases.ts` — which still carries retired spellings, a second and more forgiving surface.'); 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.'); diff --git a/scripts/gen-lucide-dynamic-name-aliases.mjs b/scripts/gen-lucide-dynamic-name-aliases.mjs new file mode 100644 index 0000000000..ef3314fb43 --- /dev/null +++ b/scripts/gen-lucide-dynamic-name-aliases.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * Generates `packages/components/src/lib/lucide-dynamic-name-aliases.ts` — the + * part of lucide's DYNAMIC icon vocabulary that cannot be derived from the + * `icons` record this bundle already carries. + * + * ## Why this file exists + * + * This repo resolves icon names against two lucide vocabularies (see + * `scripts/check-lucide-icon-record-names.mjs`): the strict RECORD (`icons`, + * 1781 keys) and the forgiving DYNAMIC list (`iconNames` from + * `lucide-react/dynamic.mjs`, 2039 names, which still carries retired + * spellings such as `alert-triangle`). Both were EAGER in the `ui-components` + * chunk, and they are two encodings of the same catalogue: `iconNames` is + * `Object.keys(dynamicIconImports)`, so reading one name costs the whole + * 120,683-byte import map. + * + * The record is eager anyway (`renderers/action/resolve-icon.ts` indexes it, and + * a namespace object has no dead members), so its keys are bytes already paid + * for. Most dynamic names are that key in lucide's own kebab spelling, so they + * can be DERIVED at runtime for nothing. What cannot be derived is written here: + * + * ALIASES dynamic names with no record key of their own — retired + * spellings (`alert-triangle` -> `TriangleAlert`) and lucide's + * alternate digit spellings (`arrow-down-0-1` -> `ArrowDown01`). + * The live key is found BY IDENTITY, never by remembering a + * rename: the retired root export and its live record entry are + * the same object, which is the technique + * `check-lucide-icon-record-names.mjs` uses for the same reason. + * EXCLUSIONS spellings the derivation produces that lucide does not publish + * (`Grid2X2` -> `grid-2x-2`, where lucide's name is `grid-2x2`). + * Listed so the reconstruction is EXACTLY lucide's vocabulary + * rather than a superset that quietly accepts invented names. + * + * ⚠️ Neither table may be hand-edited. `lucide-dynamic-name-aliases.test.ts` + * rebuilds the vocabulary the runtime builds and compares it to + * `Object.keys(dynamicIconImports)` from the INSTALLED lucide, in both + * directions — so a lucide bump that adds, retires or respells a name reds that + * test instead of silently narrowing what `LazyIcon` will draw. + * + * Usage: `node scripts/gen-lucide-dynamic-name-aliases.mjs [--check]` + */ +import { writeFileSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..'); +const OUT = join(ROOT, 'packages/components/src/lib/lucide-dynamic-name-aliases.ts'); + +// Resolve lucide the way `packages/components` does, not the way the repo root +// might: the bundle under budget is the one that package's imports produce. +const lucideRequire = createRequire(pathToFileURL(join(ROOT, 'packages/components/package.json')).href); +const lucide = await import(pathToFileURL(lucideRequire.resolve('lucide-react')).href); +const { iconNames } = await import(pathToFileURL(lucideRequire.resolve('lucide-react/dynamic.mjs')).href); + +/** + * A record KEY in lucide's own kebab spelling. Kept byte-identical to + * `toDynamicIconName` in `packages/components/src/lib/lazy-icon.tsx` — the + * runtime applies it to the same keys, and the drift test is what proves the + * two still agree. + */ +const toDynamicIconName = (key) => + key + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/([A-Za-z])([0-9])/g, '$1-$2') + .toLowerCase(); + +const toPascalCase = (name) => + name.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''); + +const recordKeys = Object.keys(lucide.icons); +const published = new Set(iconNames); + +const derived = new Map(); +for (const key of recordKeys) derived.set(toDynamicIconName(key), key); + +const exclusions = [...derived.keys()].filter((name) => !published.has(name)).sort(); + +// Identity index: component object -> its LIVE key in the record. +const liveKeyOf = new Map(); +for (const key of recordKeys) liveKeyOf.set(lucide.icons[key], key); + +const aliases = {}; +const unresolved = []; +for (const name of iconNames) { + if (derived.has(name) && !exclusions.includes(name)) continue; + const exported = lucide[toPascalCase(name)]; + const live = exported === undefined ? undefined : liveKeyOf.get(exported); + if (live === undefined) unresolved.push(name); + else aliases[name] = live; +} + +if (unresolved.length > 0) { + console.error( + `${unresolved.length} dynamic icon name(s) resolve to no live record key by identity: ` + + `${unresolved.slice(0, 10).join(', ')}.\n` + + 'That means the DYNAMIC vocabulary carries a name the RECORD cannot draw at all, so the ' + + 'reconstruction would accept a name and then render the fallback glyph — the exact silent ' + + 'degradation this table exists to prevent. Refusing to generate.', + ); + process.exit(1); +} + +const version = JSON.parse(readFileSync(lucideRequire.resolve('lucide-react/package.json'), 'utf8')).version; + +const body = `/** + * 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. + */ + +/** + * GENERATED by \`scripts/gen-lucide-dynamic-name-aliases.mjs\` from + * lucide-react@${version}. ⛔ Do not hand-edit — run \`pnpm gen:lucide-aliases\`. + * + * The part of lucide's DYNAMIC icon vocabulary that the \`icons\` record this + * bundle already carries cannot produce on its own. Deriving the rest is what + * takes \`lucide-react/dynamic.mjs\` (a ${'120,683'}-byte import map whose only + * job is to be a list of names) off the eager path without narrowing what + * \`LazyIcon\` will draw. See the generator's header for the mechanism, and + * \`lucide-dynamic-name-aliases.test.ts\` for the drift guard that keeps this + * file honest across a lucide bump. + * + * Counts at generation time: ${recordKeys.length} record keys, ${iconNames.length} dynamic names, + * ${Object.keys(aliases).length} aliases, ${exclusions.length} exclusions. + */ + +/** + * Dynamic names with no record key of their own, each mapped to the LIVE record + * key that draws it. Found by object identity against lucide's own exports, so + * a rename is read off the installed package rather than remembered here. + */ +export const LUCIDE_DYNAMIC_NAME_ALIASES: Readonly> = Object.freeze({ +${Object.entries(aliases).map(([k, v]) => ` '${k}': '${v}',`).join('\n')} +}); + +/** + * Spellings the runtime derivation produces that lucide does not publish. Listed + * so the reconstructed vocabulary is EXACTLY lucide's, not a superset that would + * accept an invented name and answer \`isLucideIconName\` with a false yes. + */ +export const LUCIDE_DERIVED_NAME_EXCLUSIONS: readonly string[] = Object.freeze([ +${exclusions.map((n) => ` '${n}',`).join('\n')} +]); +`; + +if (process.argv.includes('--check')) { + const current = readFileSync(OUT, 'utf8'); + if (current !== body) { + console.error(`${OUT} is STALE — re-run \`pnpm gen:lucide-aliases\`.`); + process.exit(1); + } + console.log(`OK — ${OUT} matches lucide-react@${version}.`); +} else { + writeFileSync(OUT, body); + console.log( + `wrote ${OUT}: ${Object.keys(aliases).length} aliases, ${exclusions.length} exclusions, ` + + `from lucide-react@${version} (${recordKeys.length} record keys, ${iconNames.length} dynamic names).`, + ); +}