diff --git a/.changeset/9251-icons-record-not-eager.md b/.changeset/9251-icons-record-not-eager.md new file mode 100644 index 0000000000..9511145fef --- /dev/null +++ b/.changeset/9251-icons-record-not-eager.md @@ -0,0 +1,33 @@ +--- +'@object-ui/components': minor +--- + +Take lucide's runtime `icons` record off the console's eager path (objectui#9251, +maintainer ruling of 2026-09-13, decision batch #132 item 4). + +`resolveIcon` — the single icon-name seam every renderer in this stack goes +through — answered "is this a legal icon name, and which glyph is it?" by +indexing lucide's `icons` record. A namespace object has no dead members, so +that one index pulled **every** icon module into the bundle that holds +`@object-ui/components`: 1,781 icon module definitions, measured in the +console's `ui-components` chunk. + +**What changed.** Membership now comes from a static list generated at build +time from lucide's own export manifest +(`scripts/regenerate-lucide-record-icon-names.mjs`), and the glyph is fetched +through lucide's dynamic-import map. Nothing that ships imports the record for a +value any more. + +**The accepted vocabulary is unchanged.** It is still the record's keys and +deliberately not `lucide-react/dynamic.mjs`'s `iconNames`, which is a strict +superset carrying 258 spellings lucide retired (`edit`, `smile`, `filter`, +`alert-triangle`). A name that resolved before resolves now; a name that +returned `null` before returns `null` now, in the same tick — so the four +different things call sites draw for an unresolvable name are untouched. + +**What a consumer can observe.** The `` is emitted synchronously, with +lucide's own classes (`lucide`, `lucide-house`, and for the 95 digit-bearing +names both spellings, e.g. `lucide-trash2 lucide-trash-2`), box, attributes and +your `className`. Its `` children arrive when the icon's own chunk lands. +Selecting or styling by `svg.lucide-` keeps working on the first frame; +a test that asserts on the path data inside the svg now has to await it. diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index ce0e713f4e..48824d8d2d 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -732,6 +732,56 @@ export default defineConfig({ { name: 'vendor-radix', test: /[\\/]node_modules[\\/]@radix-ui[\\/]/, priority: 95 }, { name: 'vendor-objectstack', test: vendorObjectstackTest, priority: 95 }, { name: 'vendor-icons-core', test: /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/](lucide-react|esm[\\/](Icon|createLucideIcon|defaultAttributes|shared))/, priority: 90 }, + // + // ## ONE CHUNK PER ICON — and ⛔ why this is not the regroup objectui#9251 forbids + // + // ⚠️ Read this before reading the group below as the shape that + // card refused. The refused shape is *an aggregate*: one + // `vendor-icons-*` chunk holding lucide's ~1,781 per-icon modules, + // which would move them off the `ui-components` line and change the + // page load by nothing, because one eagerly-imported member makes + // the whole chunk eager — the same mechanism spelled out for the + // i18n catalogues below. In that shape the budget row goes green + // and the browser downloads exactly what it downloaded before. + // + // This group cannot do that. Its `name` is a FUNCTION of the module + // id, so it emits one single-module chunk per icon: an eager icon is + // eager alone and a lazy one stays lazy. It aggregates nothing, and + // it is the per-catalogue remedy of objectui#7479 applied to the + // same defect one library over. + // + // ## What it is actually for, measured + // + // Once objectui#9251 took the `icons` record off the eager path, + // the ~125 icons that first-party code still imports BY NAME became + // shared modules — reachable statically from a workspace chunk and + // dynamically from lucide's import map. With no group claiming them, + // rolldown parked them inside whichever chunk it liked, and three of + // those chunks were LAZY plugin chunks: + // + // | plugin-dashboard | 21 icons parked, incl. `arrow-up-right` | + // | plugin-gantt | 62 icons parked, incl. `file-down` | + // | plugin-report | 1 icon parked, `table-2` | + // + // The eager `index-*.js` chunk then held a STATIC + // `import{i as ri}from"./plugin-dashboard-*.js"` for one of those + // icons — and a static import of a chunk is the whole chunk. All + // three plugins were dragged into the eager closure: 326,305 raw / + // 96,133 gzipped bytes of lazily-loaded plugin code on every page + // load, for three icons. + // + // ⛔ That is the opposite of a regroup that moves no bytes: those + // bytes are REAL and they are removed by this line. The reading is + // on objectui#9251's pull request, taken on two console builds in + // one container. + { + name: (id: string) => { + const icon = /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/]esm[\\/]icons[\\/]([a-z0-9-]+)\.mjs$/.exec(id); + return icon ? `vendor-icon-${icon[1]}` : null; + }, + test: /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/]esm[\\/]icons[\\/]/, + priority: 90, + }, { name: 'vendor-ui-utils', test: /[\\/]node_modules[\\/](class-variance-authority|clsx|tailwind-merge|sonner)[\\/]/, priority: 90 }, { name: 'vendor-zod', test: /[\\/]node_modules[\\/]zod[\\/]/, priority: 90 }, { name: 'vendor-charts', test: /[\\/]node_modules[\\/](recharts|d3-|victory-)/, priority: 90 }, diff --git a/packages/components/src/lib/lucide-record-icon-names.ts b/packages/components/src/lib/lucide-record-icon-names.ts new file mode 100644 index 0000000000..c4704a906e --- /dev/null +++ b/packages/components/src/lib/lucide-record-icon-names.ts @@ -0,0 +1,32 @@ +/** + * 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 FILE — do not edit by hand. + * + * Regenerate: node scripts/regenerate-lucide-record-icon-names.mjs + * Verified by: scripts/check-lucide-icon-record-names.mjs (part 4), which also + * proves this list is the same vocabulary as lucide's runtime + * `icons` record. + * + * The legal icon-name set for `renderers/action/resolve-icon.ts`, as read out + * of lucide's own export manifest at generation time. + * + * Each entry is `PascalCaseKey:kebab-module-name`. The first is what the seam + * looks a tokenised author-supplied name up by; the second is what + * `DynamicIcon` loads. ⛔ The second is NOT derivable from the first by a + * regex — 95 of these keys carry digits that lucide splits and a conversion + * does not (`Trash2` is `trash-2`, `ArrowDown01` is `arrow-down-0-1`). + * + * ⛔ This list is deliberately NOT `Object.keys(icons)`. That derivation is the + * eager record written a second way: indexing the record pulls every icon + * module into the eager closure, which is the payload objectui#9251 removed. + */ + +export const LUCIDE_RECORD_ICON_NAME_TABLE = + 'AArrowDown:a-arrow-down,AArrowUp:a-arrow-up,ALargeSmall:a-large-small,Accessibility:accessibility,Activity:activity,Ad:ad,AirVent:air-vent,Airplay:airplay,AlarmClock:alarm-clock,AlarmClockCheck:alarm-clock-check,AlarmClockMinus:alarm-clock-minus,AlarmClockOff:alarm-clock-off,AlarmClockPlus:alarm-clock-plus,AlarmSmoke:alarm-smoke,Album:album,AlignCenterHorizontal:align-center-horizontal,AlignCenterVertical:align-center-vertical,AlignEndHorizontal:align-end-horizontal,AlignEndVertical:align-end-vertical,AlignHorizontalDistributeCenter:align-horizontal-distribute-center,AlignHorizontalDistributeEnd:align-horizontal-distribute-end,AlignHorizontalDistributeStart:align-horizontal-distribute-start,AlignHorizontalJustifyCenter:align-horizontal-justify-center,AlignHorizontalJustifyEnd:align-horizontal-justify-end,AlignHorizontalJustifyStart:align-horizontal-justify-start,AlignHorizontalSpaceAround:align-horizontal-space-around,AlignHorizontalSpaceBetween:align-horizontal-space-between,AlignStartHorizontal:align-start-horizontal,AlignStartVertical:align-start-vertical,AlignVerticalDistributeCenter:align-vertical-distribute-center,AlignVerticalDistributeEnd:align-vertical-distribute-end,AlignVerticalDistributeStart:align-vertical-distribute-start,AlignVerticalJustifyCenter:align-vertical-justify-center,AlignVerticalJustifyEnd:align-vertical-justify-end,AlignVerticalJustifyStart:align-vertical-justify-start,AlignVerticalSpaceAround:align-vertical-space-around,AlignVerticalSpaceBetween:align-vertical-space-between,Ambulance:ambulance,Ampersand:ampersand,Ampersands:ampersands,Amphora:amphora,Anchor:anchor,Angle:angle,Antenna:antenna,Anvil:anvil,Aperture:aperture,AppWindow:app-window,AppWindowMac:app-window-mac,Apple:apple,Archive:archive,ArchiveRestore:archive-restore,ArchiveX:archive-x,Armchair:armchair,ArrowBigDown:arrow-big-down,ArrowBigDownDash:arrow-big-down-dash,ArrowBigLeft:arrow-big-left,ArrowBigLeftDash:arrow-big-left-dash,ArrowBigRight:arrow-big-right,ArrowBigRightDash:arrow-big-right-dash,ArrowBigUp:arrow-big-up,ArrowBigUpDash:arrow-big-up-dash,ArrowDown:arrow-down,ArrowDown01:arrow-down-0-1,ArrowDown10:arrow-down-1-0,ArrowDownAZ:arrow-down-a-z,ArrowDownFromLine:arrow-down-from-line,ArrowDownLeft:arrow-down-left,ArrowDownNarrowWide:arrow-down-narrow-wide,ArrowDownRight:arrow-down-right,ArrowDownToDot:arrow-down-to-dot,ArrowDownToLine:arrow-down-to-line,ArrowDownUp:arrow-down-up,ArrowDownWideNarrow:arrow-down-wide-narrow,ArrowDownZA:arrow-down-z-a,ArrowLeft:arrow-left,ArrowLeftFromLine:arrow-left-from-line,ArrowLeftRight:arrow-left-right,ArrowLeftToLine:arrow-left-to-line,ArrowRight:arrow-right,ArrowRightFromLine:arrow-right-from-line,ArrowRightLeft:arrow-right-left,ArrowRightToLine:arrow-right-to-line,ArrowUp:arrow-up,ArrowUp01:arrow-up-0-1,ArrowUp10:arrow-up-1-0,ArrowUpAZ:arrow-up-a-z,ArrowUpDown:arrow-up-down,ArrowUpFromDot:arrow-up-from-dot,ArrowUpFromLine:arrow-up-from-line,ArrowUpLeft:arrow-up-left,ArrowUpNarrowWide:arrow-up-narrow-wide,ArrowUpRight:arrow-up-right,ArrowUpToLine:arrow-up-to-line,ArrowUpWideNarrow:arrow-up-wide-narrow,ArrowUpZA:arrow-up-z-a,ArrowsUpFromLine:arrows-up-from-line,Asterisk:asterisk,Astroid:astroid,AtSign:at-sign,Atom:atom,AudioLines:audio-lines,AudioLinesOff:audio-lines-off,AudioLinesX:audio-lines-x,AudioWaveform:audio-waveform,Award:award,Axe:axe,Axis3d:axis-3d,Baby:baby,Backpack:backpack,Badge:badge,BadgeAlert:badge-alert,BadgeCent:badge-cent,BadgeCheck:badge-check,BadgeDollarSign:badge-dollar-sign,BadgeEuro:badge-euro,BadgeIndianRupee:badge-indian-rupee,BadgeInfo:badge-info,BadgeJapaneseYen:badge-japanese-yen,BadgeMinus:badge-minus,BadgePercent:badge-percent,BadgePlus:badge-plus,BadgePoundSterling:badge-pound-sterling,BadgeQuestionMark:badge-question-mark,BadgeRussianRuble:badge-russian-ruble,BadgeSwissFranc:badge-swiss-franc,BadgeTurkishLira:badge-turkish-lira,BadgeX:badge-x,BaggageClaim:baggage-claim,Balloon:balloon,Ban:ban,Banana:banana,Bandage:bandage,Banknote:banknote,BanknoteArrowDown:banknote-arrow-down,BanknoteArrowUp:banknote-arrow-up,BanknoteCheck:banknote-check,BanknoteX:banknote-x,Barcode:barcode,Barrel:barrel,Baseline:baseline,Bath:bath,Battery:battery,BatteryCharging:battery-charging,BatteryFull:battery-full,BatteryLow:battery-low,BatteryMedium:battery-medium,BatteryPlus:battery-plus,BatteryWarning:battery-warning,Beaker:beaker,Bean:bean,BeanOff:bean-off,Bed:bed,BedDouble:bed-double,BedSingle:bed-single,Beef:beef,BeefOff:beef-off,Beer:beer,BeerOff:beer-off,Bell:bell,BellCheck:bell-check,BellDot:bell-dot,BellElectric:bell-electric,BellMinus:bell-minus,BellOff:bell-off,BellPlus:bell-plus,BellRing:bell-ring,BetweenHorizontalEnd:between-horizontal-end,BetweenHorizontalStart:between-horizontal-start,BetweenVerticalEnd:between-vertical-end,BetweenVerticalStart:between-vertical-start,BicepsFlexed:biceps-flexed,Bike:bike,Binary:binary,Binoculars:binoculars,Biohazard:biohazard,Bird:bird,Birdhouse:birdhouse,Bitcoin:bitcoin,Blend:blend,Blender:blender,Blinds:blinds,Blocks:blocks,Bluetooth:bluetooth,BluetoothConnected:bluetooth-connected,BluetoothOff:bluetooth-off,BluetoothSearching:bluetooth-searching,Bold:bold,Bolt:bolt,Bomb:bomb,Bone:bone,BoneFracture:bone-fracture,Book:book,BookA:book-a,BookAlert:book-alert,BookAudio:book-audio,BookCheck:book-check,BookCopy:book-copy,BookDashed:book-dashed,BookDown:book-down,BookHeadphones:book-headphones,BookHeart:book-heart,BookImage:book-image,BookKey:book-key,BookLock:book-lock,BookMarked:book-marked,BookMinus:book-minus,BookOpen:book-open,BookOpenCheck:book-open-check,BookOpenText:book-open-text,BookPlus:book-plus,BookSearch:book-search,BookText:book-text,BookType:book-type,BookUp:book-up,BookUp2:book-up-2,BookUser:book-user,BookX:book-x,Bookmark:bookmark,BookmarkCheck:bookmark-check,BookmarkMinus:bookmark-minus,BookmarkOff:bookmark-off,BookmarkPlus:bookmark-plus,BookmarkX:bookmark-x,BoomBox:boom-box,Bot:bot,BotMessageSquare:bot-message-square,BotOff:bot-off,BottleWine:bottle-wine,BowArrow:bow-arrow,Box:box,Boxes:boxes,Braces:braces,Brackets:brackets,Brain:brain,BrainCircuit:brain-circuit,BrainCog:brain-cog,BrickWall:brick-wall,BrickWallFire:brick-wall-fire,BrickWallShield:brick-wall-shield,Briefcase:briefcase,BriefcaseBusiness:briefcase-business,BriefcaseConveyorBelt:briefcase-conveyor-belt,BriefcaseMedical:briefcase-medical,BringToFront:bring-to-front,Broccoli:broccoli,Broom:broom,BroomSparkles:broom-sparkles,Brush:brush,BrushCleaning:brush-cleaning,Bubbles:bubbles,Bug:bug,BugOff:bug-off,BugPlay:bug-play,Building:building,Building2:building-2,Bus:bus,BusFront:bus-front,Cable:cable,CableCar:cable-car,Cake:cake,CakeSlice:cake-slice,Calculator:calculator,Calendar:calendar,Calendar1:calendar-1,CalendarArrowDown:calendar-arrow-down,CalendarArrowUp:calendar-arrow-up,CalendarCheck:calendar-check,CalendarCheck2:calendar-check-2,CalendarClock:calendar-clock,CalendarCog:calendar-cog,CalendarDays:calendar-days,CalendarFold:calendar-fold,CalendarHeart:calendar-heart,CalendarMinus:calendar-minus,CalendarMinus2:calendar-minus-2,CalendarOff:calendar-off,CalendarPlus:calendar-plus,CalendarPlus2:calendar-plus-2,CalendarRange:calendar-range,CalendarSearch:calendar-search,CalendarSync:calendar-sync,CalendarX:calendar-x,CalendarX2:calendar-x-2,Calendars:calendars,Camera:camera,CameraOff:camera-off,Candy:candy,CandyCane:candy-cane,CandyOff:candy-off,Cannabis:cannabis,CannabisOff:cannabis-off,Captions:captions,CaptionsOff:captions-off,Car:car,CarBattery:car-battery,CarFront:car-front,CarTaxiFront:car-taxi-front,Caravan:caravan,CardSim:card-sim,Carrot:carrot,CaseLower:case-lower,CaseSensitive:case-sensitive,CaseUpper:case-upper,CassetteTape:cassette-tape,Cast:cast,Castle:castle,Cat:cat,Cctv:cctv,CctvOff:cctv-off,ChartArea:chart-area,ChartBar:chart-bar,ChartBarBig:chart-bar-big,ChartBarDecreasing:chart-bar-decreasing,ChartBarIncreasing:chart-bar-increasing,ChartBarStacked:chart-bar-stacked,ChartCandlestick:chart-candlestick,ChartColumn:chart-column,ChartColumnBig:chart-column-big,ChartColumnDecreasing:chart-column-decreasing,ChartColumnIncreasing:chart-column-increasing,ChartColumnStacked:chart-column-stacked,ChartGantt:chart-gantt,ChartLine:chart-line,ChartNetwork:chart-network,ChartNoAxesColumn:chart-no-axes-column,ChartNoAxesColumnDecreasing:chart-no-axes-column-decreasing,ChartNoAxesColumnIncreasing:chart-no-axes-column-increasing,ChartNoAxesCombined:chart-no-axes-combined,ChartNoAxesGantt:chart-no-axes-gantt,ChartPie:chart-pie,ChartScatter:chart-scatter,ChartSpline:chart-spline,Check:check,CheckCheck:check-check,CheckLine:check-line,ChefHat:chef-hat,Cherry:cherry,ChessBishop:chess-bishop,ChessKing:chess-king,ChessKnight:chess-knight,ChessPawn:chess-pawn,ChessQueen:chess-queen,ChessRook:chess-rook,ChevronDown:chevron-down,ChevronFirst:chevron-first,ChevronLast:chevron-last,ChevronLeft:chevron-left,ChevronRight:chevron-right,ChevronUp:chevron-up,ChevronsDown:chevrons-down,ChevronsDownUp:chevrons-down-up,ChevronsLeft:chevrons-left,ChevronsLeftRight:chevrons-left-right,ChevronsLeftRightEllipsis:chevrons-left-right-ellipsis,ChevronsRight:chevrons-right,ChevronsRightLeft:chevrons-right-left,ChevronsUp:chevrons-up,ChevronsUpDown:chevrons-up-down,Church:church,Cigarette:cigarette,CigaretteOff:cigarette-off,Circle:circle,CircleAlert:circle-alert,CircleArrowDown:circle-arrow-down,CircleArrowLeft:circle-arrow-left,CircleArrowOutDownLeft:circle-arrow-out-down-left,CircleArrowOutDownRight:circle-arrow-out-down-right,CircleArrowOutUpLeft:circle-arrow-out-up-left,CircleArrowOutUpRight:circle-arrow-out-up-right,CircleArrowRight:circle-arrow-right,CircleArrowUp:circle-arrow-up,CircleCheck:circle-check,CircleCheckBig:circle-check-big,CircleChevronDown:circle-chevron-down,CircleChevronLeft:circle-chevron-left,CircleChevronRight:circle-chevron-right,CircleChevronUp:circle-chevron-up,CircleDashed:circle-dashed,CircleDivide:circle-divide,CircleDollarSign:circle-dollar-sign,CircleDot:circle-dot,CircleDotDashed:circle-dot-dashed,CircleEllipsis:circle-ellipsis,CircleEqual:circle-equal,CircleEuro:circle-euro,CircleFadingArrowUp:circle-fading-arrow-up,CircleFadingPlus:circle-fading-plus,CircleGauge:circle-gauge,CircleMinus:circle-minus,CircleOff:circle-off,CircleParking:circle-parking,CircleParkingOff:circle-parking-off,CirclePause:circle-pause,CirclePercent:circle-percent,CirclePile:circle-pile,CirclePlay:circle-play,CirclePlus:circle-plus,CirclePoundSterling:circle-pound-sterling,CirclePower:circle-power,CircleQuestionMark:circle-question-mark,CircleSlash:circle-slash,CircleSlash2:circle-slash-2,CircleSmall:circle-small,CircleStar:circle-star,CircleStop:circle-stop,CircleUser:circle-user,CircleUserRound:circle-user-round,CircleX:circle-x,CircuitBoard:circuit-board,Citrus:citrus,Clapperboard:clapperboard,Clipboard:clipboard,ClipboardCheck:clipboard-check,ClipboardClock:clipboard-clock,ClipboardCopy:clipboard-copy,ClipboardList:clipboard-list,ClipboardMinus:clipboard-minus,ClipboardPaste:clipboard-paste,ClipboardPen:clipboard-pen,ClipboardPenLine:clipboard-pen-line,ClipboardPlus:clipboard-plus,ClipboardType:clipboard-type,ClipboardX:clipboard-x,Clock:clock,Clock1:clock-1,Clock10:clock-10,Clock11:clock-11,Clock12:clock-12,Clock2:clock-2,Clock3:clock-3,Clock4:clock-4,Clock5:clock-5,Clock6:clock-6,Clock7:clock-7,Clock8:clock-8,Clock9:clock-9,ClockAlert:clock-alert,ClockArrowDown:clock-arrow-down,ClockArrowLeft:clock-arrow-left,ClockArrowRight:clock-arrow-right,ClockArrowUp:clock-arrow-up,ClockCheck:clock-check,ClockFading:clock-fading,ClockPlus:clock-plus,ClosedCaption:closed-caption,Cloud:cloud,CloudAlert:cloud-alert,CloudBackup:cloud-backup,CloudCheck:cloud-check,CloudCog:cloud-cog,CloudDownload:cloud-download,CloudDrizzle:cloud-drizzle,CloudFog:cloud-fog,CloudHail:cloud-hail,CloudLightning:cloud-lightning,CloudMoon:cloud-moon,CloudMoonRain:cloud-moon-rain,CloudOff:cloud-off,CloudRain:cloud-rain,CloudRainWind:cloud-rain-wind,CloudSnow:cloud-snow,CloudSun:cloud-sun,CloudSunRain:cloud-sun-rain,CloudSync:cloud-sync,CloudUpload:cloud-upload,Cloudy:cloudy,Clover:clover,Club:club,Code:code,CodeXml:code-xml,Coffee:coffee,Cog:cog,Coins:coins,Columns2:columns-2,Columns3:columns-3,Columns3Cog:columns-3-cog,Columns4:columns-4,Combine:combine,Command:command,Compass:compass,Component:component,Computer:computer,ConciergeBell:concierge-bell,Cone:cone,Construction:construction,Contact:contact,ContactRound:contact-round,Container:container,Contrast:contrast,Cookie:cookie,CookingPot:cooking-pot,Copy:copy,CopyCheck:copy-check,CopyMinus:copy-minus,CopyPlus:copy-plus,CopySlash:copy-slash,CopyX:copy-x,Copyleft:copyleft,Copyright:copyright,CornerDownLeft:corner-down-left,CornerDownRight:corner-down-right,CornerLeftDown:corner-left-down,CornerLeftUp:corner-left-up,CornerRightDown:corner-right-down,CornerRightUp:corner-right-up,CornerUpLeft:corner-up-left,CornerUpRight:corner-up-right,Cpu:cpu,CreativeCommons:creative-commons,CreditCard:credit-card,Croissant:croissant,Crop:crop,Cross:cross,Crosshair:crosshair,Crown:crown,Cuboid:cuboid,CupSoda:cup-soda,Currency:currency,Cylinder:cylinder,Dam:dam,Database:database,DatabaseArrowDown:database-arrow-down,DatabaseArrowUp:database-arrow-up,DatabaseBackup:database-backup,DatabaseCheck:database-check,DatabaseMinus:database-minus,DatabasePlus:database-plus,DatabaseSearch:database-search,DatabaseX:database-x,DatabaseZap:database-zap,DecimalsArrowLeft:decimals-arrow-left,DecimalsArrowRight:decimals-arrow-right,Delete:delete,Dessert:dessert,Diameter:diameter,Diamond:diamond,DiamondMinus:diamond-minus,DiamondPercent:diamond-percent,DiamondPlus:diamond-plus,Dice1:dice-1,Dice2:dice-2,Dice3:dice-3,Dice4:dice-4,Dice5:dice-5,Dice6:dice-6,Dices:dices,Diff:diff,Disc:disc,Disc2:disc-2,Disc3:disc-3,DiscAlbum:disc-album,Divide:divide,Dna:dna,DnaOff:dna-off,Dock:dock,Dog:dog,DollarSign:dollar-sign,Donut:donut,DoorClosed:door-closed,DoorClosedLocked:door-closed-locked,DoorOpen:door-open,Dot:dot,Download:download,DraftingCompass:drafting-compass,Drama:drama,Drill:drill,Drone:drone,Droplet:droplet,DropletOff:droplet-off,Droplets:droplets,Drum:drum,Drumstick:drumstick,Dumbbell:dumbbell,Ear:ear,EarOff:ear-off,Earth:earth,EarthLock:earth-lock,Eclipse:eclipse,Egg:egg,EggFried:egg-fried,EggOff:egg-off,Eject:eject,Ellipse:ellipse,Ellipsis:ellipsis,EllipsisVertical:ellipsis-vertical,Equal:equal,EqualApproximately:equal-approximately,EqualNot:equal-not,Eraser:eraser,EthernetPort:ethernet-port,Euro:euro,EvCharger:ev-charger,Expand:expand,ExternalLink:external-link,Eye:eye,EyeClosed:eye-closed,EyeDashed:eye-dashed,EyeOff:eye-off,FaceAngry:face-angry,FaceExpressionless:face-expressionless,FaceGrinning:face-grinning,FaceNeutral:face-neutral,FaceSlightlyFrowning:face-slightly-frowning,FaceSlightlySmiling:face-slightly-smiling,FaceSlightlySmilingPlus:face-slightly-smiling-plus,Factory:factory,Fan:fan,FastForward:fast-forward,Feather:feather,Fence:fence,FerrisWheel:ferris-wheel,File:file,FileArchive:file-archive,FileAxis3d:file-axis-3d,FileBadge:file-badge,FileBox:file-box,FileBraces:file-braces,FileBracesCorner:file-braces-corner,FileChartColumn:file-chart-column,FileChartColumnIncreasing:file-chart-column-increasing,FileChartLine:file-chart-line,FileChartPie:file-chart-pie,FileCheck:file-check,FileCheckCorner:file-check-corner,FileClock:file-clock,FileCode:file-code,FileCodeCorner:file-code-corner,FileCog:file-cog,FileDiff:file-diff,FileDigit:file-digit,FileDown:file-down,FileExclamationPoint:file-exclamation-point,FileHeadphone:file-headphone,FileHeart:file-heart,FileImage:file-image,FileInput:file-input,FileKey:file-key,FileLock:file-lock,FileMinus:file-minus,FileMinusCorner:file-minus-corner,FileMusic:file-music,FileOutput:file-output,FilePen:file-pen,FilePenLine:file-pen-line,FilePlay:file-play,FilePlus:file-plus,FilePlusCorner:file-plus-corner,FileQuestionMark:file-question-mark,FileScan:file-scan,FileSearch:file-search,FileSearchCorner:file-search-corner,FileSignal:file-signal,FileSliders:file-sliders,FileSpreadsheet:file-spreadsheet,FileStack:file-stack,FileSymlink:file-symlink,FileTerminal:file-terminal,FileText:file-text,FileType:file-type,FileTypeCorner:file-type-corner,FileUp:file-up,FileUser:file-user,FileVideoCamera:file-video-camera,FileVolume:file-volume,FileX:file-x,FileXCorner:file-x-corner,Files:files,Film:film,FingerprintPattern:fingerprint-pattern,FireExtinguisher:fire-extinguisher,Fish:fish,FishOff:fish-off,FishSymbol:fish-symbol,FishingHook:fishing-hook,FishingRod:fishing-rod,Flag:flag,FlagOff:flag-off,FlagTriangleLeft:flag-triangle-left,FlagTriangleRight:flag-triangle-right,Flame:flame,FlameKindling:flame-kindling,Flashlight:flashlight,FlashlightOff:flashlight-off,FlaskConical:flask-conical,FlaskConicalOff:flask-conical-off,FlaskRound:flask-round,FlipHorizontal2:flip-horizontal-2,FlipVertical2:flip-vertical-2,Flower:flower,Flower2:flower-2,Focus:focus,FoldHorizontal:fold-horizontal,FoldVertical:fold-vertical,Folder:folder,FolderArchive:folder-archive,FolderBookmark:folder-bookmark,FolderCheck:folder-check,FolderClock:folder-clock,FolderClosed:folder-closed,FolderCode:folder-code,FolderCog:folder-cog,FolderDot:folder-dot,FolderDown:folder-down,FolderGit:folder-git,FolderGit2:folder-git-2,FolderHeart:folder-heart,FolderInput:folder-input,FolderKanban:folder-kanban,FolderKey:folder-key,FolderLock:folder-lock,FolderMinus:folder-minus,FolderOpen:folder-open,FolderOpenDot:folder-open-dot,FolderOutput:folder-output,FolderPen:folder-pen,FolderPlus:folder-plus,FolderRoot:folder-root,FolderSearch:folder-search,FolderSearch2:folder-search-2,FolderSymlink:folder-symlink,FolderSync:folder-sync,FolderTree:folder-tree,FolderUp:folder-up,FolderX:folder-x,Folders:folders,Footprints:footprints,Forklift:forklift,Form:form,Forward:forward,Frame:frame,Fuel:fuel,Fullscreen:fullscreen,Funnel:funnel,FunnelPlus:funnel-plus,FunnelX:funnel-x,Galaxy:galaxy,GalleryHorizontal:gallery-horizontal,GalleryHorizontalEnd:gallery-horizontal-end,GalleryThumbnails:gallery-thumbnails,GalleryVertical:gallery-vertical,GalleryVerticalEnd:gallery-vertical-end,Gamepad:gamepad,Gamepad2:gamepad-2,GamepadDirectional:gamepad-directional,Gauge:gauge,Gavel:gavel,Gem:gem,GeorgianLari:georgian-lari,Ghost:ghost,Gift:gift,GitBranch:git-branch,GitBranchMinus:git-branch-minus,GitBranchPlus:git-branch-plus,GitCommitHorizontal:git-commit-horizontal,GitCommitVertical:git-commit-vertical,GitCompare:git-compare,GitCompareArrows:git-compare-arrows,GitFork:git-fork,GitGraph:git-graph,GitMerge:git-merge,GitMergeConflict:git-merge-conflict,GitPullRequest:git-pull-request,GitPullRequestArrow:git-pull-request-arrow,GitPullRequestClosed:git-pull-request-closed,GitPullRequestCreate:git-pull-request-create,GitPullRequestCreateArrow:git-pull-request-create-arrow,GitPullRequestDraft:git-pull-request-draft,GlassWater:glass-water,Glasses:glasses,Globe:globe,GlobeCheck:globe-check,GlobeLock:globe-lock,GlobeOff:globe-off,GlobeX:globe-x,Goal:goal,Gpu:gpu,GraduationCap:graduation-cap,Grape:grape,Grid2x2:grid-2x2,Grid2x2Check:grid-2x2-check,Grid2x2Plus:grid-2x2-plus,Grid2x2X:grid-2x2-x,Grid3x2:grid-3x2,Grid3x3:grid-3x3,Grip:grip,GripHorizontal:grip-horizontal,GripVertical:grip-vertical,Group:group,Guitar:guitar,Ham:ham,Hamburger:hamburger,Hammer:hammer,Hand:hand,HandCoins:hand-coins,HandFist:hand-fist,HandGrab:hand-grab,HandHeart:hand-heart,HandHelping:hand-helping,HandMetal:hand-metal,HandPlatter:hand-platter,Handbag:handbag,Handshake:handshake,HardDrive:hard-drive,HardDriveDownload:hard-drive-download,HardDriveUpload:hard-drive-upload,HardHat:hard-hat,Hash:hash,HatGlasses:hat-glasses,Haze:haze,Hd:hd,HdmiPort:hdmi-port,Heading:heading,Heading1:heading-1,Heading2:heading-2,Heading3:heading-3,Heading4:heading-4,Heading5:heading-5,Heading6:heading-6,HeadphoneOff:headphone-off,Headphones:headphones,Headset:headset,Heart:heart,HeartCrack:heart-crack,HeartHandshake:heart-handshake,HeartMinus:heart-minus,HeartOff:heart-off,HeartPlus:heart-plus,HeartPulse:heart-pulse,HeartX:heart-x,Heater:heater,Helicopter:helicopter,Hexagon:hexagon,Highlighter:highlighter,Hop:hop,HopOff:hop-off,Hospital:hospital,Hotel:hotel,Hourglass:hourglass,House:house,HouseHeart:house-heart,HousePlug:house-plug,HousePlus:house-plus,HouseWifi:house-wifi,IceCreamBowl:ice-cream-bowl,IceCreamCone:ice-cream-cone,IdCard:id-card,IdCardLanyard:id-card-lanyard,Image:image,ImageDown:image-down,ImageMinus:image-minus,ImageOff:image-off,ImagePlay:image-play,ImagePlus:image-plus,ImageUp:image-up,ImageUpscale:image-upscale,Images:images,Import:import,Inbox:inbox,IndianRupee:indian-rupee,Infinity:infinity,Info:info,InspectionPanel:inspection-panel,Italic:italic,IterationCcw:iteration-ccw,IterationCw:iteration-cw,JapaneseYen:japanese-yen,Joystick:joystick,Kanban:kanban,Kayak:kayak,Key:key,KeyRound:key-round,KeySquare:key-square,Keyboard:keyboard,KeyboardMusic:keyboard-music,KeyboardOff:keyboard-off,Lamp:lamp,LampCeiling:lamp-ceiling,LampDesk:lamp-desk,LampFloor:lamp-floor,LampWallDown:lamp-wall-down,LampWallUp:lamp-wall-up,LandPlot:land-plot,Landmark:landmark,Languages:languages,Laptop:laptop,LaptopMinimal:laptop-minimal,LaptopMinimalCheck:laptop-minimal-check,Lasso:lasso,LassoSelect:lasso-select,LayerArrowDown:layer-arrow-down,LayerArrowUp:layer-arrow-up,Layers:layers,Layers2:layers-2,LayersArrowDown:layers-arrow-down,LayersArrowUp:layers-arrow-up,LayersMinus:layers-minus,LayersPlus:layers-plus,LayoutDashboard:layout-dashboard,LayoutFreeform:layout-freeform,LayoutGrid:layout-grid,LayoutList:layout-list,LayoutPanelLeft:layout-panel-left,LayoutPanelTop:layout-panel-top,LayoutTemplate:layout-template,Leaf:leaf,LeafyGreen:leafy-green,Lectern:lectern,LensConcave:lens-concave,LensConvex:lens-convex,Library:library,LibraryBig:library-big,LifeBuoy:life-buoy,Ligature:ligature,Lightbulb:lightbulb,LightbulbOff:lightbulb-off,LineDotRightHorizontal:line-dot-right-horizontal,LineSquiggle:line-squiggle,LineStyle:line-style,Link:link,Link2:link-2,Link2Off:link-2-off,List:list,ListCheck:list-check,ListChecks:list-checks,ListChevronsDownUp:list-chevrons-down-up,ListChevronsUpDown:list-chevrons-up-down,ListClock:list-clock,ListCollapse:list-collapse,ListEnd:list-end,ListFilter:list-filter,ListFilterPlus:list-filter-plus,ListIndentDecrease:list-indent-decrease,ListIndentIncrease:list-indent-increase,ListMinus:list-minus,ListMusic:list-music,ListOrdered:list-ordered,ListPlus:list-plus,ListRestart:list-restart,ListSortAscending:list-sort-ascending,ListSortDescending:list-sort-descending,ListStart:list-start,ListTodo:list-todo,ListTree:list-tree,ListVideo:list-video,ListX:list-x,Loader:loader,LoaderCircle:loader-circle,LoaderPinwheel:loader-pinwheel,Locate:locate,LocateFixed:locate-fixed,LocateOff:locate-off,Lock:lock,LockKeyhole:lock-keyhole,LockKeyholeOpen:lock-keyhole-open,LockOpen:lock-open,LogIn:log-in,LogOut:log-out,Logs:logs,Lollipop:lollipop,Luggage:luggage,Magnet:magnet,Mail:mail,MailBadge:mail-badge,MailCheck:mail-check,MailClock:mail-clock,MailMinus:mail-minus,MailOpen:mail-open,MailPlus:mail-plus,MailQuestionMark:mail-question-mark,MailSearch:mail-search,MailWarning:mail-warning,MailX:mail-x,Mailbox:mailbox,Mails:mails,Map:map,MapMinus:map-minus,MapPin:map-pin,MapPinCheck:map-pin-check,MapPinCheckInside:map-pin-check-inside,MapPinHouse:map-pin-house,MapPinMinus:map-pin-minus,MapPinMinusInside:map-pin-minus-inside,MapPinOff:map-pin-off,MapPinPen:map-pin-pen,MapPinPlus:map-pin-plus,MapPinPlusInside:map-pin-plus-inside,MapPinSearch:map-pin-search,MapPinX:map-pin-x,MapPinXInside:map-pin-x-inside,MapPinned:map-pinned,MapPlus:map-plus,Mars:mars,MarsStroke:mars-stroke,Martini:martini,Maximize:maximize,Maximize2:maximize-2,Medal:medal,Megaphone:megaphone,MegaphoneOff:megaphone-off,MemoryStick:memory-stick,Menu:menu,Merge:merge,MessageCircle:message-circle,MessageCircleCheck:message-circle-check,MessageCircleCode:message-circle-code,MessageCircleDashed:message-circle-dashed,MessageCircleHeart:message-circle-heart,MessageCircleMore:message-circle-more,MessageCircleOff:message-circle-off,MessageCirclePlus:message-circle-plus,MessageCircleQuestionMark:message-circle-question-mark,MessageCircleReply:message-circle-reply,MessageCircleWarning:message-circle-warning,MessageCircleX:message-circle-x,MessageSquare:message-square,MessageSquareCheck:message-square-check,MessageSquareCode:message-square-code,MessageSquareDashed:message-square-dashed,MessageSquareDiff:message-square-diff,MessageSquareDot:message-square-dot,MessageSquareHeart:message-square-heart,MessageSquareLock:message-square-lock,MessageSquareMore:message-square-more,MessageSquareOff:message-square-off,MessageSquarePlus:message-square-plus,MessageSquareQuote:message-square-quote,MessageSquareReply:message-square-reply,MessageSquareShare:message-square-share,MessageSquareText:message-square-text,MessageSquareWarning:message-square-warning,MessageSquareX:message-square-x,MessagesSquare:messages-square,Metronome:metronome,Mic:mic,MicAudioLines:mic-audio-lines,MicOff:mic-off,MicSignal:mic-signal,MicVocal:mic-vocal,Microchip:microchip,Microscope:microscope,Microwave:microwave,MidiPort:midi-port,Milestone:milestone,Milk:milk,MilkOff:milk-off,Minimize:minimize,Minimize2:minimize-2,Minus:minus,MirrorRectangular:mirror-rectangular,MirrorRound:mirror-round,Monitor:monitor,MonitorCheck:monitor-check,MonitorCloud:monitor-cloud,MonitorCog:monitor-cog,MonitorDot:monitor-dot,MonitorDown:monitor-down,MonitorOff:monitor-off,MonitorPause:monitor-pause,MonitorPlay:monitor-play,MonitorSmartphone:monitor-smartphone,MonitorSpeaker:monitor-speaker,MonitorStop:monitor-stop,MonitorUp:monitor-up,MonitorX:monitor-x,Moon:moon,MoonStar:moon-star,Mop:mop,MopSparkles:mop-sparkles,Mosque:mosque,Motorbike:motorbike,Mountain:mountain,MountainSnow:mountain-snow,Mouse:mouse,MouseLeft:mouse-left,MouseOff:mouse-off,MousePointer:mouse-pointer,MousePointer2:mouse-pointer-2,MousePointer2Off:mouse-pointer-2-off,MousePointerBan:mouse-pointer-ban,MousePointerClick:mouse-pointer-click,MouseRight:mouse-right,Move:move,Move3d:move-3d,MoveDiagonal:move-diagonal,MoveDiagonal2:move-diagonal-2,MoveDown:move-down,MoveDownLeft:move-down-left,MoveDownRight:move-down-right,MoveHorizontal:move-horizontal,MoveLeft:move-left,MoveRight:move-right,MoveUp:move-up,MoveUpLeft:move-up-left,MoveUpRight:move-up-right,MoveVertical:move-vertical,Music:music,Music2:music-2,Music3:music-3,Music4:music-4,Navigation:navigation,Navigation2:navigation-2,Navigation2Off:navigation-2-off,NavigationOff:navigation-off,Network:network,Newspaper:newspaper,Nfc:nfc,NonBinary:non-binary,Notebook:notebook,NotebookPen:notebook-pen,NotebookTabs:notebook-tabs,NotebookText:notebook-text,NotepadText:notepad-text,NotepadTextDashed:notepad-text-dashed,Nut:nut,NutOff:nut-off,Octagon:octagon,OctagonAlert:octagon-alert,OctagonMinus:octagon-minus,OctagonPause:octagon-pause,OctagonX:octagon-x,Omega:omega,Option:option,Orbit:orbit,Origami:origami,Package:package,Package2:package-2,PackageCheck:package-check,PackageMinus:package-minus,PackageOpen:package-open,PackagePlus:package-plus,PackageSearch:package-search,PackageX:package-x,PaintBucket:paint-bucket,PaintRoller:paint-roller,Paintbrush:paintbrush,PaintbrushVertical:paintbrush-vertical,Palette:palette,Panda:panda,PanelBottom:panel-bottom,PanelBottomClose:panel-bottom-close,PanelBottomDashed:panel-bottom-dashed,PanelBottomOpen:panel-bottom-open,PanelLeft:panel-left,PanelLeftClose:panel-left-close,PanelLeftDashed:panel-left-dashed,PanelLeftOpen:panel-left-open,PanelLeftRightDashed:panel-left-right-dashed,PanelRight:panel-right,PanelRightClose:panel-right-close,PanelRightDashed:panel-right-dashed,PanelRightOpen:panel-right-open,PanelTop:panel-top,PanelTopBottomDashed:panel-top-bottom-dashed,PanelTopClose:panel-top-close,PanelTopDashed:panel-top-dashed,PanelTopOpen:panel-top-open,PanelsLeftBottom:panels-left-bottom,PanelsRightBottom:panels-right-bottom,PanelsTopLeft:panels-top-left,PaperBag:paper-bag,Paperclip:paperclip,Parasol:parasol,Parentheses:parentheses,ParkingMeter:parking-meter,PartyPopper:party-popper,Pause:pause,PawPrint:paw-print,PcCase:pc-case,Pen:pen,PenLine:pen-line,PenOff:pen-off,PenTool:pen-tool,Pencil:pencil,PencilLine:pencil-line,PencilOff:pencil-off,PencilRuler:pencil-ruler,PencilSparkles:pencil-sparkles,Pentagon:pentagon,Percent:percent,PersonStanding:person-standing,Phi:phi,PhilippinePeso:philippine-peso,Phone:phone,PhoneCall:phone-call,PhoneForwarded:phone-forwarded,PhoneIncoming:phone-incoming,PhoneMissed:phone-missed,PhoneOff:phone-off,PhoneOutgoing:phone-outgoing,Pi:pi,Piano:piano,Pickaxe:pickaxe,PictureInPicture:picture-in-picture,PictureInPicture2:picture-in-picture-2,PiggyBank:piggy-bank,Pilcrow:pilcrow,PilcrowLeft:pilcrow-left,PilcrowRight:pilcrow-right,Pill:pill,PillBottle:pill-bottle,Pin:pin,PinOff:pin-off,Pipette:pipette,Pizza:pizza,Plane:plane,PlaneLanding:plane-landing,PlaneTakeoff:plane-takeoff,Play:play,PlayOff:play-off,Plug:plug,Plug2:plug-2,PlugZap:plug-zap,Plus:plus,PocketKnife:pocket-knife,Podium:podium,Pointer:pointer,PointerOff:pointer-off,Popcorn:popcorn,Popsicle:popsicle,PoundSterling:pound-sterling,Power:power,PowerOff:power-off,Presentation:presentation,Printer:printer,PrinterCheck:printer-check,PrinterX:printer-x,Projector:projector,Proportions:proportions,Puzzle:puzzle,Pyramid:pyramid,QrCode:qr-code,Quote:quote,Rabbit:rabbit,Radar:radar,Radiation:radiation,Radical:radical,Radio:radio,RadioOff:radio-off,RadioReceiver:radio-receiver,RadioTower:radio-tower,Radius:radius,Rainbow:rainbow,Rat:rat,Ratio:ratio,Receipt:receipt,ReceiptCent:receipt-cent,ReceiptEuro:receipt-euro,ReceiptIndianRupee:receipt-indian-rupee,ReceiptJapaneseYen:receipt-japanese-yen,ReceiptPoundSterling:receipt-pound-sterling,ReceiptRussianRuble:receipt-russian-ruble,ReceiptSwissFranc:receipt-swiss-franc,ReceiptText:receipt-text,ReceiptTurkishLira:receipt-turkish-lira,RectangleCircle:rectangle-circle,RectangleEllipsis:rectangle-ellipsis,RectangleGoggles:rectangle-goggles,RectangleHorizontal:rectangle-horizontal,RectangleVertical:rectangle-vertical,Recycle:recycle,Redo:redo,Redo2:redo-2,RedoDot:redo-dot,RefreshCcw:refresh-ccw,RefreshCcwDot:refresh-ccw-dot,RefreshCw:refresh-cw,RefreshCwOff:refresh-cw-off,Refrigerator:refrigerator,Regex:regex,RemoveFormatting:remove-formatting,Repeat:repeat,Repeat1:repeat-1,Repeat2:repeat-2,RepeatOff:repeat-off,Replace:replace,ReplaceAll:replace-all,Reply:reply,ReplyAll:reply-all,Rewind:rewind,Ribbon:ribbon,Road:road,RobotArm:robot-arm,Rocket:rocket,RockingChair:rocking-chair,RollerCoaster:roller-coaster,Rose:rose,Rotate3d:rotate-3d,RotateCcw:rotate-ccw,RotateCcwClock:rotate-ccw-clock,RotateCcwKey:rotate-ccw-key,RotateCcwSquare:rotate-ccw-square,RotateCw:rotate-cw,RotateCwFadingClock:rotate-cw-fading-clock,RotateCwSquare:rotate-cw-square,Route:route,RouteOff:route-off,Router:router,Rows2:rows-2,Rows3:rows-3,Rows4:rows-4,Rss:rss,Ruler:ruler,RulerDimensionLine:ruler-dimension-line,RussianRuble:russian-ruble,Sailboat:sailboat,Salad:salad,Sandwich:sandwich,Satellite:satellite,SatelliteDish:satellite-dish,SaudiRiyal:saudi-riyal,Save:save,SaveAll:save-all,SaveCheck:save-check,SaveOff:save-off,SavePen:save-pen,SavePlus:save-plus,Scale:scale,Scale3d:scale-3d,Scaling:scaling,Scan:scan,ScanBarcode:scan-barcode,ScanBox:scan-box,ScanEye:scan-eye,ScanFace:scan-face,ScanHeart:scan-heart,ScanLine:scan-line,ScanQrCode:scan-qr-code,ScanSearch:scan-search,ScanSquare:scan-square,ScanText:scan-text,School:school,Scissors:scissors,ScissorsLineDashed:scissors-line-dashed,Scooter:scooter,ScreenShare:screen-share,ScreenShareOff:screen-share-off,Scroll:scroll,ScrollText:scroll-text,Search:search,SearchAlert:search-alert,SearchCheck:search-check,SearchCode:search-code,SearchSlash:search-slash,SearchX:search-x,Section:section,Send:send,SendHorizontal:send-horizontal,SendToBack:send-to-back,SeparatorHorizontal:separator-horizontal,SeparatorVertical:separator-vertical,Server:server,ServerCog:server-cog,ServerCrash:server-crash,ServerOff:server-off,ServerPlus:server-plus,Settings:settings,Settings2:settings-2,Shapes:shapes,Share:share,Share2:share-2,Sheet:sheet,Shell:shell,ShelvingUnit:shelving-unit,Shield:shield,ShieldAlert:shield-alert,ShieldBan:shield-ban,ShieldCheck:shield-check,ShieldCog:shield-cog,ShieldCogCorner:shield-cog-corner,ShieldEllipsis:shield-ellipsis,ShieldHalf:shield-half,ShieldKeyhole:shield-keyhole,ShieldLock:shield-lock,ShieldMinus:shield-minus,ShieldOff:shield-off,ShieldPlus:shield-plus,ShieldQuestionMark:shield-question-mark,ShieldUser:shield-user,ShieldX:shield-x,Ship:ship,ShipCargo:ship-cargo,ShipWheel:ship-wheel,Shirt:shirt,ShoppingBag:shopping-bag,ShoppingBasket:shopping-basket,ShoppingCart:shopping-cart,Shovel:shovel,ShowerHead:shower-head,Shredder:shredder,Shrimp:shrimp,Shrink:shrink,Shrub:shrub,Shuffle:shuffle,Sigma:sigma,Signal:signal,SignalHigh:signal-high,SignalLow:signal-low,SignalMedium:signal-medium,SignalZero:signal-zero,Signature:signature,Signpost:signpost,SignpostBig:signpost-big,Siren:siren,SkipBack:skip-back,SkipForward:skip-forward,Skull:skull,Slash:slash,Slice:slice,SlidersHorizontal:sliders-horizontal,SlidersVertical:sliders-vertical,Smartphone:smartphone,SmartphoneCharging:smartphone-charging,SmartphoneNfc:smartphone-nfc,Snail:snail,Snowflake:snowflake,SoapDispenserDroplet:soap-dispenser-droplet,Sofa:sofa,SolarPanel:solar-panel,Soup:soup,Space:space,Spade:spade,Sparkle:sparkle,Sparkles:sparkles,Speaker:speaker,Speech:speech,SpellCheck:spell-check,SpellCheck2:spell-check-2,Spline:spline,SplinePointer:spline-pointer,Split:split,Spool:spool,SportShoe:sport-shoe,Spotlight:spotlight,SprayCan:spray-can,Sprout:sprout,Square:square,SquareActivity:square-activity,SquareArrowDown:square-arrow-down,SquareArrowDownLeft:square-arrow-down-left,SquareArrowDownRight:square-arrow-down-right,SquareArrowLeft:square-arrow-left,SquareArrowOutDownLeft:square-arrow-out-down-left,SquareArrowOutDownRight:square-arrow-out-down-right,SquareArrowOutUpLeft:square-arrow-out-up-left,SquareArrowOutUpRight:square-arrow-out-up-right,SquareArrowRight:square-arrow-right,SquareArrowRightEnter:square-arrow-right-enter,SquareArrowRightExit:square-arrow-right-exit,SquareArrowUp:square-arrow-up,SquareArrowUpLeft:square-arrow-up-left,SquareArrowUpRight:square-arrow-up-right,SquareAsterisk:square-asterisk,SquareBottomDashedScissors:square-bottom-dashed-scissors,SquareCenterlineDashedHorizontal:square-centerline-dashed-horizontal,SquareCenterlineDashedVertical:square-centerline-dashed-vertical,SquareChartGantt:square-chart-gantt,SquareCheck:square-check,SquareCheckBig:square-check-big,SquareChevronDown:square-chevron-down,SquareChevronLeft:square-chevron-left,SquareChevronRight:square-chevron-right,SquareChevronUp:square-chevron-up,SquareCode:square-code,SquareDashed:square-dashed,SquareDashedBottom:square-dashed-bottom,SquareDashedBottomCode:square-dashed-bottom-code,SquareDashedKanban:square-dashed-kanban,SquareDashedMousePointer:square-dashed-mouse-pointer,SquareDashedText:square-dashed-text,SquareDashedTopSolid:square-dashed-top-solid,SquareDimensions:square-dimensions,SquareDivide:square-divide,SquareDot:square-dot,SquareEqual:square-equal,SquareFunction:square-function,SquareKanban:square-kanban,SquareLibrary:square-library,SquareM:square-m,SquareMenu:square-menu,SquareMinus:square-minus,SquareMousePointer:square-mouse-pointer,SquareOff:square-off,SquareParking:square-parking,SquareParkingOff:square-parking-off,SquarePause:square-pause,SquarePen:square-pen,SquarePercent:square-percent,SquarePi:square-pi,SquarePilcrow:square-pilcrow,SquarePlay:square-play,SquarePlus:square-plus,SquarePower:square-power,SquareRadical:square-radical,SquareRoundCorner:square-round-corner,SquareScissors:square-scissors,SquareSigma:square-sigma,SquareSlash:square-slash,SquareSplitHorizontal:square-split-horizontal,SquareSplitVertical:square-split-vertical,SquareSquare:square-square,SquareStack:square-stack,SquareStar:square-star,SquareStop:square-stop,SquareTerminal:square-terminal,SquareText:square-text,SquareUser:square-user,SquareUserRound:square-user-round,SquareX:square-x,SquaresExclude:squares-exclude,SquaresIntersect:squares-intersect,SquaresSubtract:squares-subtract,SquaresUnite:squares-unite,Squircle:squircle,SquircleDashed:squircle-dashed,Squirrel:squirrel,Stamp:stamp,Star:star,StarCheck:star-check,StarHalf:star-half,StarMinus:star-minus,StarOff:star-off,StarPlus:star-plus,StarX:star-x,StepBack:step-back,StepForward:step-forward,Stethoscope:stethoscope,Sticker:sticker,StickyNote:sticky-note,StickyNoteCheck:sticky-note-check,StickyNoteMinus:sticky-note-minus,StickyNoteOff:sticky-note-off,StickyNotePlus:sticky-note-plus,StickyNoteX:sticky-note-x,StickyNotes:sticky-notes,Stone:stone,Store:store,StretchHorizontal:stretch-horizontal,StretchVertical:stretch-vertical,Strikethrough:strikethrough,Subscript:subscript,Summary:summary,Sun:sun,SunDim:sun-dim,SunMedium:sun-medium,SunMoon:sun-moon,SunSnow:sun-snow,Sunrise:sunrise,Sunset:sunset,Superscript:superscript,SwatchBook:swatch-book,SwissFranc:swiss-franc,SwitchCamera:switch-camera,Sword:sword,Swords:swords,Syringe:syringe,Table:table,Table2:table-2,TableCellsMerge:table-cells-merge,TableCellsSplit:table-cells-split,TableColumnsSplit:table-columns-split,TableOfContents:table-of-contents,TableProperties:table-properties,TableRowsSplit:table-rows-split,Tablet:tablet,TabletSmartphone:tablet-smartphone,Tablets:tablets,Tag:tag,TagPlus:tag-plus,TagX:tag-x,Tags:tags,Tally1:tally-1,Tally2:tally-2,Tally3:tally-3,Tally4:tally-4,Tally5:tally-5,Tangent:tangent,Target:target,Telescope:telescope,Tent:tent,TentTree:tent-tree,Terminal:terminal,TestTube:test-tube,TestTubeDiagonal:test-tube-diagonal,TestTubes:test-tubes,TextAlignCenter:text-align-center,TextAlignEnd:text-align-end,TextAlignJustify:text-align-justify,TextAlignStart:text-align-start,TextCursor:text-cursor,TextCursorInput:text-cursor-input,TextInitial:text-initial,TextQuote:text-quote,TextSearch:text-search,TextWrap:text-wrap,Theater:theater,Thermometer:thermometer,ThermometerSnowflake:thermometer-snowflake,ThermometerSun:thermometer-sun,ThumbsDown:thumbs-down,ThumbsUp:thumbs-up,Ticket:ticket,TicketCheck:ticket-check,TicketMinus:ticket-minus,TicketPercent:ticket-percent,TicketPlus:ticket-plus,TicketSlash:ticket-slash,TicketX:ticket-x,Tickets:tickets,TicketsPlane:tickets-plane,Timeline:timeline,Timer:timer,TimerOff:timer-off,TimerReset:timer-reset,ToggleLeft:toggle-left,ToggleRight:toggle-right,Toilet:toilet,ToolCase:tool-case,Toolbox:toolbox,Tornado:tornado,Torus:torus,Touchpad:touchpad,TouchpadOff:touchpad-off,TowelRack:towel-rack,TowerControl:tower-control,ToyBrick:toy-brick,Tractor:tractor,TrafficCone:traffic-cone,Trailer:trailer,TrainFront:train-front,TrainFrontTunnel:train-front-tunnel,TrainTrack:train-track,TramFront:tram-front,Transgender:transgender,Trash:trash,Trash2:trash-2,TreeDeciduous:tree-deciduous,TreePalm:tree-palm,TreePine:tree-pine,Trees:trees,TrendingDown:trending-down,TrendingUp:trending-up,TrendingUpDown:trending-up-down,Triangle:triangle,TriangleAlert:triangle-alert,TriangleDashed:triangle-dashed,TriangleRight:triangle-right,Trophy:trophy,Truck:truck,TruckElectric:truck-electric,TurkishLira:turkish-lira,Turntable:turntable,Turtle:turtle,Tv:tv,TvMinimal:tv-minimal,TvMinimalPlay:tv-minimal-play,Type:type,TypeOutline:type-outline,Umbrella:umbrella,UmbrellaOff:umbrella-off,Underline:underline,Undo:undo,Undo2:undo-2,UndoDot:undo-dot,UnfoldHorizontal:unfold-horizontal,UnfoldVertical:unfold-vertical,Ungroup:ungroup,University:university,Unlink:unlink,Unlink2:unlink-2,Unplug:unplug,Upload:upload,Usb:usb,UsbCPort:usb-c-port,User:user,UserCheck:user-check,UserCog:user-cog,UserKey:user-key,UserLock:user-lock,UserMinus:user-minus,UserPen:user-pen,UserPlus:user-plus,UserRound:user-round,UserRoundArrowLeft:user-round-arrow-left,UserRoundCheck:user-round-check,UserRoundCog:user-round-cog,UserRoundKey:user-round-key,UserRoundMinus:user-round-minus,UserRoundPen:user-round-pen,UserRoundPlus:user-round-plus,UserRoundSearch:user-round-search,UserRoundX:user-round-x,UserSearch:user-search,UserShield:user-shield,UserStar:user-star,UserX:user-x,Users:users,UsersRound:users-round,Utensils:utensils,UtensilsCrossed:utensils-crossed,UtilityPole:utility-pole,Van:van,Variable:variable,Vault:vault,VectorSquare:vector-square,Vegan:vegan,VenetianMask:venetian-mask,Venus:venus,VenusAndMars:venus-and-mars,Vibrate:vibrate,VibrateOff:vibrate-off,Video:video,VideoOff:video-off,Videotape:videotape,View:view,Voicemail:voicemail,Volleyball:volleyball,Volume:volume,Volume1:volume-1,Volume2:volume-2,VolumeOff:volume-off,VolumeX:volume-x,Vote:vote,Wallet:wallet,WalletCards:wallet-cards,WalletMinimal:wallet-minimal,Wallpaper:wallpaper,Wand:wand,WandSparkles:wand-sparkles,Warehouse:warehouse,WashingMachine:washing-machine,Watch:watch,WavesArrowDown:waves-arrow-down,WavesArrowUp:waves-arrow-up,WavesHorizontal:waves-horizontal,WavesLadder:waves-ladder,WavesVertical:waves-vertical,Waypoints:waypoints,Webcam:webcam,WebcamOff:webcam-off,Webhook:webhook,WebhookOff:webhook-off,Weight:weight,WeightTilde:weight-tilde,Wheat:wheat,WheatOff:wheat-off,WholeWord:whole-word,Wifi:wifi,WifiCog:wifi-cog,WifiHigh:wifi-high,WifiLow:wifi-low,WifiOff:wifi-off,WifiPen:wifi-pen,WifiSync:wifi-sync,WifiZero:wifi-zero,Wind:wind,WindArrowDown:wind-arrow-down,Wine:wine,WineOff:wine-off,Workflow:workflow,Worm:worm,Wrench:wrench,WrenchOff:wrench-off,X:x,XLineTop:x-line-top,Zap:zap,ZapOff:zap-off,ZodiacAquarius:zodiac-aquarius,ZodiacAries:zodiac-aries,ZodiacCancer:zodiac-cancer,ZodiacCapricorn:zodiac-capricorn,ZodiacGemini:zodiac-gemini,ZodiacLeo:zodiac-leo,ZodiacLibra:zodiac-libra,ZodiacOphiuchus:zodiac-ophiuchus,ZodiacPisces:zodiac-pisces,ZodiacSagittarius:zodiac-sagittarius,ZodiacScorpio:zodiac-scorpio,ZodiacTaurus:zodiac-taurus,ZodiacVirgo:zodiac-virgo,ZoomIn:zoom-in,ZoomOut:zoom-out'; diff --git a/packages/components/src/renderers/action/__tests__/resolve-icon-lazy-9251.test.tsx b/packages/components/src/renderers/action/__tests__/resolve-icon-lazy-9251.test.tsx new file mode 100644 index 0000000000..4dbc6436e8 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/resolve-icon-lazy-9251.test.tsx @@ -0,0 +1,128 @@ +/** + * 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. + */ + +/** + * objectui#9251 — the `icons` record is off the eager path, and what that cost. + * + * The ruling (decision batch #132 item 4, maintainer 「同意」, 2026-09-13) moved + * the seam's glyph loading onto lucide's dynamic-import map and its membership + * question onto a build-generated static list. Two things therefore have to be + * pinned, and they pull in opposite directions: + * + * 1. The BYTES actually leave — nothing on the seam's import graph reaches + * lucide's runtime `icons` record any more. That half is mechanical, in + * `scripts/check-lucide-icon-record-names.mjs` part 4, and re-asserted from + * the test side in `lucide-record-icon-names-generated-9251.test.ts`. + * 2. What renders does NOT change, apart from when the path data arrives. + * That is this file. + * + * ⚠️ The second is the one a reader will doubt, because "lazy" usually means + * "renders nothing for a frame". It does not here: which icon a name resolves + * to is known synchronously from the generated list, so the `` — its + * classes, its box, its attributes — is emitted on the first frame and only the + * `` children arrive late. The rows below assert both halves of that + * against lucide's OWN component as the oracle, never against a copy of what + * this repo happens to emit. + */ + +import { describe, it, expect } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { icons } from 'lucide-react'; + +import { resolveIcon } from '../resolve-icon'; + +/** Render a component to a detached container and hand back its root ``. */ +function renderIcon(Component: React.ElementType, props: Record = {}) { + const { container } = render(React.createElement(Component, props)); + return container.querySelector('svg'); +} + +/** Class list as a sorted set, so ORDER is not silently pinned alongside it. */ +const classesOf = (svg: Element | null): string[] => + (svg?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).sort(); + +describe('objectui#9251 — the seam draws lazily without moving what renders', () => { + it('DISCRIMINATES — a live name gives a component and a dead one gives null, both synchronously', () => { + // The precondition for every row below, and the half of the contract that + // deliberately did NOT become async: four call sites choose their own + // fallback off `null` vs not, while they render. + expect(resolveIcon('house')).not.toBeNull(); + expect(resolveIcon('not-a-real-icon')).toBeNull(); + }); + + it('emits the `` on the FIRST frame, with the caller\'s className on it', () => { + const Icon = resolveIcon('house')!; + const svg = renderIcon(Icon, { className: 'h-4 w-4' }); + expect(svg).not.toBeNull(); + expect(classesOf(svg)).toEqual(['h-4', 'lucide', 'lucide-house', 'w-4'].sort()); + // ⭐ The first frame carries no path data — that is the lazy half, stated as + // a fact rather than left to be inferred from the row below passing. + expect(svg!.querySelectorAll('path, circle, rect, line, polyline, polygon')).toHaveLength(0); + }); + + it('fills the path data in once the icon module arrives', async () => { + const Icon = resolveIcon('house')!; + const { container } = render(React.createElement(Icon, { className: 'h-4 w-4' })); + await waitFor(() => { + expect(container.querySelectorAll('svg.lucide-house path').length).toBeGreaterThan(0); + }); + // The eventual DOM is lucide's own, path for path. + const reference = render(React.createElement(icons.House, { className: 'h-4 w-4' })); + const expected = [...reference.container.querySelectorAll('svg path')].map((p) => p.getAttribute('d')); + const actual = [...container.querySelectorAll('svg path')].map((p) => p.getAttribute('d')); + expect(expected.length).toBeGreaterThan(0); + expect(actual).toEqual(expected); + }); + + it('carries lucide\'s own attributes, not a re-invented set', async () => { + const seam = renderIcon(resolveIcon('house')!, {}); + const reference = renderIcon(icons.House, {}); + for (const attribute of ['viewBox', 'width', 'height', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'aria-hidden']) { + expect(seam!.getAttribute(attribute), attribute).toBe(reference!.getAttribute(attribute)); + } + // `size` still reaches lucide's own calculation rather than a hard-coded 24. + expect(renderIcon(resolveIcon('house')!, { size: 32 })!.getAttribute('width')).toBe('32'); + }); + + it('reproduces lucide\'s per-icon class names over the WHOLE record vocabulary', () => { + // ⚠️ The class names are the part a conversion rule gets wrong silently: 95 + // of the record's keys pack digits that lucide splits in the module name and + // not in the class derived from the key, so `Trash2` renders BOTH + // `lucide-trash2` and `lucide-trash-2`. `createLucideIcon` is what builds + // them and the seam no longer calls it, so every name is compared against + // the record's own component here — not a sample, and not a copy of the + // rule. + const keys = Object.keys(icons); + expect(keys.length).toBeGreaterThan(1000); + const mismatches: string[] = []; + let digitKeysChecked = 0; + for (const key of keys) { + if (/\d/.test(key)) digitKeysChecked += 1; + const reference = renderIcon((icons as Record)[key], {}); + const seam = renderIcon(resolveIcon(key)!, {}); + const want = classesOf(reference).join(' '); + const got = classesOf(seam).join(' '); + if (want !== got) mismatches.push(`${key}: expected "${want}", got "${got}"`); + } + expect(mismatches.slice(0, 10)).toEqual([]); + // Non-vacuity, both ways: the loop ran, and it ran over the class of name + // this row exists for. + expect(digitKeysChecked).toBeGreaterThan(50); + }); + + it('returns ONE stable component per name', () => { + // Several call sites disable `react-hooks/static-components` on the promise + // that this seam hands back a stable component. A fresh identity per call + // would remount the glyph on every parent render, so it would re-enter the + // empty state and never settle. + expect(resolveIcon('house')).toBe(resolveIcon('house')); + expect(resolveIcon('Home')).toBe(resolveIcon('house')); + expect(resolveIcon('house')).not.toBe(resolveIcon('file-text')); + }); +}); diff --git a/packages/components/src/renderers/action/__tests__/resolve-icon-seam.test.ts b/packages/components/src/renderers/action/__tests__/resolve-icon-seam.test.ts index dc3b9961fc..5c52633d2a 100644 --- a/packages/components/src/renderers/action/__tests__/resolve-icon-seam.test.ts +++ b/packages/components/src/renderers/action/__tests__/resolve-icon-seam.test.ts @@ -33,13 +33,57 @@ * record keys. `split('-')` regresses 4,748 pairs in that last reading, which * is why it is not adoptable and why the rows below assert the WIDER rule * rather than the more common one. + * + * ## ⭐ Why these rows compare glyph IDENTITY and not object identity + * + * objectui#9251 took the seam off lucide's runtime `icons` record — indexing it + * dragged all 1,781 icon modules into the console's eager closure — so the + * value `resolveIcon` returns is no longer the record's own component object, + * and `toBe(icons.ArrowRight)` stopped expressing "resolved to ArrowRight". + * + * ⛔ The record is NOT dropped as the oracle here; only the comparison changed. + * Every row below still asks the record what the right answer is, and compares + * by `displayName`, which lucide's own `createLucideIcon` sets to the PascalCase + * record key and which the seam sets to the same value. That substitution is + * only sound if no two record glyphs share a `displayName`, so that is measured + * in the first row rather than assumed — and the whole file still runs against + * the installed `icons`, so a lucide retirement moves these rows exactly as it + * did before. */ import { describe, it, expect } from 'vitest'; import { icons } from 'lucide-react'; import { resolveIcon, describeIconLookup } from '../resolve-icon'; +/** + * Which record glyph a component IS, by the name lucide gives it. + * + * `null` stays `null` so an unresolvable name is still distinguishable from a + * resolved one; a component without a `displayName` answers a sentinel rather + * than `null`, so a seam that started returning anonymous components could not + * pass for one that resolved nothing. + */ +const glyphOf = (component: unknown): string | null => { + if (component === null || component === undefined) return null; + return (component as { displayName?: string }).displayName ?? ''; +}; + describe('the icon-name seam resolves (objectui#5935)', () => { + /** + * ⭐ The precondition for every `glyphOf` comparison below: within the record, + * a `displayName` names exactly one glyph, so comparing by it is as strong as + * the `toBe(icons.X)` identity check it replaced (objectui#9251). + */ + it('IDENTIFIES uniquely — the record\'s 1,781 glyphs have 1,781 distinct display names', () => { + const keys = Object.keys(icons); + expect(keys.length).toBeGreaterThan(1000); + const names = new Set(keys.map((key) => glyphOf((icons as Record)[key]))); + expect(names.size).toBe(keys.length); + // Non-vacuity: the set is built from real names, not from the sentinel. + expect(names.has('')).toBe(false); + expect(names.has('House')).toBe(true); + }); + /** * ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned * some component for EVERY input would pass them all; a `resolveIcon` that @@ -52,17 +96,17 @@ describe('the icon-name seam resolves (objectui#5935)', () => { }); it('accepts all four authored spellings of one glyph', () => { - const canonical = icons.ArrowRight; - expect(canonical).toBeDefined(); + const canonical = glyphOf(icons.ArrowRight); + expect(canonical).toBe('ArrowRight'); // kebab — what the docs and most fixtures author. - expect(resolveIcon('arrow-right')).toBe(canonical); + expect(glyphOf(resolveIcon('arrow-right'))).toBe(canonical); // snake — resolved on TWO of the seven surfaces before this card and on // five of them not at all. This row is the consolidation. - expect(resolveIcon('arrow_right')).toBe(canonical); + expect(glyphOf(resolveIcon('arrow_right'))).toBe(canonical); // space-separated — same story. - expect(resolveIcon('arrow right')).toBe(canonical); + expect(glyphOf(resolveIcon('arrow right'))).toBe(canonical); // already-Pascal — authored in real fixtures, must not be mangled. - expect(resolveIcon('ArrowRight')).toBe(canonical); + expect(glyphOf(resolveIcon('ArrowRight'))).toBe(canonical); }); it('collapses repeated and mixed separators', () => { @@ -70,8 +114,8 @@ describe('the icon-name seam resolves (objectui#5935)', () => { // tokens, which capitalise to nothing and join to nothing — measured // identical over 51,449 hostile spellings, and pinned here so the two // spellings are not "fixed" apart later. - expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight); - expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight); + expect(glyphOf(resolveIcon('arrow--right'))).toBe(glyphOf(icons.ArrowRight)); + expect(glyphOf(resolveIcon('arrow-_ right'))).toBe(glyphOf(icons.ArrowRight)); }); it('applies the `Home` -> `House` rename, which is the ONLY rename', () => { @@ -79,8 +123,8 @@ describe('the icon-name seam resolves (objectui#5935)', () => { // exists so a name that used to resolve still does — it is not a general // alias table, and nothing else belongs in it. expect(icons).not.toHaveProperty('Home'); - expect(resolveIcon('home')).toBe(icons.House); - expect(resolveIcon('Home')).toBe(icons.House); + expect(glyphOf(resolveIcon('home'))).toBe(glyphOf(icons.House)); + expect(glyphOf(resolveIcon('Home'))).toBe(glyphOf(icons.House)); expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' }); // The control: an UNMAPPED name passes through both halves unchanged, so // the row above is about the map and not about `describeIconLookup` always @@ -101,7 +145,7 @@ describe('the icon-name seam resolves (objectui#5935)', () => { // is gone from the runtime record. Rules out a resolver that reached for // the named exports instead — a third, more forgiving vocabulary. expect(resolveIcon('edit')).toBeNull(); - expect(resolveIcon('square-pen')).toBe(icons.SquarePen); + expect(glyphOf(resolveIcon('square-pen'))).toBe(glyphOf(icons.SquarePen)); }); it('takes the seam FUNCTION, not a re-derived string, as the answer', () => { @@ -112,9 +156,9 @@ describe('the icon-name seam resolves (objectui#5935)', () => { for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) { const { key } = describeIconLookup(authored); const expected = Object.prototype.hasOwnProperty.call(icons, key) - ? (icons as Record)[key] + ? glyphOf((icons as Record)[key]) : null; - expect(resolveIcon(authored)).toBe(expected); + expect(glyphOf(resolveIcon(authored))).toBe(expected); } }); @@ -127,7 +171,7 @@ describe('the icon-name seam resolves (objectui#5935)', () => { const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(''); const mapped = pascal === 'Home' ? 'House' : pascal; return Object.prototype.hasOwnProperty.call(icons, mapped) - ? (icons as Record)[mapped] + ? glyphOf((icons as Record)[mapped]) : null; }; let carried = 0; @@ -138,14 +182,14 @@ describe('the icon-name seam resolves (objectui#5935)', () => { const before = narrow(kebab); if (before === null) continue; carried += 1; - expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before); + expect(glyphOf(resolveIcon(kebab)), `${kebab} stopped resolving`).toBe(before); } // Non-vacuity: a loop that skipped everything would pass silently. expect(carried).toBeGreaterThan(1000); // And the widening the enumeration measured, in both of its named cases. expect(narrow('building_2')).toBeNull(); - expect(resolveIcon('building_2')).toBe(icons.Building2); + expect(glyphOf(resolveIcon('building_2'))).toBe(glyphOf(icons.Building2)); expect(narrow('layout_dashboard')).toBeNull(); - expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard); + expect(glyphOf(resolveIcon('layout_dashboard'))).toBe(glyphOf(icons.LayoutDashboard)); }); }); diff --git a/packages/components/src/renderers/action/resolve-icon.ts b/packages/components/src/renderers/action/resolve-icon.ts index 1fecd0e57d..7dc71026ce 100644 --- a/packages/components/src/renderers/action/resolve-icon.ts +++ b/packages/components/src/renderers/action/resolve-icon.ts @@ -6,11 +6,17 @@ * LICENSE file in the root directory of this source tree. */ -import { icons, type LucideIcon } from 'lucide-react'; +import React from 'react'; +import { Icon } from 'lucide-react'; +import { dynamicIconImports } from 'lucide-react/dynamic.mjs'; + +import type { LucideIcon, LucideProps } from 'lucide-react'; + +import { LUCIDE_RECORD_ICON_NAME_TABLE } from '../../lib/lucide-record-icon-names'; /** * THE icon-name seam (objectui#5935). One tokeniser, one rename map, one - * lookup into lucide's runtime `icons` record — for the whole repo. + * membership set — for the whole repo. * * ## What this module does, and the line it does not cross * @@ -36,15 +42,62 @@ import { icons, type LucideIcon } from 'lucide-react'; * is local and there is nothing for it to disagree with, unlike the three * tokenisers this seam replaced. * + * ⭐ That contract is why the resolution below stayed SYNCHRONOUS when the + * GLYPH became lazy (objectui#9251). All four behaviours above are chosen from + * `null` vs not-`null` while the caller renders; an answer that had to be + * awaited would have published a loading state into every one of them. The + * membership question is answered from a static list, in the same tick, exactly + * as before — only the SVG path data now arrives later. + * + * ## ⛔ The `icons` record is not read here, and must not come back + * + * This module used to answer `name -> component` by indexing lucide's runtime + * `icons` record. A namespace object has no dead members, so that one index + * pulled every icon module into the console's eager closure: measured on the + * console build at `ac05d4f4dd`, 1,781 icon module definitions inside + * `assets/ui-components-*.js`, in a chunk of 1,535,917 raw / 397,091 gzipped + * bytes. The maintainer ruling of 2026-09-13 (objectui#9251, decision batch + * #132 item 4, 「同意」) took it off that path, verbatim: + * + * 「`icons` 总表不再 eager:图标按名经动态导入表解析(Door 1 已证明该表几乎 + * 免费);合法图标名集合由构建期生成的静态名单提供(⛔ 不从 + * `Object.keys(icons)` 推导 ⇒ #9204 救援 A 拒绝);总表的每个读点 + * (`check-lucide-icon-record-names.mjs` 普查着)迁到懒解析;验收 = 页面 + * 实际字节减少,不是那一行变绿」 + * + * So, precisely: + * + * - MEMBERSHIP comes from `lib/lucide-record-icon-names.ts`, generated from + * lucide's own export manifest by + * `scripts/regenerate-lucide-record-icon-names.mjs`. ⛔ Never from + * `Object.keys(icons)` — that derivation IS the eager record, written a + * second way, and objectui#9204's rescue option A is refused by name in the + * ruling above for exactly that reason. + * - The GLYPH comes through `dynamicIconImports`, lucide's dynamic-import + * map, which objectui#9204 measured to be nearly free in this context + * (deferring it cost +923 gzipped bytes). ⛔ Keeping that map is part of the + * ruling, not an accident. The map is used directly rather than through + * `DynamicIcon` for one reason: `DynamicIcon` renders `null` while it + * loads, and `createElement(Fallback)` passes its fallback no props, so + * neither of its two states can carry the caller's `className` or lucide's + * per-icon classes. See {@link EMPTY_ICON_NODE}. + * - The VOCABULARY is unchanged: it is still the record's 1,781 keys and ⛔ + * not `lucide-react/dynamic.mjs`'s `iconNames`, which is a strict superset + * that still carries `edit`, `smile`, `filter` and `alert-triangle` — + * spellings lucide RETIRED from the record and that this repo's authored + * names must keep failing on. `scripts/check-lucide-icon-record-names.mjs` + * is what holds the two apart, and it censuses this module as the one + * record-vocabulary site. + * * ## ⛔ A new icon-rendering container does NOT bring its own resolver * * Ruling point 4 of 2026-08-31 (comment 5472612351, verbatim 「同意」): * 「本裁定后新容器 ⛔ 不得再自带解析器,一律走 seam」. This is mechanically * enforced, not merely asked for — `scripts/check-lucide-icon-record-names.mjs` - * rediscovers every module that named-imports lucide's `icons` record and - * indexes it, and fails when the discovered set differs from its declared - * census in EITHER direction. That census is now this file alone. A container - * that hand-rolls a lookup turns the gate red on the commit that adds it. + * rediscovers every module that reads the record vocabulary and fails when the + * discovered set differs from its declared census in EITHER direction. That + * census is this file alone. A container that hand-rolls a lookup turns the + * gate red on the commit that adds it. * * ## The tokeniser is MEASURED, not chosen * @@ -104,6 +157,138 @@ function toPascalCase(name: string): string { .join(''); } +/** + * The generated table, decoded on first use. + * + * Deliberately lazy: a page that draws no icon never pays for it, and the + * eager cost of the module is one string literal rather than 1,781 object + * properties. ⛔ Do not hoist this to module scope for tidiness — that trades a + * measured saving for a formatting preference. + */ +let recordIconNames: Map | null = null; + +function recordIconName(key: string): string | undefined { + if (recordIconNames === null) { + const decoded = new Map(); + for (const entry of LUCIDE_RECORD_ICON_NAME_TABLE.split(',')) { + const separator = entry.indexOf(':'); + decoded.set(entry.slice(0, separator), entry.slice(separator + 1)); + } + recordIconNames = decoded; + } + return recordIconNames.get(key); +} + +/** + * The two `lucide-*` class names lucide's own `createLucideIcon` puts on every + * glyph, rebuilt here because they are added by `createLucideIcon`, which builds the + * per-icon components this seam no longer loads. `Icon` — the component every + * lucide glyph ultimately renders, and the one used below — contributes only + * the bare `lucide` class. + * + * ⚠️ Without this, `svg.lucide-house` would stop matching anywhere in the + * product and in 194 lines of this repo's own assertions, and it would stop + * matching SILENTLY: the glyph still draws, so nothing looks broken until a + * stylesheet or a query that selects by icon identity quietly matches nothing. + * + * Both spellings are derived the way lucide derives them — + * `lucide-${toKebabCase(toPascalCase(kebab))}` and `lucide-${kebab}` — which is + * one class for most icons and two for the 95 whose PascalCase key packs digits + * (`Trash2` gives `lucide-trash2 lucide-trash-2`). The PascalCase key is + * already in hand here, so only the kebab-casing of it is re-implemented; + * `resolve-icon-classnames.test.ts` pins the result against the record's own + * components over the WHOLE vocabulary, so a lucide change to either derivation + * fails rather than drifts. + */ +function lucideClassNames(recordKey: string, kebab: string, className?: string): string { + const fromKey = `lucide-${recordKey.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}`; + return [fromKey, `lucide-${kebab}`, className] + .filter((c, index, all) => Boolean(c) && String(c).trim() !== '' && all.indexOf(c) === index) + .join(' ') + .trim(); +} + +/** lucide's own path-data shape, taken from the component that consumes it. */ +type IconNode = React.ComponentProps['iconNode']; + +/** + * What the glyph holds before its module arrives: the same `` lucide + * draws, with the same classes and the same box, and no paths in it yet. + * + * ⭐ This is what keeps the lazy half from being a visible regression. The + * ALTERNATIVE — `DynamicIcon`'s own behaviour — is to render `null` until the + * module resolves, which means the icon's slot has no box, so every toolbar and + * menu row reflows when the chunks land, and `svg.lucide-house` matches nothing + * for the first frame. Neither is necessary: which icon this is, is known + * synchronously from the generated name list. Only its path data is late. + */ +const EMPTY_ICON_NODE = [] as unknown as IconNode; + +/** Path data already fetched, so a second mount of the same glyph is instant. */ +const loadedIconNodes = new Map(); + +/** + * One component per resolved name, created once and reused. + * + * ⭐ Load-bearing, not an optimisation. Every call site in this repo renders the + * return value as ``, and several carry an + * `eslint-disable react-hooks/static-components` line asserting that this seam + * hands back "a stable icon component from a static registry". A fresh + * component identity per render would remount the glyph on every parent render + * — which for a lazily loaded icon means re-entering the loading state and + * never settling. + */ +const resolvedComponents = new Map(); + +function lazyIconComponent(recordKey: string, kebab: string): LucideIcon { + const cached = resolvedComponents.get(recordKey); + if (cached) return cached; + + const Component = React.forwardRef(({ className, ...rest }, ref) => { + const [iconNode, setIconNode] = React.useState(() => loadedIconNodes.get(kebab)); + + React.useEffect(() => { + if (iconNode !== undefined) return undefined; + let live = true; + const load = dynamicIconImports[kebab as keyof typeof dynamicIconImports]; + // `resolveIcon` only reaches here for a name the generated list carries, + // and part 4 of `check-lucide-icon-record-names.mjs` fails the build if + // any of those names is missing from this map. The guard is for a + // consumer that has pinned a mismatched lucide, where an empty box is a + // better answer than a thrown render. + if (!load) return undefined; + void load() + .then((module: { __iconNode?: IconNode }) => { + const node = module.__iconNode; + if (!node) return; + loadedIconNodes.set(kebab, node); + if (live) setIconNode(node); + }) + .catch(() => { + // The empty `` stays. A network failure on one icon chunk must + // not take the surface that hosts it down with it. + }); + return () => { + live = false; + }; + }, [iconNode]); + + return React.createElement(Icon, { + ...rest, + ref, + iconNode: iconNode ?? EMPTY_ICON_NODE, + className: lucideClassNames(recordKey, kebab, className), + }); + }); + // The same value lucide's `createLucideIcon` sets: `toPascalCase(kebab)` is + // the record key, so React DevTools and test output read as they did before. + Component.displayName = recordKey; + + const icon = Component as unknown as LucideIcon; + resolvedComponents.set(recordKey, icon); + return icon; +} + /** * The lookup this seam performs, exposed for DIAGNOSTICS only. * @@ -125,8 +310,19 @@ export function describeIconLookup(name: string): { pascal: string; key: string * Accepts kebab-case, snake_case, space-separated and PascalCase spellings. * Returns `null` when the name is absent or names no live glyph — deciding what * to draw instead belongs to the caller (see the module docblock). + * + * ⚠️ The component this returns draws its `` — with lucide's own classes, + * box and attributes — SYNCHRONOUSLY, and fills in the path data when the + * icon's module arrives (objectui#9251). So `svg.lucide-house` matches on the + * first frame and nothing reflows, while the 1,781 icon modules stay off the + * eager path. ⛔ A test that asserts on the PATHS inside the svg must await + * them (`findBy*` / `waitFor`); everything that asks which icon this is — + * including `null` vs not — is answered in the same tick, as before. */ export function resolveIcon(name: string | undefined): LucideIcon | null { if (!name) return null; - return (icons as Record)[describeIconLookup(name).key] ?? null; + const recordKey = describeIconLookup(name).key; + const kebab = recordIconName(recordKey); + if (kebab === undefined) return null; + return lazyIconComponent(recordKey, kebab); } diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index 6d2639f900..e0647ba170 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -668,10 +668,11 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { // literal is `BASELINE.gzipBytes` rendered, re-taken each time the baseline // moves (objectui#6683 down to 3177.7, objectui#6776 down to 3146.8, // objectui#7122 UP to 3468.0 on the authorised raise, objectui#7479 down to - // 3090.6 when nine locale catalogues left the eager closure) — a + // 3090.6 when nine locale catalogues left the eager closure, objectui#9251 + // down to 3060.0 when lucide's 1,781-icon record left it) — a // rendering derived in the test would agree with the renderer by // construction and pin nothing. - expect(result.message).toContain('3090.6'); + expect(result.message).toContain('3060.0'); }); it('is exactly one regression wide, from either side of the line', () => { @@ -853,13 +854,22 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { /** * A declared row's hinge is its pinned figure LESS one grain, and the pair - * is taken at that exact boundary. 4,289 is the live `ui-components` - * headroom the allowance was read from, so this is the ratchet at its own - * hinge rather than a rounded neighbourhood of it. + * is taken at that exact boundary. + * + * ⭐ The allowance below is SYNTHETIC and that is deliberate (objectui#9251). + * It used to be read live out of {@link EXHAUSTED_HEADROOM_ALLOWANCES}, and + * when `ui-components` paid its debt off the table went empty — which would + * have left this whole block with no subject, silently retiring the ratchet + * on the run that proved it worked. A mechanism must stay pinned when + * nothing currently uses it, or the day someone needs it again is the day + * they find out it was never checked. 4,289 is kept as the figure because it + * is the one the ratchet was designed and measured against; the LIVE table + * is pinned separately, under "the allowance table is a ratchet, pinned". */ describe('a declared row', () => { const CEILING = PER_CHUNK_GZIP_CEILINGS['ui-components']; - const ALLOWANCE = EXHAUSTED_HEADROOM_ALLOWANCES['ui-components']; + const ALLOWANCE = 4_289; + const DECLARED = { 'ui-components': ALLOWANCE }; const GRAIN = REGRESSION_THIS_GATE_MUST_CATCH_BYTES * EXHAUSTED_HEADROOM_ALLOWANCE_GRANULARITY_MULTIPLE; @@ -867,6 +877,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 +946,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'); @@ -947,11 +958,18 @@ 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. + // + // ⭐ Driven by a SYNTHETIC table, and the live one is folded in beside it. + // Reading only the live table made this case vacuous the moment the last + // debt was paid off (objectui#9251) — a green tick over an empty `for`. + const declared = { ...EXHAUSTED_HEADROOM_ALLOWANCES, '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)) { + expect(Object.keys(declared).length).toBeGreaterThan(0); + for (const [name, allowance] of Object.entries(declared)) { expect(result.message).toContain(`chunk \`${name}\``); expect(result.message).toContain(`declared ${allowance}-byte allowance`); } @@ -964,23 +982,32 @@ 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('holds exactly the rows still in debt — today, none', () => { + // ⚠️ Two rows have left this table and NEITHER was lowered, which is the + // distinction the ratchet is made of: + // + // `i18n-locales: 8_804` — objectui#7479. Its CHUNK ceased to exist. + // `ui-components: 4_289` — objectui#9251. Its ROW cleared the floor: + // lucide's 1,781-icon record came off the eager path, the ceiling + // was re-pinned DOWN to 289,000 over a 265,937 measurement, and the + // headroom went 0.02x -> 0.25x. + // + // ⛔ An empty table is NOT this mechanism being retired. Every case in + // "a declared row" above now drives a SYNTHETIC entry for exactly that + // reason, so the ratchet stays measured with nothing currently owing. + 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. + // ⚠️ The live table is empty today, so the rule is also asserted the way + // it FAILS — otherwise this case is a green tick over an empty loop. for (const allowance of Object.values(EXHAUSTED_HEADROOM_ALLOWANCES)) { expect(allowance).toBeLessThan(FLOOR); } + expect(4_289).toBeLessThan(FLOOR); + expect(FLOOR).toBeLessThan(FLOOR + 1); }); it('is compared at the coarser of the two grids this gate renders on', () => { @@ -1004,10 +1031,13 @@ 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. Asserted on the live table AND on + // the synthetic figure the ratchet was measured against, so an empty + // live table cannot make this read as checked. 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', () => { @@ -1017,6 +1047,10 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { for (const key of Object.keys(EXHAUSTED_HEADROOM_ALLOWANCES)) { expect(judged).toContain(key); } + // Non-vacuity for an empty live table: the key the last entry named is + // still a budgeted chunk, and an invented one is still not. + expect(judged).toContain('ui-components'); + expect(judged).not.toContain('a-chunk-nothing-budgets'); }); }); }); @@ -1112,7 +1146,7 @@ describe('main', () => { // about the FIXTURE while the gate under test behaved correctly. The number // this case is actually about is "the report's chunk count, echoed". expect(outputs.closure_chunks).toBe(String(fixture.files.length)); - expect(outputs.closure_gzip_kb).toBe('3090.6'); + expect(outputs.closure_gzip_kb).toBe('3060.0'); }); it('exits 1 — a verdict about the BUNDLE — when over budget', () => { diff --git a/scripts/__tests__/lucide-record-icon-names-generated-9251.test.ts b/scripts/__tests__/lucide-record-icon-names-generated-9251.test.ts new file mode 100644 index 0000000000..d55d8007a1 --- /dev/null +++ b/scripts/__tests__/lucide-record-icon-names-generated-9251.test.ts @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/** + * objectui#9251 — the legal icon-name set is BUILD-GENERATED, and ⛔ not + * derived from `Object.keys(icons)`. + * + * The maintainer ruling of 2026-09-13 (decision batch #132 item 4, 「同意」) is + * specific about the source, and about why: + * + * 「合法图标名集合由构建期生成的静态名单提供(⛔ 不从 `Object.keys(icons)` + * 推导 ⇒ #9204 救援 A 拒绝)」 + * + * Indexing the record is what put all 1,781 icon modules into the console's + * eager closure. A membership set read from `Object.keys(icons)` is that same + * import, so it would pin the record eager while LOOKING like a name list — the + * shape objectui#9204's rescue option A proposed and this ruling refused by + * name. The rows below hold that shut from the test side; part 4 of + * `scripts/check-lucide-icon-record-names.mjs` holds it shut in CI. + * + * ⚠️ Every "does not import the record" row runs its probe against a POSITIVE + * CONTROL first — a file in this tree that does import it. Without that, a + * probe that had stopped matching anything would report the same silence as a + * clean tree, and the silence is what the rows are made of. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + deriveRecordIconNamePairs, + renderModule, + TARGET, +} from '../regenerate-lucide-record-icon-names.mjs'; +import { + GENERATED_NAME_LIST_TARGET, + RECORD_FREE_MODULES, + RECORD_IMPORT_POSITIVE_CONTROL, + icons, + importsRecordEntry, + judgeGeneratedNameList, +} from '../check-lucide-icon-record-names.mjs'; + +/** + * The repo root, walked up from THIS FILE rather than taken from + * `process.cwd()` — the package-level and repo-root vitest invocations have + * different working directories and a cwd-rooted assertion reads a different + * tree in each (AGENTS.md, objectui#7791 / objectui#7799). + */ +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +describe('the generated legal icon-name list (objectui#9251)', () => { + it('is REGENERABLE — the committed file is exactly what the generator renders', () => { + const committed = readFileSync(join(repoRoot, TARGET), 'utf8'); + expect(TARGET).toBe(GENERATED_NAME_LIST_TARGET); + expect(committed).toBe(renderModule(deriveRecordIconNamePairs(repoRoot))); + }); + + it('is the RECORD vocabulary, key for key — a superset would bless retired spellings', () => { + // lucide's dynamic surface carries 258 names the record dropped (`edit`, + // `smile`, `filter`, `alert-triangle`). Taking membership from the wrong + // one is the defect `check-lucide-icon-record-names.mjs` exists for, and it + // would be invisible: those names render as components and resolve to + // nothing as strings. + const derived = new Set(deriveRecordIconNamePairs(repoRoot).map(([pascal]) => pascal)); + const recordKeys = Object.keys(icons); + expect(recordKeys.length).toBeGreaterThan(1000); + expect([...derived].sort()).toEqual([...recordKeys].sort()); + }); + + it('does NOT come from `Object.keys(icons)` — nothing on the path imports the record', () => { + // The probe must be shown to fire. This control is a TEST file, which is + // allowed to import the record because tests are not bundled into a page. + expect(importsRecordEntry(repoRoot, RECORD_IMPORT_POSITIVE_CONTROL)).toBe(true); + expect(RECORD_FREE_MODULES.length).toBeGreaterThan(0); + for (const file of RECORD_FREE_MODULES) { + expect(importsRecordEntry(repoRoot, file), file).toBe(false); + } + // The generator and the seam are both on that list, named here so a future + // edit that drops one from `RECORD_FREE_MODULES` fails rather than shrinks + // the population in silence. + expect(RECORD_FREE_MODULES).toContain('scripts/regenerate-lucide-record-icon-names.mjs'); + expect(RECORD_FREE_MODULES).toContain('packages/components/src/renderers/action/resolve-icon.ts'); + expect(RECORD_FREE_MODULES).toContain(GENERATED_NAME_LIST_TARGET); + }); + + it('carries the kebab spelling lucide ships, which a conversion rule cannot produce', () => { + const pairs = new Map(deriveRecordIconNamePairs(repoRoot)); + // The plain cases, so the rows below are not only about the exceptions. + expect(pairs.get('House')).toBe('house'); + expect(pairs.get('AirVent')).toBe('air-vent'); + // The 95 that a PascalCase-to-kebab regex gets wrong. Each of these would + // become `trash2` / `arrow-down01` / `axis3d`, none of which lucide can + // load — and the failure is an icon that draws nothing, with no error. + expect(pairs.get('Trash2')).toBe('trash-2'); + expect(pairs.get('ArrowDown01')).toBe('arrow-down-0-1'); + expect(pairs.get('Axis3d')).toBe('axis-3d'); + const naive = (key: string) => key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); + const diverging = [...pairs].filter(([key, kebab]) => naive(key) !== kebab); + expect(diverging.length).toBeGreaterThan(50); + }); + + it('is judged by the census gate itself, which reports no findings on this tree', () => { + expect(judgeGeneratedNameList(repoRoot)).toEqual([]); + }); +}); diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 907ab61c7f..6c52f489eb 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -474,8 +474,26 @@ import { isEntrypoint } from './invoked-as.mjs'; * ⛔ No other exemption was added, no import was made lazy, and no other * ceiling was moved — the three per-chunk lines that still pass were left * exactly as they are. + * + * ## Why this number came DOWN (objectui#9251) + * + * It was 3,210,000 over a 3,164,817 baseline. objectui#9251 took lucide's + * runtime `icons` record off the eager path — 1,781 icon module definitions + * that one namespace index was dragging into `ui-components` — and the closure + * went 3,180,591 -> 3,133,419 gzipped, 10,975,695 -> 10,606,541 raw, on two + * console builds of the same tree in one container. That is 47,172 gzipped + * bytes out, past the ~45 KB at which the header above makes re-pinning an + * obligation rather than an option: left at 3,210,000 this ceiling would have + * carried 0.84x of a regression in headroom, against the 0.50x it is designed + * for. + * + * ⛔ A TIGHTENING, and it must not be read as one of the raises above. No build + * that passed before this edit and measures under 3,179,000 fails after it. + * Headroom 45,581 bytes = 0.50x {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, + * which is exactly the value the header argues for — the first time this + * constant has sat on it rather than above it. */ -export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_210_000; +export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_179_000; /** * The measurement the ceiling above was derived from. Exported so the two @@ -488,55 +506,64 @@ export const BASELINE = Object.freeze({ /** * `emitEagerClosureReport`'s `eagerGzipBytes` on this commit. * - * ⚠️ `755d34a5f` is NOT "this branch's last commit before the one that edits - * this file" — the argument every earlier entry here made. objectui#7479's - * change IS a console build input (`packages/i18n/src/**` and - * `apps/console/vite.config.ts`), so the commit named below is the one that + * ⚠️ `bbf6b02d9` is NOT "this branch's last commit before the one that edits + * this file" — the argument every earlier entry here made. objectui#9251's + * change IS a console build input (`packages/components/src/**` and + * `apps/console/vite.config.ts`), so the commit named here is the one that * CARRIES it and the reading is of that tree. The - * `scripts/vite-*.ts`-versus-`scripts/check-*.mjs` half of the argument - * the previous baseline made (`34a1578ef`, and `3d257c85a` / `bd2a7ec50` - * before it) still holds, and is why the two can share one branch: nothing in - * this file or its unit test reaches the bundler, so the ceiling edit cannot - * have moved the figure it pins. + * `scripts/vite-*.ts`-versus-`scripts/check-*.mjs` half of the argument the + * previous baseline entries made (`755d34a5f`, `34a1578ef`, and `3d257c85a` / + * `bd2a7ec50` before them) still holds, and is why the two can share one + * branch: nothing in this file or its unit test reaches the bundler, so the + * ceiling edit cannot have moved the figure it pins. + * + * ⚠️ `755d34a5f`, the previous baseline, is the one to compare against when + * reading the deltas below; it is a branch tip and behaves as described under + * PROVENANCE. + * + * The reading it is SUBTRACTED from is a control build of this branch's own + * base, `origin/main` `ac05d4f4d`, in the same container with the same + * instrument — 3,180,591 bytes across 52 of 528 chunks, 10,975,695 raw. Both + * builds are recorded on objectui#9251's pull request, with the three unmoved + * chunks (`framework`, `vendor-objectstack`, `i18n-locale-en`, all three + * byte-identical across the pair) that make the delta readable as bytes + * LEAVING rather than bytes moving. * - * The reading it is SUBTRACTED from is the control build of `origin/main` - * `d8b4739d4` in the same container with the same instrument — 3,575,370 - * bytes across 50 of 518 chunks. Both builds are recorded under "Why - * `i18n-locales` became `i18n-locale-en`" in the header, with the three - * unmoved chunks that make the delta readable as bytes LEAVING rather than - * bytes moving. + * ⚠️ The CHUNK COUNTS moved by an order of magnitude and that is the change, + * not an artefact: 52 of 528 became 329 of 2309 because every lucide icon + * module is now its own chunk. 277 of the eager 329 are `vendor-icon-*` + * single-module chunks holding the icons first-party code imports by name — + * 107,117 raw / 76,330 gzipped between them, which is the price of the split + * and is named here so nobody reads the aggregate drop as free. * - * Measured by `pnpm --filter @object-ui/console build` (exit 0) reading - * `apps/console/dist/eager-closure.json`. ⛔ Not taken from CI's report and - * not extrapolated: CI weighs the pull-request MERGE ref and this is the - * branch tree, so the two differ by whatever has landed on `main` since. + * Measured by `pnpm --filter @object-ui/console exec vite build` (exit 0) + * reading `apps/console/dist/eager-closure.json`, both legs under + * `scripts/pm/os-verify-lock.sh`. ⛔ Not taken from CI's report and not + * extrapolated: CI weighs the pull-request MERGE ref and this is the branch + * tree, so the two differ by whatever has landed on `main` since. * * ⚠️ PROVENANCE — what a reader can and cannot check, because a reader who * tries the obvious thing gets nothing and currently learns nothing from it. * The commit named below is a BRANCH TIP and this repository squash-merges, * so it is not reachable from `main` and cannot be fetched by sha: - * `git fetch origin 755d34a5f1` answers "couldn't find remote ref", and + * `git fetch origin ` answers "couldn't find remote ref", and * `git merge-base --is-ancestor` cannot resolve the object at all (exit 128, - * ⛔ not the exit 1 that would mean "resolved, and not an ancestor"). The - * previous baseline `34a1578ef` does resolve and is genuinely not an ancestor - * — exit 1, read against a control leg `d9580f4647` of the same age that - * exits 0 in the same checkout, because an exit 1 from a shallow clone means - * nothing without one. ⛔ This is the convention working rather than a defect: - * naming the tree the reading was taken on is the point, and no commit on - * `main` has that tree. + * ⛔ not the exit 1 that would mean "resolved, and not an ancestor"). ⛔ This + * is the convention working rather than a defect: naming the tree the reading + * was taken on is the point, and no commit on `main` has that tree. The + * CONTROL leg above is the half that does resolve — `ac05d4f4d` is an + * ordinary `main` commit — so the pair is checkable from one end. * * ⇒ the CONSEQUENCE, which nobody had written down: the provenance of this * constant ⛔ cannot be checked from a `main` checkout with git alone. It is * checkable — the GitHub compare API resolves these shas when a clone cannot, * and every ancestry figure in "What a re-baseline ABSORBS" above came from - * it. The squash merge that carried this branch onto `main` is `77b2a18a16`, - * which is reachable; ⚠️ its tree is 22 commits PAST the one measured here, - * so it is a handle on what LANDED and ⛔ never a substitute for the reading. + * it. */ - gzipBytes: 3_164_817, - chunks: 51, - totalChunks: 528, - commit: '755d34a5f', + gzipBytes: 3_133_419, + chunks: 329, + totalChunks: 2309, + commit: 'bbf6b02d9', }); /** @@ -1045,7 +1072,43 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ // REGRESSION_THIS_GATE_MUST_CATCH_BYTES on `3f775eeb8` — the loosest of the // four. See "Why `framework` moved UP" above for what that costs. framework: 100_000, - 'ui-components': 399_000, + // ⭐ LOWERED by objectui#9251, which took lucide's runtime `icons` record off + // the eager path. Indexing that namespace object put 1,781 icon module + // definitions in this chunk; membership now comes from a build-generated + // static name list and the glyphs arrive through lucide's dynamic-import map, + // so the chunk went 397,091 -> 265,937 gzipped, on the two console builds + // recorded under "Why this number came DOWN" on + // {@link MAX_EAGER_CLOSURE_GZIP_BYTES}. + // + // ⛔ The RAW pair those same builds recorded is NOT restated here, and the + // omission is the repair rather than an oversight. It was restated, the head + // leg was wrong by 32,857 bytes, and the KB claim beside it matched neither + // that figure nor the right one — and nothing here could have caught either: + // no constant in this file reads raw bytes, no test weighs them, and a figure + // written into a comment is re-derived never. A ceiling-tier contract review + // re-measuring the head leg by hand is what found it — comment 5654270820 on + // objectui#9399, an ISSUE comment rather than a pull request review, which is + // where a reader looks it up. ⛔ The old figures are not quoted back, for the + // reason the objectui#7528 pin gives: a reader cannot tell a quotation from a + // claim. + // ⇒ Read raw off the instrument this gate already consumes: the `bytes` + // field beside `gzipBytes` for this key in + // `apps/console/dist/eager-closure.json`, on your own build. ⚠️ That answers + // the HEAD leg only — the control leg is a build of `ac05d4f4d`, which no + // checkout re-derives — which is why the drop above is stated in gzipped + // bytes, the unit this ceiling is weighed in. + // + // ⛔ A TIGHTENING. No build that passed before this edit and measures under + // 289,000 fails after it. Headroom 23,063 bytes = 0.25x + // REGRESSION_THIS_GATE_MUST_CATCH_BYTES — well above the 0.10x floor, and + // chosen larger than `i18n-locale-en`'s 0.11x because this row is the one + // that had been living at 0.02x: the runway is the point of paying it down, + // and a re-pin that left it at the floor would hand the next author the same + // ratchet the day after it was cleared. + // + // ⚠️ This is also why this key no longer appears in + // {@link EXHAUSTED_HEADROOM_ALLOWANCES} — see the note there. + 'ui-components': 289_000, }); /** @@ -1113,12 +1176,15 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * in this comment. * * ⚠️ These readings are on DIFFERENT commits from {@link BASELINE} above — - * except `i18n-locale-en`, which as of objectui#7479 shares BASELINE's commit - * exactly — and WHICH ONE IS LATER flips every time either side is - * re-baselined, so read the commit names, never a direction asserted here. As - * of objectui#7479 the AGGREGATE is the later reading: BASELINE's `755d34a5f` - * is dated 2026-09-11 against `2c8474c04` on 2026-08-25 for `ui-components` - * here, and `34a1578ef` (2026-09-06, objectui#7122) for `vendor-objectstack`. + * except `i18n-locale-en`, which as of objectui#7479 shares BASELINE's commit, + * and `ui-components`, which as of objectui#9251 shares it too — and WHICH ONE + * IS LATER flips every time either side is re-baselined, so read the commit + * names, never a direction asserted here. As of objectui#9251 the AGGREGATE is + * the later reading: BASELINE's `bbf6b02d9` is dated 2026-09-13 against + * `34a1578ef` (2026-09-06, objectui#7122) for `vendor-objectstack`, the one key + * left on an older tree. ⚠️ `i18n-locale-en`'s commit was `755d34a5f` when it + * was taken and the aggregate has moved on since, which is exactly why the two + * are named per key rather than described by a direction. * This paragraph asserted the reverse, * in the present tense, from objectui#5490 until objectui#6778 — true when it * was written, then left standing while three aggregate re-baselines moved @@ -1186,7 +1252,10 @@ 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, + // `bbf6b02d9`, the same console build as BASELINE above, so the two are + // directly comparable, and the same instrument and container as the control + // build it is subtracted from (objectui#9251). + 'ui-components': 265_937, }); /** @@ -1292,18 +1361,49 @@ export const EXHAUSTED_HEADROOM_FLOOR_MULTIPLE = 0.1; * ⇒ that reading is also why these figures are NOT compared at the byte. See * {@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. + * + * ⚠️ The `@type` is load-bearing now that the table can be EMPTY. Its shape used + * to be inferred from the one entry it carried, so `Object.values(...)` was + * `number[]` for free; an empty literal infers nothing and the same expression + * becomes `unknown[]`, which fails `tsc -p tsconfig.scripts.json` in the unit + * suite that reads it — a leg no per-package `type-check` and no + * `turbo run type-check` covers, because `scripts/` is not a workspace package. + * ⛔ The fix belongs HERE, on the declaration, and not as a cast at the reader: + * chunk name to allowance bytes is what this table IS, whether or not it + * currently holds a row. + * + * @type {Readonly>} */ 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 that is a state this table is allowed to be in: it is a ledger + // of debt, and debt can be discharged. Two rows have left it, by the two ways + // a row leaves — neither of them by being lowered, because a lowered figure is + // headroom supplied to a row that still owes it. + // + // `i18n-locales: 8_804` — left at objectui#7479 because its CHUNK ceased to + // exist: nine of the ten catalogues it weighed became `import()`ed on + // demand, and the one that stays is budgeted under its own key at 0.11x. + // + // `ui-components: 4_289` — left at objectui#9251 because the ROW cleared the + // floor. Its chunk is still here and still budgeted; what changed is that + // lucide's 1,781-icon record came off the eager path, the ceiling was + // re-pinned DOWN to 289,000 over a 265,937 measurement, and the headroom + // went from 0.02x to 0.25x — two and a half times the floor this table + // exists to excuse rows from. + // + // ⛔ Leaving the entry in place after that would have been the worse edit, not + // the cautious one, and in two ways at once. `floorFor` reads an allowance as + // this row's REQUIRED headroom, so a stale 4,289 would have replaced the + // 9,113.6-byte floor with a 3,377.6-byte one — the gate running WEAKER on the + // row it had just been strengthened for. And the row renderer prints + // "under the 0.10x floor and held open by its declared allowance" for every + // listed key unconditionally, so the passing verdict would have said the row + // was under a floor it is 2.5x clear of. + // + // ⚠️ An empty table must not be read as "this mechanism is unused". The + // ratchet is pinned on a synthetic row in + // `scripts/__tests__/check-eager-closure-budget.test.ts`, precisely so that + // paying the last debt off cannot quietly retire the instrument with it. }); /** diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index 3d38830368..212f44a23d 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -231,6 +231,10 @@ import { createRequire } from 'node:module'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { + deriveRecordIconNamePairs, + renderModule as renderGeneratedNameList, +} from './regenerate-lucide-record-icon-names.mjs'; /** This gate's OWN repo — where lucide and typescript are resolved from. */ const gateRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -585,7 +589,7 @@ export const ANCHORED_MAPS = [ export const LUCIDE_OWNER_PKG = 'packages/components/package.json'; const lucideRequire = createRequire(join(gateRoot, LUCIDE_OWNER_PKG)); export const lucide = await import(pathToFileURL(lucideRequire.resolve('lucide-react')).href); -export const { iconNames } = await import(pathToFileURL(lucideRequire.resolve('lucide-react/dynamic.mjs')).href); +export const { iconNames, dynamicIconImports } = await import(pathToFileURL(lucideRequire.resolve('lucide-react/dynamic.mjs')).href); export const icons = lucide.icons; export const lucideVersion = JSON.parse(readFileSync(lucideRequire.resolve('lucide-react/package.json'), 'utf8')).version; const ts = createRequire(join(gateRoot, 'package.json'))('typescript'); @@ -777,6 +781,176 @@ function objectProp(objectLiteral, name) { return null; } +// ── The generated legal-name list ──────────────────────────────────────────── +/** + * The module the seam now asks its membership question of, instead of indexing + * lucide's runtime `icons` record (objectui#9251). + * + * ⚠️ Reading the record was what put all 1,781 icon modules in the console's + * eager closure, so the whole point of the move is that NO shipped module + * imports the record any more. Discovery therefore cannot keep looking only for + * `import { icons }` — with that predicate alone it would find zero record + * sites the day the seam changed and, worse, would be telling the truth: the + * record IS unread. What has to stay censused is the site that judges names + * against the record's VOCABULARY, however it holds that vocabulary. + * + * The predicate is deliberately narrow, and the same shape as the one above it: + * a NAMED import (a rename counts) whose specifier names the generated table. + * A module that re-derives the vocabulary some other way is outside it — the + * same declared bound the `icons` predicate carries, and review holds the rest. + */ +export const GENERATED_NAME_LIST_MODULE = 'lucide-record-icon-names'; + +/** Path of the generated list, relative to the repo root. */ +export const GENERATED_NAME_LIST_TARGET = 'packages/components/src/lib/lucide-record-icon-names.ts'; + +function importsGeneratedNameList(node, specifier) { + if (!/(^|\/)lucide-record-icon-names(\.(js|ts))?$/.test(specifier)) return false; + if (node.importClause?.isTypeOnly) return false; + const bindings = node.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) return false; + return bindings.elements.some((element) => !element.isTypeOnly); +} + +// ── Part 4: the generated legal-name list ──────────────────────────────────── +/** + * The list the seam judges membership with must be (a) in sync with lucide's + * own export manifest, (b) the same vocabulary as the runtime `icons` record, + * and (c) derived WITHOUT importing that record. + * + * ## Why all three, and why here + * + * (a) alone is a self-consistency check: the file matches a script nobody + * proved was reading the right thing. (b) is what makes the list a RECORD + * vocabulary rather than a third one — this gate already loads the record for + * its own judgement, so it is the natural place to assert the equality that the + * generator deliberately does not (keeping the record out of the generator is + * the point of the exercise). (c) is the ruling's own clause, made mechanical: + * 「合法图标名集合由构建期生成的静态名单提供(⛔ 不从 `Object.keys(icons)` 推导)」 + * — a derivation that read the record would re-pin every icon module into the + * eager closure and undo objectui#9251 without changing one visible byte of the + * seam. + * + * ⚠️ (c) is checked as an IMPORT, not as a text search. The prose of both files + * names `Object.keys(icons)` in order to forbid it, so a grep would fail on the + * very sentence that states the rule. A value import of `'lucide-react'` is the + * thing that actually costs the bytes; a `import type` of it is erased before + * anything is bundled and is not one. + */ +export const RECORD_FREE_MODULES = Object.freeze([ + GENERATED_NAME_LIST_TARGET, + 'scripts/regenerate-lucide-record-icon-names.mjs', + 'packages/components/src/renderers/action/resolve-icon.ts', +]); + +/** + * A file in this tree that DOES import the record for a value, so the + * "no record import" probe below can be shown to fire before its silence over + * {@link RECORD_FREE_MODULES} is quoted as a result. It is a test file, which + * is why it is allowed to do so: tests are not bundled into any page. + */ +export const RECORD_IMPORT_POSITIVE_CONTROL = + 'packages/components/src/renderers/action/__tests__/resolve-icon-seam.test.ts'; + +/** Does `file` import lucide's record entry for a VALUE (not just a type)? */ +export function importsRecordEntry(root, file) { + const sf = parseSource(root, file); + let found = false; + sf.forEachChild((node) => { + if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return; + if (node.moduleSpecifier.text !== 'lucide-react') return; + if (node.importClause?.isTypeOnly) return; + const bindings = node.importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + if (element.isTypeOnly) continue; + if ((element.propertyName ?? element.name).text === 'icons') found = true; + } + return; + } + // A namespace import reaches the record through the namespace object. + if (bindings && ts.isNamespaceImport(bindings)) found = true; + }); + return found; +} + +/** + * @param {string} root + * @returns {string[]} errors + */ +export function judgeGeneratedNameList(root) { + const errors = []; + + let pairs; + try { + pairs = deriveRecordIconNamePairs(root); + } catch (e) { + errors.push(`the generated legal-name list could not be derived: ${e instanceof Error ? e.message : String(e)}`); + return errors; + } + + // (a) the committed file is what the derivation renders. + const target = join(root, GENERATED_NAME_LIST_TARGET); + let committed = null; + try { committed = readFileSync(target, 'utf8'); } catch { committed = null; } + if (committed === null) { + errors.push(`${GENERATED_NAME_LIST_TARGET} is missing. Run \`node scripts/regenerate-lucide-record-icon-names.mjs\`.`); + } else if (committed !== renderGeneratedNameList(pairs)) { + errors.push( + `${GENERATED_NAME_LIST_TARGET} has drifted from lucide's export manifest — the seam would accept a ` + + 'different set of names than lucide ships. Run `node scripts/regenerate-lucide-record-icon-names.mjs` and commit the result.', + ); + } + + // (b) it is the RECORD vocabulary, key for key. + const derived = new Set(pairs.map(([pascal]) => pascal)); + const recordKeys = new Set(Object.keys(icons)); + const missing = [...recordKeys].filter((k) => !derived.has(k)); + const extra = [...derived].filter((k) => !recordKeys.has(k)); + if (missing.length > 0 || extra.length > 0) { + errors.push( + `the generated legal-name list is not the runtime \`icons\` vocabulary: ${missing.length} record key(s) absent ` + + `(${missing.slice(0, 5).join(', ')}), ${extra.length} name(s) it does not have ` + + `(${extra.slice(0, 5).join(', ')}). The manifest parse and the record have diverged.`, + ); + } + + // (b2) every name must be loadable through the dynamic import map, which is + // how the seam draws it. A name that is in the list but not in the map + // resolves to a component that throws on mount instead of to `null`. + const loadable = new Set(Object.keys(dynamicIconImports ?? {})); + if (loadable.size === 0) { + errors.push('lucide\'s dynamic import map read as empty — the loadability probe is blind, so its silence proves nothing.'); + } else { + const unloadable = pairs.filter(([, kebab]) => !loadable.has(kebab)); + if (unloadable.length > 0) { + errors.push( + `${unloadable.length} generated name(s) are not keys of lucide's dynamic import map and could not be ` + + `drawn: ${unloadable.slice(0, 5).map(([p, k]) => `${p} -> ${k}`).join(', ')}.`, + ); + } + } + + // (c) nothing on the derivation or resolution path imports the record. + if (!importsRecordEntry(root, RECORD_IMPORT_POSITIVE_CONTROL)) { + errors.push( + `the record-import probe did not fire on its positive control ${RECORD_IMPORT_POSITIVE_CONTROL}, ` + + 'so its silence over the modules below is not a reading. Point the control at a module that still imports `icons`.', + ); + } else { + for (const file of RECORD_FREE_MODULES) { + if (importsRecordEntry(root, file)) { + errors.push( + `${file} imports lucide's runtime \`icons\` record for a value. That import is the eager closure ` + + 'objectui#9251 removed — 1,781 icon modules — and the ruling refuses deriving the legal-name set from it.', + ); + } + } + } + + return errors; +} + // ── Part 1: surface census ─────────────────────────────────────────────────── /** * Which modules read which lucide vocabulary — rediscovered from source, so the @@ -790,33 +964,51 @@ export function discoverResolvers(root, files) { for (const file of files) { if (isTestPath(file)) continue; const text = readFileSync(join(root, file), 'utf8'); - if (!text.includes('lucide-react')) continue; + if (!text.includes('lucide-react') && !text.includes(GENERATED_NAME_LIST_MODULE)) continue; const sf = parseSource(root, file); let recordLocal = null; + let readsGeneratedNameList = false; let readsDynamic = 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 (importsGeneratedNameList(node, specifier)) readsGeneratedNameList = true; if (specifier !== 'lucide-react') return; const bindings = node.importClause?.namedBindings; if (!bindings || !ts.isNamedImports(bindings)) return; for (const element of bindings.elements) { + // A TYPE-only binding is erased before anything is bundled, so it does + // not put the record on the eager path and is not a read of it. The + // seam imports `LucideIcon` this way. + if (node.importClause?.isTypeOnly || element.isTypeOnly) continue; if ((element.propertyName ?? element.name).text === 'icons') recordLocal = element.name.text; } }); - if (readsDynamic) dynamic.push(file); - if (!recordLocal) continue; - let indexes = false; - const visit = (node) => { - if (ts.isElementAccessExpression(node)) { - const base = unwrap(node.expression); - if (ts.isIdentifier(base) && base.text === recordLocal) indexes = true; - } - ts.forEachChild(node, visit); - }; - ts.forEachChild(sf, visit); - if (indexes) record.push(file); + + let indexesRecord = false; + if (recordLocal) { + const visit = (node) => { + if (ts.isElementAccessExpression(node)) { + const base = unwrap(node.expression); + if (ts.isIdentifier(base) && base.text === recordLocal) indexesRecord = true; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + } + + const readsRecordVocabulary = indexesRecord || readsGeneratedNameList; + if (readsRecordVocabulary) record.push(file); + // ⚠️ A record site is NOT also a dynamic site, even though the seam imports + // `DynamicIcon`. The split this gate polices is which VOCABULARY a site's + // names are judged against, and the seam's is the record's 1,781 keys — it + // uses the dynamic map as a LOADER for names it has already accepted, never + // as the membership question. Listing it in both censuses would say it + // admits `iconNames`'s 258 retired spellings, which is the one thing this + // gate exists to deny. Moving the seam's membership to `iconNames` still + // fails loudly: the record census would drop to zero. + else if (readsDynamic) dynamic.push(file); } return { record: record.sort(), dynamic: dynamic.sort() }; } @@ -1042,6 +1234,7 @@ function judgeAnchoredMaps(root, anchors) { * declaredDynamicReaders?: readonly string[], * negativeControl?: string, * recordReadingTypes?: Record, + * generatedNameListRoot?: string, * }} AnalyzeOptions * * @param {string} root @@ -1053,6 +1246,13 @@ export function analyze(root, { declaredDynamicReaders = DECLARED_DYNAMIC_READERS, negativeControl = DISCOVERY_NEGATIVE_CONTROL, recordReadingTypes = RECORD_READING_TYPES, + // ⚠️ Deliberately NOT `root`. Parts 1-3 judge whatever tree they are pointed + // at, which is what lets the unit suite drive them with synthetic fixtures. + // Part 4 judges THIS repository's own generator, generated list and seam — + // three fixed paths, none of which a fixture tree has — so it is anchored to + // the gate's own root and a fixture run exercises it against the real files. + // Passing `null` turns it off for a caller that only wants parts 1-3. + generatedNameListRoot = gateRoot, } = {}) { const errors = [...selfTest(), ...censusResolverProblems()]; const { sources, documents } = collectFiles(root); @@ -1085,6 +1285,7 @@ export function analyze(root, { const authored = judgeAuthoredNodes(root, { sources, documents }, recordReadingTypes); const anchored = judgeAnchoredMaps(root, anchors); errors.push(...authored.errors, ...anchored.errors); + if (generatedNameListRoot) errors.push(...judgeGeneratedNameList(generatedNameListRoot)); return { discovered, diff --git a/scripts/regenerate-lucide-record-icon-names.mjs b/scripts/regenerate-lucide-record-icon-names.mjs new file mode 100644 index 0000000000..ab944077f5 --- /dev/null +++ b/scripts/regenerate-lucide-record-icon-names.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +/** + * Regenerates `packages/components/src/lib/lucide-record-icon-names.ts` — the + * BUILD-GENERATED static list of the icon names the seam accepts, and the + * kebab-case spelling each one loads. + * + * ── Why this file exists ─────────────────────────────────────────────────── + * + * `renderers/action/resolve-icon.ts` used to answer "is this a legal icon + * name?" by INDEXING lucide's runtime `icons` record. A namespace object has no + * dead members, so that one index pulled EVERY icon module into the eager + * closure: measured on the console build at `ac05d4f4dd`, 1,781 icon module + * definitions inside `assets/ui-components-*.js` — 1,499 KB raw of a 1,535,917 + * byte chunk. The record was the payload, and the name list was free only + * because the payload was already there. + * + * ⛔ So the membership set may NOT be derived from `Object.keys(icons)`: that + * derivation IS the eager record, written a second way. Deriving it would pin + * the record eager for as long as anything asked a name question — which is + * what objectui#9204's rescue option A proposed and what the ruling on + * objectui#9251 refused, verbatim: 「合法图标名集合由构建期生成的静态名单提供 + * (⛔ 不从 `Object.keys(icons)` 推导)」. + * + * ── What it reads instead ────────────────────────────────────────────────── + * + * lucide's OWN export manifest, `lucide-react/dist/esm/icons/index.mjs`, which + * is the file the `icons` record is built from: + * + * export { default as Trash2 } from './trash-2.mjs'; + * + * Each line carries BOTH spellings this repo needs — the PascalCase key the + * seam looks names up by, and the kebab-case module name `DynamicIcon` loads. + * Reading the manifest as TEXT is what keeps the generation off the record: no + * icon module is ever imported here, so nothing about this script can bring one + * into anybody's bundle. + * + * ⚠️ The second spelling is not decoration and ⛔ must not be replaced by a + * conversion rule at the call site. 95 of the 1,781 keys do not survive one: + * `Trash2` is `trash-2`, `ArrowDown01` is `arrow-down-0-1`, `Axis3d` is + * `axis-3d`. A PascalCase-to-kebab regex silently produces `trash2`, + * `arrow-down01` and `axis3d`, none of which `dynamicIconImports` can load — + * and the failure surfaces as an icon that renders nothing, with no error. + * + * ── The three preconditions it refuses to write without ──────────────────── + * + * Each one is a way this generator could produce a plausible-looking file that + * is wrong, so each is checked rather than assumed: + * + * 1. The parse found icons at all, and a known pair is among them. A regex + * that stops matching after a lucide release upgrade would otherwise write + * an empty or truncated list and every icon in the product would stop + * resolving, quietly, with the gate green. + * 2. Every kebab spelling is a key of `dynamicIconImports`. That map is what + * the seam loads through; a name that is in the record but not in the map + * resolves to a component that throws on mount instead of to `null`. + * 3. The PascalCase keys are unique and the kebab targets are unique. The + * table is a bijection today; an aliasing release would make the decoded + * map lossy in a way no consumer could detect. + * + * The equality between this derivation and lucide's runtime record is asserted + * SEPARATELY, in `scripts/check-lucide-icon-record-names.mjs`, which already + * loads the record for its own judgement. Keeping the record out of this script + * is the point; keeping the comparison is what proves the manifest is the same + * vocabulary. + * + * Run: node scripts/regenerate-lucide-record-icon-names.mjs + * Verify: node scripts/regenerate-lucide-record-icon-names.mjs --check + * Gated: `pnpm check:icon-record-names` runs the same comparison as part 4 of + * the census gate, so a drifted file fails CI without a second step. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +export const TARGET = 'packages/components/src/lib/lucide-record-icon-names.ts'; + +/** + * `lucide-react` is not resolvable from the repo root — only the packages that + * declare it have it. Resolve it from the package that owns the seam, so this + * generator reads the very copy `resolve-icon.ts` will load at runtime. + */ +export const LUCIDE_OWNER_PKG = 'packages/components/package.json'; + +/** The manifest line shape, and the only thing this script parses. */ +const EXPORT_LINE = /^export \{ default as ([A-Za-z0-9]+) \} from '\.\/([a-z0-9]+(?:-[a-z0-9]+)*)\.mjs';$/; + +/** + * A pair this repo authors today and that lucide has carried for years. It is + * the FIRING CONTROL on precondition 1: a parse that returns rows but not this + * one has matched something other than the manifest. + */ +const CONTROL_PAIR = ['House', 'house']; + +/** One of the 95 keys a PascalCase-to-kebab conversion gets wrong. */ +const DIGIT_CONTROL_PAIR = ['Trash2', 'trash-2']; + +/** + * Every `Pascal -> kebab` pair lucide's own export manifest declares, sorted by + * the PascalCase key so the emitted file is stable across runs. + * + * @param {string} [root] repository root + * @returns {[string, string][]} + */ +export function deriveRecordIconNamePairs(root = repoRoot) { + const lucideRequire = createRequire(join(root, LUCIDE_OWNER_PKG)); + // The ESM manifest, deliberately: `dist/esm/lucide-react.mjs` builds the + // `icons` record out of exactly this file (`import * as index from + // './icons/index.mjs'`), and the ESM tree is the one the console's bundler + // reads. ⛔ Not `require.resolve('lucide-react')`, which answers with the CJS + // `main` and would put this on a build nothing ships. + const lucideRoot = dirname(lucideRequire.resolve('lucide-react/package.json')); + const manifestPath = join(lucideRoot, 'dist/esm/icons/index.mjs'); + const manifest = readFileSync(manifestPath, 'utf8'); + + /** @type {[string, string][]} */ + const pairs = []; + for (const line of manifest.split('\n')) { + const match = EXPORT_LINE.exec(line.trim()); + if (match) pairs.push([match[1], match[2]]); + } + + // Precondition 1 — the parse found the manifest, not merely some lines. + if (pairs.length === 0) { + throw new Error( + `no icon exports parsed out of ${manifestPath}. The manifest's line shape has changed; ` + + 'refusing to write an empty legal-name list.', + ); + } + for (const [pascal, kebab] of [CONTROL_PAIR, DIGIT_CONTROL_PAIR]) { + if (!pairs.some(([p, k]) => p === pascal && k === kebab)) { + throw new Error( + `the control pair ${pascal} -> ${kebab} is absent from the parse of ${manifestPath}. ` + + 'A parse that misses a name lucide still ships is not a reading; refusing to write.', + ); + } + } + + // Precondition 3 — the table must stay a bijection. + const seenPascal = new Set(); + const seenKebab = new Set(); + for (const [pascal, kebab] of pairs) { + if (seenPascal.has(pascal)) throw new Error(`duplicate PascalCase key in the manifest: ${pascal}`); + if (seenKebab.has(kebab)) throw new Error(`two PascalCase keys share the kebab target ${kebab}`); + seenPascal.add(pascal); + seenKebab.add(kebab); + } + + return pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); +} + +/** + * Precondition 2 — every kebab spelling must be loadable through the dynamic + * import map. Separated from the parse because it is the only step that imports + * anything out of lucide, and it imports the MAP (a table of thunks), never an + * icon module. + * + * @param {[string, string][]} pairs + * @param {string} [root] + */ +export async function assertLoadableThroughDynamicMap(pairs, root = repoRoot) { + const lucideRequire = createRequire(join(root, LUCIDE_OWNER_PKG)); + const dynamic = await import(pathToFileURL(lucideRequire.resolve('lucide-react/dynamic.mjs')).href); + const loadable = new Set(Object.keys(dynamic.dynamicIconImports)); + if (loadable.size === 0) { + throw new Error('lucide\'s dynamic import map is empty — the probe is blind, so its silence proves nothing.'); + } + const missing = pairs.filter(([, kebab]) => !loadable.has(kebab)); + if (missing.length > 0) { + throw new Error( + `${missing.length} record icon name(s) are not keys of lucide's dynamic import map and could ` + + `not be loaded at runtime: ${missing.slice(0, 5).map(([p, k]) => `${p} -> ${k}`).join(', ')}`, + ); + } +} + +/** + * The exact contents the committed module must have. + * + * ## Why one delimited string and not an object literal + * + * The table is eager — the seam has to answer "is this name legal?" while it + * renders, so the answer cannot be awaited. Two shapes were measured at the + * commit this landed on, gzipped standalone: an object literal of 1,781 + * properties is 45,307 raw / 14,106 gzipped; this string is 41,745 / 13,857. + * The string also costs nothing at module-evaluation time — it is parsed into a + * `Map` on the first lookup and never at all on a page that draws no icon, + * where the object literal would allocate 1,781 properties regardless. + * + * ⛔ The figures above are anchored to that measurement and are NOT a live + * claim; `pnpm check:eager-closure` weighs what the chunk costs today. + * + * @param {[string, string][]} pairs + */ +export function renderModule(pairs) { + const table = pairs.map(([pascal, kebab]) => `${pascal}:${kebab}`).join(','); + 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. + */ + +/** + * GENERATED FILE — do not edit by hand. + * + * Regenerate: node scripts/regenerate-lucide-record-icon-names.mjs + * Verified by: scripts/check-lucide-icon-record-names.mjs (part 4), which also + * proves this list is the same vocabulary as lucide's runtime + * \`icons\` record. + * + * The legal icon-name set for \`renderers/action/resolve-icon.ts\`, as read out + * of lucide's own export manifest at generation time. + * + * Each entry is \`PascalCaseKey:kebab-module-name\`. The first is what the seam + * looks a tokenised author-supplied name up by; the second is what + * \`DynamicIcon\` loads. ⛔ The second is NOT derivable from the first by a + * regex — 95 of these keys carry digits that lucide splits and a conversion + * does not (\`Trash2\` is \`trash-2\`, \`ArrowDown01\` is \`arrow-down-0-1\`). + * + * ⛔ This list is deliberately NOT \`Object.keys(icons)\`. That derivation is the + * eager record written a second way: indexing the record pulls every icon + * module into the eager closure, which is the payload objectui#9251 removed. + */ + +export const LUCIDE_RECORD_ICON_NAME_TABLE = + '${table}'; +`; +} + +async function main() { + const check = process.argv.includes('--check'); + const target = join(repoRoot, TARGET); + + let expected; + try { + const pairs = deriveRecordIconNamePairs(); + await assertLoadableThroughDynamicMap(pairs); + expected = renderModule(pairs); + } catch (e) { + console.error(`x ${e instanceof Error ? e.message : String(e)}`); + process.exit(1); + return; + } + + let actual = null; + try { + actual = readFileSync(target, 'utf8'); + } catch { + actual = null; + } + + if (actual === expected) { + console.log(`OK ${TARGET} matches lucide's export manifest.`); + return; + } + + if (check) { + console.error( + `x ${TARGET} has drifted from lucide's export manifest.\n` + + ' Run `node scripts/regenerate-lucide-record-icon-names.mjs` and commit the result.', + ); + process.exit(1); + return; + } + + writeFileSync(target, expected); + console.log(`wrote ${TARGET}`); +} + +if (isEntrypoint(import.meta.url)) { + await main(); +}