Skip to content
Closed
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
39 changes: 39 additions & 0 deletions .changeset/9204-icon-names-from-the-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/console': minor
---

Icon-name membership answers from lucide's `icons` record, and a retired
spelling is refused instead of silently degraded (objectui#9204).

⚠️ **Behaviour change, on purpose.** lucide publishes two icon vocabularies: the
runtime `icons` record it actually ships, and the larger dynamic-import list
that still carries spellings lucide has retired. `getLazyIcon`, `LazyIcon` and
`isLucideIconName` used to judge names against the second one; they now judge
against the record, which is what every other resolver in this package already
read. Measured against the installed lucide, that retires **254 spellings** —
`smile`, `edit`, `filter`, `alert-triangle`, `sort-desc` and the rest.

- **A retired spelling is now REFUSED OUT LOUD.** It used to become the
`Database` glyph (or, through `isLucideIconName`, a notification's severity
icon) with nothing logged — a page that still rendered and a glyph that looked
deliberate. The console now carries a diagnostic, once per spelling, naming
the spelling, lucide's current name for it, and the exact spelling to write
instead. Both halves are derived from the installed lucide at runtime; there
is no list of retired names in this repo to go stale.
- **`isLucideIconName` keeps its signature and its synchronous contract.** Only
the vocabulary behind it moved. Spellings the two lists agree on — kebab-case,
snake_case, space-separated and PascalCase alike — resolve exactly as before.
- **New export `loadLucideIconNames()`** — the renderable icon vocabulary,
`Promise`-returning, for a picker that needs the whole list to search. The
metadata designer's icon picker loads it when it opens.

**Why:** importing the icon NAMES imports lucide's dynamic-import map with them,
because lucide derives the names as that map's keys. Four modules did, and the
map sat in the console's eager `ui-components` chunk on every page load. Sourcing
membership from the record — which the same chunk already carries — takes
**8,515 gzipped bytes** off that chunk and **8,520** off the whole eager closure,
measured on two console builds in one container. The `ui-components` row goes
from 1,910 B of headroom (0.02x, red) to 10,425 B (0.11x, green), which pays off
the declared allowance that row has been carrying.
49 changes: 15 additions & 34 deletions apps/console/src/utils/getIcon.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,22 @@
/**
* Icon utilities
*
* Synchronous accessor that returns a lazy-loaded Lucide icon React
* component. Wraps lucide-react's `DynamicIcon` so we don't bloat the
* vendor bundle by statically importing the entire icon namespace.
*/

import React from 'react';
import { Database } from 'lucide-react';
import { DynamicIcon } from 'lucide-react/dynamic';

function toKebab(name: string): string {
if (name.includes('-')) return name.toLowerCase();
return name
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}

const cache = new Map<string, React.ElementType>();

/**
* Resolve a Lucide icon component by name.
* Synchronous accessor that returns a lazy-loaded Lucide icon React component.
*
* ## Delegated rather than transcribed (objectui#9204)
*
* The result is memoised per name in the module-level `cache`, so call sites
* get a *stable* component reference across renders — nothing is created during
* This was a third copy of `@object-ui/components`' `getLazyIcon` — the same
* kebab-casing, the same memo, the same `Database` fallback — differing only in
* that it skipped the name check and let lucide log "Name in Lucide DynamicIcon
* not found" for an off-catalog name. Its `lucide-react/dynamic` import put
* lucide's 2,025-entry dynamic-import map on the console's eager path; the
* shared resolver keeps the icon NAMES as data and fetches the map through
* `import()` on first use.
*
* The result is memoised per name inside that resolver, so call sites still get
* a *stable* component reference across renders — nothing is created during
* render. `react-hooks/static-components` cannot see through the call, so the
* JSX sites that render the result carry a targeted disable pointing back here.
*/
export function getIcon(name?: string): React.ElementType {
if (!name) return Database;
const cached = cache.get(name);
if (cached) return cached;
const kebab = toKebab(name);
const Wrapped: React.FC<any> = (props) =>
React.createElement(DynamicIcon as any, { name: kebab, fallback: Database, ...props });
Wrapped.displayName = `LucideIcon(${name})`;
cache.set(name, Wrapped);
return Wrapped;
}

export { getLazyIcon as getIcon } from '@object-ui/components';
78 changes: 24 additions & 54 deletions packages/app-shell/src/utils/getIcon.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,35 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Icon utilities
*
* Helpers for resolving Lucide icons by name.
*
* Implementation: instead of statically importing every icon (~1500
* components, ~568 KB raw / 140 KB gz), we wrap lucide-react's built-in
* `DynamicIcon` so each icon is fetched as its own tiny chunk on first use.
*
* The exported `getIcon(name)` API stays synchronous and returns a React
* component, preserving call sites that do `const Icon = getIcon(name); <Icon />`.
*/

import React from 'react';
import { Database } from 'lucide-react';
import { DynamicIcon, iconNames } from 'lucide-react/dynamic.mjs';

/** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */
function toKebab(name: string): string {
if (name.includes('-')) return name.toLowerCase();
return name
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}

// Lucide ships ~3900 icon names; storing as a Set keeps lookups O(1).
const VALID_ICON_NAMES: Set<string> = new Set(iconNames as string[]);

const cache = new Map<string, React.ElementType>();

/**
* Resolve a Lucide icon by name (kebab-case or PascalCase).
*
* Returns a React component that lazy-loads the underlying SVG icon on
* mount. Falls back to the `Database` icon (statically imported) when no
* `name` is given, or when the requested name is not a valid Lucide icon
* — server-driven metadata frequently references icons from other libraries
* (e.g. `box-open` from Font Awesome), and we silently degrade to the
* fallback rather than letting Lucide log a console error.
* ## One resolver, not a second copy (objectui#9204)
*
* The returned component is memoised per `name` so repeated calls with the
* same name yield the same component reference (stable for React.memo).
* This file used to carry its own transcription of `@object-ui/components`'
* `getLazyIcon`: the same kebab-casing, the same name-membership Set, the same
* per-name memo, the same `Database` fallback. The copy is now a delegation,
* for two reasons that are the same reason:
*
* - the membership Set was built from `iconNames`, and lucide derives that
* from its 2,025-entry dynamic-import map — so this module's import alone
* put that whole map on the console's eager path;
* - two transcriptions of one lookup are two chances to disagree about which
* lucide vocabulary a name is judged against, which is precisely what
* `scripts/check-lucide-icon-record-names.mjs` censuses.
*
* The shared resolver keeps the names as data and reaches the map through
* `import()`. Behaviour here is unchanged: same normalisation, same fallback.
*/
export function getIcon(name?: string): React.ElementType {
if (!name) return Database;
const cached = cache.get(name);
if (cached) return cached;

const kebab = toKebab(name);
if (!VALID_ICON_NAMES.has(kebab)) {
cache.set(name, Database);
return Database;
}

const Wrapped: React.FC<any> = (props) =>
React.createElement(DynamicIcon as any, {
name: kebab,
fallback: Database,
...props,
});
Wrapped.displayName = `LucideIcon(${name})`;
cache.set(name, Wrapped);
return Wrapped;
}
export { getLazyIcon as getIcon } from '@object-ui/components';
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
// The grid's vocabulary arrives through `import('lucide-react/dynamic.mjs')`
// (objectui#9204). Importing that module here, at module scope, pays for it in
// the import phase — which is under no test or hook timeout — instead of
// letting a saturated transform pipeline spend a `findBy` budget on it.
import 'lucide-react/dynamic.mjs';
import { WIDGETS } from './widgets';

afterEach(cleanup);
Expand All @@ -11,9 +16,15 @@ const Icon = WIDGETS['icon'];
/**
* `icon` widget — a searchable Lucide icon picker for page/app/object `icon`
* fields (replaces the raw text input). Built inline (no Radix portal) so the
* trigger, search box and result grid all render eagerly in jsdom. The icon
* previews lazy-load their SVG chunk and degrade to a fallback glyph, so these
* tests assert on the catalog wiring rather than the rendered <svg>.
* trigger, search box and result grid all render without a portal in jsdom. The
* icon previews lazy-load their SVG chunk and degrade to a fallback glyph, so
* these tests assert on the catalog wiring rather than the rendered <svg>.
*
* ⚠️ The GRID is asynchronous since objectui#9204 — the vocabulary is lucide's
* dynamic-import map, fetched when the dialog opens rather than held on the
* eager path. The trigger is not: it asks `isLucideIconName`, which reads the
* `icons` record and needs nothing loaded. That split is why the rows below
* await the options but not the combobox.
*/
describe('icon widget', () => {
it('is registered in the WIDGETS map', () => {
Expand All @@ -27,34 +38,67 @@ describe('icon widget', () => {
expect(trigger).toHaveTextContent('calendar');
});

it('opens a search box and filters the icon grid by query', () => {
it('says it is loading before the catalogue lands, not "no matching icons"', () => {
// The synchronous frame. An empty grid mid-fetch must not read as a query
// that found nothing — that sentence would be a false answer to a question
// nobody has asked yet.
render(<Icon value="" onChange={() => {}} schema={{ type: 'string' }} />);
fireEvent.click(screen.getByRole('combobox'));
expect(screen.queryAllByRole('option')).toHaveLength(0);
expect(screen.getByText('Loading options…')).toBeInTheDocument();
expect(screen.queryByText('No matching icons.')).toBeNull();
});

it('opens a search box and filters the icon grid by query', async () => {
render(<Icon value="" onChange={() => {}} schema={{ type: 'string' }} />);
fireEvent.click(screen.getByRole('combobox'));

const search = screen.getByLabelText('Search icons…');
const before = screen.getAllByRole('option').length;
const before = (await screen.findAllByRole('option')).length;
expect(before).toBeGreaterThan(0);

fireEvent.change(search, { target: { value: 'ampersand' } });
await waitFor(() => {
expect(screen.getAllByRole('option').length).toBeLessThan(before);
});
const after = screen.getAllByRole('option');
expect(after.length).toBeGreaterThan(0);
expect(after.length).toBeLessThan(before);
// Every surviving option matches the query.
for (const opt of after) {
expect(opt.getAttribute('title')).toContain('ampersand');
}
});

it('writes the selected icon name through onChange', () => {
it('writes the selected icon name through onChange', async () => {
const onChange = vi.fn();
render(<Icon value="" onChange={onChange} schema={{ type: 'string' }} />);
fireEvent.click(screen.getByRole('combobox'));
await screen.findAllByRole('option');
fireEvent.change(screen.getByLabelText('Search icons…'), { target: { value: 'ampersand' } });
await waitFor(() => {
expect(screen.getAllByRole('option')[0]?.getAttribute('title')).toContain('ampersand');
});
fireEvent.click(screen.getAllByRole('option')[0]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(String(onChange.mock.calls[0][0])).toContain('ampersand');
});

it('offers only names the renderer will resolve — the vocabulary is the record', async () => {
// objectui#9204: the picker and `getLazyIcon` read ONE vocabulary now. A
// retired spelling in this grid would be an author-facing trap — pickable,
// then refused at render time.
render(<Icon value="" onChange={() => {}} schema={{ type: 'string' }} />);
fireEvent.click(screen.getByRole('combobox'));
fireEvent.change(screen.getByLabelText('Search icons…'), { target: { value: 'filter' } });
const options = await screen.findAllByRole('option');
const titles = options.map((o) => o.getAttribute('title'));
// `filter` is a spelling lucide still LOADS but has dropped from the record
// (it is `funnel` now). ⭐ The control is `list-filter`: the query DOES match
// live names, so the absence below is a refusal rather than an empty grid.
expect(titles).toContain('list-filter');
expect(titles).not.toContain('filter');
});

it('preserves an out-of-catalog value (renders it, offers a keep option)', () => {
render(<Icon value="totally-made-up-icon" onChange={() => {}} schema={{ type: 'string' }} />);
const trigger = screen.getByRole('combobox');
Expand Down
Loading
Loading