Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/9251-icons-record-not-eager.md
Original file line number Diff line number Diff line change
@@ -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 `<svg>` 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 `<path>` children arrive when the icon's own chunk lands.
Selecting or styling by `svg.lucide-<name>` keeps working on the first frame;
a test that asserts on the path data inside the svg now has to await it.
50 changes: 50 additions & 0 deletions apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
32 changes: 32 additions & 0 deletions packages/components/src/lib/lucide-record-icon-names.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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 `<svg>` — its
* classes, its box, its attributes — is emitted on the first frame and only the
* `<path>` 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 `<svg>`. */
function renderIcon(Component: React.ElementType, props: Record<string, unknown> = {}) {
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 `<svg>` 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<string, React.ElementType>)[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'));
});
});
Loading
Loading