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
2 changes: 2 additions & 0 deletions docs/component-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ A single-step widening cast like `value as unknown` or `[] as unknown[]` (inside

Stock Lucide icons imported from `lucide-react` and custom SVG icons from `@/icons` (`src/libs/icons/icons.tsx`) should **always** use real implementations in tests—do not `vi.mock('lucide-react')` or `vi.mock('@/icons')` to stub icons. This ensures snapshots capture actual SVG output and visual regression tests detect icon changes.

`DynamicLucideIcon` is also real, but resolves its icon chunk **asynchronously**: a first render shows an empty size-preserving svg (`<svg class="lucide">` with no children), and the resolved paths appear after the dynamic import settles. Before asserting on paths or matching a snapshot, either await resolution (`waitFor` on `svg.childElementCount > 0` — avoid `querySelector('svg *')`, jsdom's selector engine misses svg descendants) or warm the icon with `await loadLucideIconNode(name)` from `@/libs/utils/lucideIcons`. Note the icon cache is **module-level and persists across tests within a file** — a loading-state assertion needs an icon name no earlier test in the file has loaded.

Application import conventions (where to import icons, URL helpers, and what not to do) are documented in **`docs/components.md`** — _Icons (Lucide and custom)_.

### Radix UI Components: Always Real
Expand Down
12 changes: 11 additions & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export { Home as default } from '@/templates/Feed/Home/Home';

## Icons (Lucide and custom)

Icons are split on purpose: **stock Lucide** ships from the `lucide-react` package; **app-owned SVGs** (brands, bespoke marks, non-Lucide shapes) live in a single module behind the **`@/icons`** path alias (`src/libs/icons/icons.tsx`).
Icons are split on purpose: **stock Lucide** ships from the `lucide-react` package; **app-owned SVGs** (brands, bespoke marks, non-Lucide shapes) live in a single module behind the **`@/icons`** path alias (`src/libs/icons/icons.tsx`); **data-driven Lucide icons** (an icon _name_ stored on a record, e.g. a custom feed's icon) render through the `DynamicLucideIcon` atom.

### Stock Lucide icons

Expand All @@ -154,6 +154,16 @@ import { ChevronDown, Plus, Trash2 } from 'lucide-react';

Use named imports from `lucide-react` only.

### Data-driven Lucide icons (`DynamicLucideIcon`)

When the icon is chosen at runtime from data (a kebab-case Lucide name like `"folder-heart"` stored on a feed), a static named import is impossible. Render it with the **`DynamicLucideIcon`** atom (`@/atoms/DynamicLucideIcon/DynamicLucideIcon`):

```tsx
<DynamicLucideIcon name={feed.icon} className="size-5" />
```

Icon chunks load lazily via `lucide-react/dynamic.js` through a module-level cache in **`@/libs/utils/lucideIcons`** (`isLucideIconName`, `loadLucideIconNode`, `preloadLucideIcons`). Once an icon has resolved anywhere in the session it renders synchronously on first paint. While a valid name is still loading, the atom renders an empty size-preserving svg — never a wrong icon; the `fallback` prop (default `Activity`) applies only to names that are not Lucide icons at all. Call `preloadLucideIcons(names)` when the icon names become known (e.g. when feed data lands) so mounts hit the cache. Never use this path for static UI icons — those stay named imports.

### Custom / brand icons

```tsx
Expand Down
12 changes: 12 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -733,11 +733,23 @@
"urlPlaceholder": "https://twitter.com/satoshi",
"saveButton": "Save link"
},
"iconPicker": {
"title": "Choose icon",
"searchPlaceholder": "Search for icon",
"emptyMessage": "No icons found",
"clearSearch": "Clear search"
},
"customFeed": {
"createTitle": "Create Feed",
"editTitle": "Edit Feed",
"feedName": "Feed Name",
"feedNamePlaceholder": "Not your keys...",
"feedIcon": "Feed Icon",
"chooseIcon": "Select icon",
"feedIconCreateDescription": "Choose a custom icon for your new feed.",
"feedIconEditDescription": "Choose a custom icon for your feed.",
"editFeedLabel": "Edit {name}",
"moreFeeds": "More feeds",
"reach": "Reach",
"reachPlaceholder": "Select a reach",
"sort": "Sort",
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"next": "16.2.6",
"next-intl": "4.12.0",
"next-themes": "0.4.6",
"pubky-app-specs": "0.6.2",
"pubky-app-specs": "0.7.0",
"qrcode.react": "4.2.0",
"radix-ui": "1.4.3",
"react": "19.2.6",
Expand Down
69 changes: 69 additions & 0 deletions src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { render, screen, waitFor } from '@testing-library/react';
import { Library } from 'lucide-react';
import { describe, expect, it } from 'vitest';
import { loadLucideIconNode } from '@/libs/utils/lucideIcons';
import { DynamicLucideIcon } from './DynamicLucideIcon';

// The icon cache is module-level and persists across tests in this file, so
// every loading-state assertion uses an icon name no other test has loaded.
describe('DynamicLucideIcon', () => {
it('renders a valid dynamic icon once its chunk resolves', async () => {
render(<DynamicLucideIcon name="mountain" data-testid="dynamic-icon" />);

await waitFor(() => expect(screen.getByTestId('dynamic-icon').querySelector('path')).not.toBeNull());
});

it('never shows the fallback while a valid icon is loading', async () => {
render(<DynamicLucideIcon name="anchor" data-testid="loading-icon" className="size-5" />);

const svg = screen.getByTestId('loading-icon');
expect(svg).toHaveClass('lucide');
expect(svg).toHaveClass('size-5');
expect(svg).not.toHaveClass('lucide-activity');
expect(svg.childElementCount).toBe(0);

await waitFor(() => expect(svg.querySelector('path')).not.toBeNull());
});

it('renders a cached icon synchronously on first paint', async () => {
await loadLucideIconNode('library');

render(<DynamicLucideIcon name="library" data-testid="cached-icon" />);

expect(screen.getByTestId('cached-icon').querySelector('path')).not.toBeNull();
});

it('renders the default fallback for a missing icon', () => {
render(<DynamicLucideIcon data-testid="fallback-icon" />);

expect(screen.getByTestId('fallback-icon')).toHaveClass('lucide-activity');
});

it('renders a consumer-provided fallback for an invalid icon', () => {
render(
<DynamicLucideIcon
name="not-a-real-lucide-icon"
fallback={Library}
data-testid="fallback-icon"
className="size-6"
/>,
);

expect(screen.getByTestId('fallback-icon')).toHaveClass('lucide-library');
expect(screen.getByTestId('fallback-icon')).toHaveClass('size-6');
});

it('can omit the fallback while a consumer handles its own loading state', () => {
const { container } = render(<DynamicLucideIcon name="not-a-real-lucide-icon" fallback={null} />);

expect(container.firstChild).toBeNull();
});
});

describe('DynamicLucideIcon - Snapshots', () => {
it('matches snapshot for a consumer-provided fallback', () => {
const { container } = render(<DynamicLucideIcon name={null} fallback={Library} className="size-6" />);

expect(container.firstChild).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`DynamicLucideIcon - Snapshots > matches snapshot for a consumer-provided fallback 1`] = `
<svg
aria-hidden="true"
class="lucide lucide-library size-6"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m16 6 4 14"
/>
<path
d="M12 6v14"
/>
<path
d="M8 8v12"
/>
<path
d="M4 4v16"
/>
</svg>
`;
70 changes: 70 additions & 0 deletions src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import { useEffect, useState } from 'react';
import { Activity, Icon, type IconNode, type LucideIcon, type LucideProps } from 'lucide-react';
import type { IconName } from 'lucide-react/dynamic.js';
import { getLoadedLucideIconNode, isLucideIconName, loadLucideIconNode } from '@/libs/utils/lucideIcons';

const EMPTY_ICON_NODE: IconNode = [];

export interface DynamicLucideIconProps extends Omit<LucideProps, 'name'> {
name?: string | null;
/** Rendered only for a missing/invalid name — never while a valid icon is loading. */
fallback?: LucideIcon | null;
}

interface ResolvedIcon {
name: IconName | null;
node: IconNode | null;
/** The chunk load failed; render the fallback and retry only on remount. */
failed?: boolean;
}

function resolveFromCache(name: IconName | null): ResolvedIcon {
return { name, node: name ? (getLoadedLucideIconNode(name) ?? null) : null };
}

/**
* Renders a Lucide icon by its dynamic (kebab-case) name without bundling the
* full icon set. Icon chunks resolve through a module-level cache, so an icon
* renders synchronously on first paint once it has loaded anywhere in the
* session. While a valid icon is genuinely loading it renders an empty,
* size-preserving svg — never a wrong icon.
*/
export function DynamicLucideIcon({ name, fallback, ...iconProps }: DynamicLucideIconProps) {
const FallbackIcon = fallback === undefined ? Activity : fallback;
const validName = isLucideIconName(name) ? name : null;
const [resolved, setResolved] = useState<ResolvedIcon>(() => resolveFromCache(validName));

// Adjust state during render when the requested icon changes, so a cached
// icon swaps in synchronously instead of after an effect roundtrip.
if (resolved.name !== validName) {
setResolved(resolveFromCache(validName));
}

useEffect(() => {
if (!validName || (resolved.name === validName && (resolved.node || resolved.failed))) return;
let cancelled = false;
void loadLucideIconNode(validName).then((node) => {
if (cancelled) return;
setResolved((current) => {
if (current.name !== validName) return current;
if (node) return current.node === node ? current : { name: validName, node };
return current.failed ? current : { name: validName, node: null, failed: true };
});
});
return () => {
cancelled = true;
};
}, [validName, resolved]);

if (!validName) {
return FallbackIcon ? <FallbackIcon {...iconProps} /> : null;
}

if (resolved.failed && !resolved.node) {
return FallbackIcon ? <FallbackIcon {...iconProps} /> : null;
}

return <Icon iconNode={resolved.node ?? EMPTY_ICON_NODE} {...iconProps} />;
}
Loading
Loading