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
61 changes: 61 additions & 0 deletions .changeset/lucide-react-1-43-0-retired-trash2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@object-ui/app-shell': patch
'@object-ui/cli': patch
'@object-ui/components': patch
'@object-ui/console': patch
'@object-ui/fields': patch
'@object-ui/layout': patch
'@object-ui/plugin-ai': patch
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-charts': patch
'@object-ui/plugin-chatbot': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-designer': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-form': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-kanban': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-report': patch
'@object-ui/plugin-tree': patch
'@object-ui/plugin-view': patch
'@object-ui/runner': patch
'@object-ui/types': patch
---

Move `lucide-react` from `^1.31.0` to `^1.43.0` in every package that declares it, and
repair what the jump breaks, so icons resolved from a STRING keep drawing a glyph.

Measured once against the installed 1.43.0 artifact when this change was made; nothing in
the repository re-derives these readings. Across the jump lucide removes no runtime export,
no public type name and no `lucide-react/dynamic.mjs` name, and every name this repository
imports from `lucide-react` resolves. Exactly one key leaves the runtime `icons` record:
`Trash2`, retired in favour of `trash`.

What a user sees change:

- Icons the lazy icon seam draws (`resolveIcon`, objectui#9251) get their path data again.
lucide 1.43.0 icon modules export their path data inside `__iconData` and no longer
export `__iconNode`; the seam now reads both. Reading only `__iconNode` against 1.43.0
leaves every such glyph an empty box, with nothing thrown or logged.
- `DetailView`'s delete action and three schema-catalog examples spell their icon
`trash`, not `trash-2`, so they keep resolving. The glyph is unchanged: 1.43.0's `trash`
path data is byte-identical to the `trash-2` path data of 1.31.0 and 1.35.0. Anything
that already authored `trash` now draws that same artwork, because lucide moved it under
the `trash` name; a few other glyphs were also redrawn upstream.
- Icons imported as COMPONENTS carry lucide 1.43.0's own classes: one class per declared
alias (a spinner renders `class="lucide lucide-loader-circle lucide-loader-2 ..."`), and
no longer the class lucide used to derive from the PascalCase key where that differs
(`ArrowDown01` no longer carries `lucide-arrow-down01`). The canonical
`lucide-<icon name>` class is still there, so a `.lucide-loader-circle` selector still
matches.
- Icons resolved from a STRING through the seam keep the classes they had: `lucide`, the
canonical `lucide-<icon name>` class and the key-derived class. They do not carry
lucide's per-alias classes. That is a maintainer ruling (objectui#8941, option C): the
alias list lives only inside each lazily loaded icon module, and it was not moved into
the eager name list. A selector that targets an alias class matches a component-imported
icon and not a string-resolved one. This also dates the example in the objectui#9251
entry: `trash-2` no longer resolves as a string, so no seam-drawn glyph carries
`lucide-trash2 lucide-trash-2`; a digit-bearing name that still resolves, such as
`arrow-down-0-1`, carries `lucide-arrow-down01 lucide-arrow-down-0-1`.
2 changes: 1 addition & 1 deletion apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"@vitest/ui": "^4.1.10",
"autoprefixer": "^10.5.4",
"happy-dom": "^20.11.2",
"lucide-react": "^1.31.0",
"lucide-react": "^1.43.0",
"postcss": "^8.5.26",
"react": "19.2.8",
"react-dom": "19.2.8",
Expand Down
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"fumadocs-core": "16.15.4",
"fumadocs-mdx": "15.2.3",
"fumadocs-ui": "16.15.4",
"lucide-react": "^1.31.0",
"lucide-react": "^1.43.0",
"next": "16.3.3",
"react": "19.2.8",
"react-dom": "19.2.8",
Expand Down
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ A single-record detail view with grouped fields, actions, and tabs.
],
"actions": [
{ "type": "action", "label": "Edit", "icon": "Pencil", "level": "primary" },
{ "type": "action", "label": "Delete", "icon": "Trash2", "level": "danger", "actionType": "confirm" }
{ "type": "action", "label": "Delete", "icon": "trash", "level": "danger", "actionType": "confirm" }
],
"tabs": [
{
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/schema-playground.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ A data grid with sortable columns and row actions:
],
"actions": [
{ "label": "Edit", "icon": "pencil", "action": "edit" },
{ "label": "Delete", "icon": "trash-2", "action": "delete", "variant": "destructive" }
{ "label": "Delete", "icon": "trash", "action": "delete", "variant": "destructive" }
],
"pagination": {
"pageSize": 10,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"type": "button",
"label": "Delete",
"variant": "destructive",
"icon": "trash-2"
"icon": "trash"
},
{
"type": "button",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"type": "button",
"label": "Yes, Delete",
"variant": "destructive",
"icon": "trash-2"
"icon": "trash"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"label": "Delete",
"variant": "destructive",
"size": "sm",
"icon": "trash-2"
"icon": "trash"
}
]
}
Expand Down
14 changes: 12 additions & 2 deletions examples/schema-catalog/test/svg-host-dom-leak-5632.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,13 +314,23 @@ describe('schema-catalog — no SVG-hosted renderer leaks an authored prop to th
// `SpinnerSchema.size` is an ENUM consumed through `sizeClasses`. Spreading
// it handed the string to lucide's numeric `size` prop, which is how
// `width="lg" height="lg"` reached every sized spinner in the catalog.
// `class` carries lucide's own two classes AND both of this renderer's —
// `class` carries lucide's own classes AND both of this renderer's —
// `animate-spin` and the size class. Before this slice it carried ONLY
// lucide's: the spread's `className` overrode the computed one, so a
// `ui:spinner` rendered through `SchemaRenderer` did not spin.
//
// `lucide-loader-2` is lucide's own RETIRED-ALIAS class, not a leak. It
// appeared when `createLucideIcon` was reshaped to take an icon-data object
// carrying `aliases` (lucide-react 1.43.0; absent in 1.31.0, where icon
// modules have no `aliases` field at all) and it now emits one class per
// alias. Measured on the installed artifact: 248 of 1818 icon modules carry
// aliases, so any icon with one gains a class here. The assertion stays
// EXACT on purpose — this list is what catches an authored prop reaching
// the DOM, which is the whole point of objectui#5632; loosening it to a
// substring match would retire the check rather than update it.
expect(attributesOf({ type: 'spinner', size: 'lg' })).toEqual([
'aria-hidden="true"',
'class="lucide lucide-loader-circle animate-spin h-8 w-8"',
'class="lucide lucide-loader-circle lucide-loader-2 animate-spin h-8 w-8"',
'data-obj-type="spinner"',
'fill="none"',
'height="24"',
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"@objectstack/spec": "^17.3.0",
"@sentry/react": "^10.70.0",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.31.0",
"lucide-react": "^1.43.0",
"qrcode": "^1.5.4",
"sonner": "^2.0.8",
"zod": "^4.4.3"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/app-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function buildRoutedAppDependencies(): Record<string, string> {
react: REACT_RANGE,
'react-dom': REACT_RANGE,
'react-router-dom': '^7.18.2',
'lucide-react': '^1.31.0',
'lucide-react': '^1.43.0',
...Object.fromEntries(PLATFORM_RUNTIME_PACKAGES.map((name) => [name, range]))
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"date-fns": "^4.4.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^1.31.0",
"lucide-react": "^1.43.0",
"next-themes": "^0.4.6",
"react-day-picker": "^10.0.1",
"react-hook-form": "^7.85.0",
Expand Down
7 changes: 4 additions & 3 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
Expand Up @@ -90,14 +90,27 @@ describe('objectui#9251 — the seam draws lazily without moving what renders',
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.
it('carries lucide\'s CANONICAL per-icon class over the WHOLE record vocabulary', () => {
// ⚠️ RELAXED under the maintainer's ruling C on objectui#8941 (comment
// 5750512894, verbatim 「8941 C」). This row USED TO REFUSE any seam glyph
// whose complete class set differed from lucide's own component's. It NO
// LONGER DOES, because lucide-react 1.43.0 emits one class per DECLARED
// ALIAS (`house` renders `lucide-house lucide-home`) and no longer emits the
// class it used to derive from the key, and the alias list exists only
// inside each icon module — the modules objectui#9251 made lazy — while the
// seam has to put its classes on the FIRST frame. The ruling chose the bump
// over carrying every alias in the eager static name list (option A), and
// it was taken knowing that it weakens this row. So a seam glyph missing
// lucide's alias classes, or carrying the key-derived class lucide no longer
// emits, now passes here.
//
// What it still refuses: a seam glyph without `lucide` or without lucide's
// CANONICAL class, `lucide-<icon name>`, which lucide emits first after the
// bare `lucide`. That is the class a conversion rule gets wrong silently,
// because many keys pack digits that lucide splits in the module name
// (`ArrowDown01` is `lucide-arrow-down-0-1`, never `lucide-arrow-down01`).
// Every name is still compared against the record's own component — not a
// sample, and not a copy of the rule.
const keys = Object.keys(icons);
expect(keys.length).toBeGreaterThan(1000);
const mismatches: string[] = [];
Expand All @@ -106,9 +119,13 @@ describe('objectui#9251 — the seam draws lazily without moving what renders',
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}"`);
// Read in lucide's own order: `lucide`, then the canonical class, then
// one class per alias. Only the first two are required of the seam.
const [base, canonical] = (reference?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
const got = classesOf(seam);
if (base !== 'lucide' || !canonical?.startsWith('lucide-') || !got.includes(base) || !got.includes(canonical)) {
mismatches.push(`${key}: expected "${base} ${canonical}" among "${got.join(' ')}"`);
}
}
expect(mismatches.slice(0, 10)).toEqual([]);
// Non-vacuity, both ways: the loop ran, and it ran over the class of name
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/renderers/action/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
* location: 'record_header',
* actions: [
* { name: 'mark_complete', label: 'Mark Complete', type: 'script', icon: 'check', component: 'action:button' },
* { name: 'delete', label: 'Delete', type: 'api', icon: 'trash-2', variant: 'destructive', component: 'action:button' },
* { name: 'delete', label: 'Delete', type: 'api', icon: 'trash', variant: 'destructive', component: 'action:button' },
* ],
* }} />
* ```
Expand Down
59 changes: 42 additions & 17 deletions packages/components/src/renderers/action/resolve-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,25 +180,33 @@ function recordIconName(key: string): string | undefined {
}

/**
* 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.
* The per-icon `lucide-*` class names, rebuilt here because lucide derives them
* from the icon's own module, which this seam does not have on the first frame.
* `Icon` — the component every lucide glyph ultimately renders, and the one
* used below — contributes only the bare `lucide` class when it is handed a
* bare `iconNode`.
*
* ⚠️ 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.
* Two spellings are emitted: `lucide-${kebab}`, lucide's CANONICAL class for
* the icon, and `lucide-${toKebabCase(toPascalCase(kebab))}`, the class lucide
* derived from the PascalCase key up to 1.35.0 — one class for most icons and
* two for the keys that pack digits or a leading initial (`ArrowDown01` also
* gives `lucide-arrow-down01`). The PascalCase key is already in hand here, so
* only the kebab-casing of it is re-implemented.
*
* ⚠️ That is NOT the set lucide-react 1.43.0 puts on its own component, which
* no longer carries the key-derived class and carries one class per DECLARED
* ALIAS instead (`house` also renders `lucide-home`). The alias list
* exists only inside each lazily loaded icon module, and the maintainer's
* ruling C on objectui#8941 (comment 5750512894, 「8941 C」) accepted that the
* seam reproduces the canonical class and not the aliases, rather than carry
* every alias in the eager static name list. `resolve-icon-lazy-9251.test.tsx`
* pins exactly that much — `lucide` plus the canonical class — against the
* record's own components over the WHOLE vocabulary.
*/
function lucideClassNames(recordKey: string, kebab: string, className?: string): string {
const fromKey = `lucide-${recordKey.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}`;
Expand All @@ -208,8 +216,18 @@ function lucideClassNames(recordKey: string, kebab: string, className?: string):
.trim();
}

/** lucide's own path-data shape, taken from the component that consumes it. */
type IconNode = React.ComponentProps<typeof Icon>['iconNode'];
/**
* lucide's own path-data shape, taken from the component that consumes it.
*
* ⛔ `NonNullable` is load-bearing, not decoration. lucide 1.43.0 split `Icon`'s
* props into a UNION — one arm takes `iconNode` and forbids `icon`, the other
* takes `icon` and forbids `iconNode` — and indexing a union yields the union
* of the members, so this alias silently picked up the second arm's
* `iconNode?: undefined`. That widened {@link EMPTY_ICON_NODE} to include
* `undefined`, which is the one value NEITHER arm accepts, so the placeholder
* written to guarantee a box stopped type-checking as a box.
*/
type IconNode = NonNullable<React.ComponentProps<typeof Icon>['iconNode']>;

/**
* What the glyph holds before its module arrives: the same `<svg>` lucide
Expand Down Expand Up @@ -258,8 +276,15 @@ function lazyIconComponent(recordKey: string, kebab: string): LucideIcon {
// better answer than a thrown render.
if (!load) return undefined;
void load()
.then((module: { __iconNode?: IconNode }) => {
const node = module.__iconNode;
.then((module: { __iconNode?: IconNode; __iconData?: { node?: IconNode } }) => {
// lucide moved the path data between these two exports: an icon
// module used to expose `__iconNode` directly and now wraps it in
// `__iconData` alongside `name`, `size` and `aliases`. Both are read
// because the failure mode of reading only one is invisible — the
// placeholder `<svg>` below is already mounted with the right classes
// and the right box, so a glyph that never arrives looks exactly like
// one that has not arrived YET. Nothing throws and nothing logs.
const node = module.__iconData?.node ?? module.__iconNode;
if (!node) return;
loadedIconNodes.set(kebab, node);
if (live) setIconNode(node);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,23 @@ describe('ui:button icon resolution (objectui#5993)', () => {
// would have changed had the dedupe dropped the alias on the floor.
const button = renderButton({ icon: 'home' });
expect(button.querySelector('svg.lucide-house')).not.toBeNull();
expect(button.querySelector('svg.lucide-home')).toBeNull();
// ⚠️ RELAXED under the maintainer's ruling C on objectui#8941 (comment
// 5750512894, verbatim 「8941 C」). This row USED TO REFUSE a house glyph
// that did not also carry lucide's `lucide-home` ALIAS class on the same
// element: `querySelector('svg.lucide-home')` had to BE the house svg. It
// NO LONGER DOES, because this button draws through the objectui#9251
// seam, which emits lucide's canonical class and not lucide-react
// 1.43.0's per-alias classes, and the ruling accepted that rather than
// put every icon's aliases into the eager static name list.
//
// ⛔ Nor does it go back to the older form, `svg.lucide-home` is null:
// lucide 1.43.0 itself puts `lucide-home` on the house glyph, so that
// class no longer says which glyph was drawn, and pinning its absence
// would pin the seam's divergence from lucide. `lucide-home` is neither
// required nor forbidden here. What still discriminates is the count:
// `Home` is not a key of the runtime record, so had the rename been
// dropped this button would render NO glyph at all.
expect(button.querySelectorAll('svg')).toHaveLength(1);
});

it('renders NO glyph for a retired spelling — the RECORD surface, not a fallback', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/fields/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"lucide-react": "^1.31.0",
"lucide-react": "^1.43.0",
"react-markdown": "^10.1.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
Expand Down
2 changes: 1 addition & 1 deletion packages/layout/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"lucide-react": "^1.31.0"
"lucide-react": "^1.43.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@object-ui/core": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"lucide-react": "^1.31.0"
"lucide-react": "^1.43.0"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-calendar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@object-ui/plugin-detail": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"lucide-react": "^1.31.0"
"lucide-react": "^1.43.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-charts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@object-ui/i18n": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"lucide-react": "^1.31.0",
"lucide-react": "^1.43.0",
"recharts": "^3.10.1"
},
"peerDependencies": {
Expand Down
Loading
Loading