From 032ad081eb97d4db46c7608332a0b665b614e5ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 13:58:11 +0000 Subject: [PATCH 1/6] perf(components): take lucide's `icons` record off the eager path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon seam answered `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: 1,781 icon module definitions inside `assets/ui-components-*.js`, measured on the console build at ac05d4f4dd. Membership now comes from a build-generated static list derived from lucide's own export manifest, and the glyph comes through lucide's dynamic-import map. The record is no longer imported for a value by anything that ships. What renders is unchanged apart from when the path data arrives: which icon a name resolves to is still known synchronously, so the `` — lucide's classes, box and attributes — is emitted on the first frame and only its `` children are late. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../src/lib/lucide-record-icon-names.ts | 32 ++ .../__tests__/resolve-icon-lazy-9251.test.tsx | 128 ++++++++ .../__tests__/resolve-icon-seam.test.ts | 78 +++-- .../src/renderers/action/resolve-icon.ts | 210 ++++++++++++- ...e-record-icon-names-generated-9251.test.ts | 113 +++++++ scripts/check-lucide-icon-record-names.mjs | 222 +++++++++++++- .../regenerate-lucide-record-icon-names.mjs | 279 ++++++++++++++++++ 7 files changed, 1023 insertions(+), 39 deletions(-) create mode 100644 packages/components/src/lib/lucide-record-icon-names.ts create mode 100644 packages/components/src/renderers/action/__tests__/resolve-icon-lazy-9251.test.tsx create mode 100644 scripts/__tests__/lucide-record-icon-names-generated-9251.test.ts create mode 100644 scripts/regenerate-lucide-record-icon-names.mjs 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__/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-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index 3d38830368..e1aa6817e6 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() }; } @@ -1084,7 +1276,7 @@ export function analyze(root, { const authored = judgeAuthoredNodes(root, { sources, documents }, recordReadingTypes); const anchored = judgeAnchoredMaps(root, anchors); - errors.push(...authored.errors, ...anchored.errors); + errors.push(...authored.errors, ...anchored.errors, ...judgeGeneratedNameList(root)); 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(); +} From bbf6b02d97798927db10257901c5dc032c0f25c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:23:09 +0000 Subject: [PATCH 2/6] perf(console): give every lucide icon module its own chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the `icons` record left the eager path, the ~125 icons 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 were LAZY plugin chunks: plugin-dashboard (21 icons), plugin-gantt (62) and plugin-report (1). The eager entry chunk then held a static import of 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. The group's `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. That is the same remedy the ten i18n catalogues carry, and the opposite of an aggregate icons chunk, which would move the budget row without moving one byte off the page. Also anchors the census gate's new generated-list judgement to the gate's own root: it judges three fixed repository paths, none of which the unit suite's synthetic fixture trees have. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .changeset/9251-icons-record-not-eager.md | 33 ++++++++++++++ apps/console/vite.config.ts | 50 ++++++++++++++++++++++ scripts/check-lucide-icon-record-names.mjs | 11 ++++- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 .changeset/9251-icons-record-not-eager.md 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/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index e1aa6817e6..212f44a23d 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -1234,6 +1234,7 @@ function judgeAnchoredMaps(root, anchors) { * declaredDynamicReaders?: readonly string[], * negativeControl?: string, * recordReadingTypes?: Record, + * generatedNameListRoot?: string, * }} AnalyzeOptions * * @param {string} root @@ -1245,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); @@ -1276,7 +1284,8 @@ export function analyze(root, { const authored = judgeAuthoredNodes(root, { sources, documents }, recordReadingTypes); const anchored = judgeAnchoredMaps(root, anchors); - errors.push(...authored.errors, ...anchored.errors, ...judgeGeneratedNameList(root)); + errors.push(...authored.errors, ...anchored.errors); + if (generatedNameListRoot) errors.push(...judgeGeneratedNameList(generatedNameListRoot)); return { discovered, From 1e779514fc34545d847d6424505c488c2b9d6141 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:26:57 +0000 Subject: [PATCH 3/6] perf(budget): re-pin the eager-closure ceilings the icon work made decorative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both moves are TIGHTENINGS and both are what the gate itself asked for in its own verdict: no build that passed before this commit and measures under the new figures fails after it. aggregate 3,210,000 -> 3,179,000 over 3,133,419 (0.50x) chunk ui-components 399,000 -> 289,000 over 265,937 (0.25x) Baselines move with them, both from the console build of bbf6b02d9, subtracted from a control build of this branch's base ac05d4f4d taken in the same container with the same instrument. `EXHAUSTED_HEADROOM_ALLOWANCES['ui-components']` is REMOVED rather than lowered: the row cleared the floor on its own, 0.02x -> 0.25x, which is the only currency that table takes. Leaving it would have been the worse edit — `floorFor` reads an allowance as the 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, running the gate weaker on the row it had just been strengthened for, while the renderer went on printing "under the 0.10x floor" about a row 2.5x clear of it. The table is now empty, so every case that drove the ratchet off it now drives a synthetic row instead. Paying the last debt off must not retire the instrument that collects it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../check-eager-closure-budget.test.ts | 72 +++++-- scripts/check-eager-closure-budget.mjs | 176 ++++++++++++------ 2 files changed, 176 insertions(+), 72 deletions(-) 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/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 907ab61c7f..71c71ff522 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,25 @@ 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 259 of those 1,781 raw KB left the chunk: 397,091 -> 265,937 gzipped, + // 1,535,917 -> 997,293 raw, on the two console builds recorded under "Why + // this number came DOWN" on {@link MAX_EAGER_CLOSURE_GZIP_BYTES}. + // + // ⛔ 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 +1158,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 +1234,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, }); /** @@ -1294,16 +1345,35 @@ export const EXHAUSTED_HEADROOM_FLOOR_MULTIPLE = 0.1; * the comparison is made in and the reason a red here is a red a reader can see. */ export const EXHAUSTED_HEADROOM_ALLOWANCES = Object.freeze({ - // ⭐ `i18n-locales: 8_804` stood here until objectui#7479. It is REMOVED, not - // lowered, and the distinction is the whole of why that is allowed: the rule - // above forbids LOWERING a figure, because a lowered figure is headroom - // supplied to a row that still exists. This row's chunk does not exist any - // more — nine of the ten catalogues it weighed are `import()`ed on demand, and - // the one that stays is budgeted under its own key at a headroom of 0.11x, - // ABOVE the floor and needing no allowance at all. That is the debt PAID, in - // the only currency this table takes: the row cleared the floor on its own. - // ⛔ Re-adding a locale row here would mean the catalogues came back. - 'ui-components': 4_289, + // ⭐ EMPTY, and 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. }); /** From 977612e924e4064aba0488294bb3c77b1bcff3aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:41:37 +0000 Subject: [PATCH 4/6] fix(budget): give the allowance table its real type, not the one it happened to infer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EXHAUSTED_HEADROOM_ALLOWANCES` inferred its shape from the single entry it carried, so `Object.values(...)` was `number[]` for free. Emptying the table left an empty literal, which infers nothing, and the same expression in the unit suite became `unknown[]`: scripts/__tests__/check-eager-closure-budget.test.ts(1038,18): error TS18046: 'allowance' is of type 'unknown'. The annotation goes on the declaration, where the fact lives: chunk name to allowance bytes is what this table IS, whether or not it currently holds a row. ⛔ Not a cast at the reader, which would have hidden the same gap the next time the table changed shape. ⚠️ This leg is not covered by any per-package `type-check`, nor by `turbo run type-check` — `scripts/` is not a workspace package. `pnpm type-check:scripts` has to be run on its own; it now exits 0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- scripts/check-eager-closure-budget.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 71c71ff522..a62ae2a060 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -1343,6 +1343,18 @@ 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({ // ⭐ EMPTY, and that is a state this table is allowed to be in: it is a ledger From a6f759490905ab8f8390d8a6c26a8543432a70f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 15:58:24 +0000 Subject: [PATCH 5/6] fix(budget): let the report answer for this chunk's raw size, not a comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ui-components` re-pin restated the raw byte pair beside the gzipped one. The head leg was wrong by 32,857 bytes, and the KB claim in front of it was this gate's own gzipped KB reading wearing the word "raw" — two figures nothing re-derives, in a file where no constant reads raw bytes and no test weighs them, so no build could ever have gone red on either. A ceiling-tier contract review re-measuring by hand is what found it. Repaired by pointing at the instrument rather than by swapping a digit (AGENTS.md #9): the `bytes` field beside `gzipBytes` for this key in `apps/console/dist/eager-closure.json` re-derives the head leg on any build. The control leg is a build of `ac05d4f4d`, which no checkout re-derives — stated in the text rather than left to read as live. ⛔ No constant, ceiling, baseline or allowance moves. The premise was verified before editing, on a console build of this head read two independent ways — the report's `bytes` field and `wc -c` on the emitted chunk, which agree — while that same build reproduces every gzipped figure in the re-pin byte for byte. node scripts/check-eager-closure-budget.mjs exit 0 vitest run --project unit exit 0, 145 passed node scripts/check-control-bytes.mjs exit 0 pnpm run type-check:scripts exit 0 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- scripts/check-eager-closure-budget.mjs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index a62ae2a060..6d24c28ab7 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -1076,9 +1076,25 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ // 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 259 of those 1,781 raw KB left the chunk: 397,091 -> 265,937 gzipped, - // 1,535,917 -> 997,293 raw, on the two console builds recorded under "Why - // this number came DOWN" on {@link MAX_EAGER_CLOSURE_GZIP_BYTES}. + // 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 (review 5654270820 on + // objectui#9399); 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 From 6c96f55537f3518a540ed132bdf5fcfb3df11c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 16:05:46 +0000 Subject: [PATCH 6/6] docs(budget): name the review by the id that resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pointer added in the previous commit said "review 5654270820". There is no pull-request review with that id — the contract review was delivered as an ISSUE comment, and that is the API a reader has to ask. A pointer that does not resolve is the same defect one column over from the figure it replaced. node scripts/check-eager-closure-budget.mjs exit 0 vitest run --project unit exit 0, 145 passed node scripts/check-control-bytes.mjs exit 0 pnpm run type-check:scripts exit 0 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- scripts/check-eager-closure-budget.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 6d24c28ab7..6c52f489eb 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -1086,9 +1086,11 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ // 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 (review 5654270820 on - // objectui#9399); the old figures are not quoted back, for the reason the - // objectui#7528 pin gives — a reader cannot tell a quotation from a claim. + // 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