@alfakit/react 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/components/cms/blocks.d.ts.map +1 -1
  3. package/dist/components/cms/blocks.js +2 -2
  4. package/dist/components/cms/blocks.js.map +1 -1
  5. package/dist/components/cms/head.d.ts +6 -4
  6. package/dist/components/cms/head.d.ts.map +1 -1
  7. package/dist/components/cms/head.js +10 -4
  8. package/dist/components/cms/head.js.map +1 -1
  9. package/dist/components/cms/page.d.ts.map +1 -1
  10. package/dist/components/cms/page.js +16 -3
  11. package/dist/components/cms/page.js.map +1 -1
  12. package/dist/components/fields/file-upload.js +4 -4
  13. package/dist/components/fields/file-upload.js.map +1 -1
  14. package/dist/components/fields/rich-editor.js +4 -2
  15. package/dist/components/fields/rich-editor.js.map +1 -1
  16. package/dist/components/fields/slider.js +1 -1
  17. package/dist/components/fields/slider.js.map +1 -1
  18. package/dist/components/helpdesk/chat-widget.js +1 -1
  19. package/dist/components/layout/section.js +1 -1
  20. package/dist/components/layout/tabs.js +1 -1
  21. package/dist/components/panel/app-sidebar.d.ts.map +1 -1
  22. package/dist/components/panel/app-sidebar.js +27 -5
  23. package/dist/components/panel/app-sidebar.js.map +1 -1
  24. package/dist/components/panel/global-search.js +2 -2
  25. package/dist/components/panel/global-search.js.map +1 -1
  26. package/dist/components/panel/shortcut-keys.js +1 -1
  27. package/dist/components/panel/shortcut-keys.js.map +1 -1
  28. package/dist/components/table/list-tabs.js +1 -1
  29. package/dist/components/table/list-tabs.js.map +1 -1
  30. package/dist/components/table/table-views.js +1 -1
  31. package/dist/components/table/table-views.js.map +1 -1
  32. package/dist/components/versions/record-versions.js +1 -1
  33. package/dist/context.d.ts +8 -0
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/context.js +27 -2
  36. package/dist/context.js.map +1 -1
  37. package/dist/create-alfakit.d.ts.map +1 -1
  38. package/dist/create-alfakit.js +6 -3
  39. package/dist/create-alfakit.js.map +1 -1
  40. package/dist/eject-manifest.json +2 -2
  41. package/dist/index.js +1 -1
  42. package/dist/layouts/app-layout.d.ts.map +1 -1
  43. package/dist/layouts/app-layout.js +13 -3
  44. package/dist/layouts/app-layout.js.map +1 -1
  45. package/dist/lib/discovery.d.ts +4 -0
  46. package/dist/lib/discovery.d.ts.map +1 -1
  47. package/dist/lib/discovery.js +17 -7
  48. package/dist/lib/discovery.js.map +1 -1
  49. package/dist/lib/icons.d.ts +2 -1
  50. package/dist/lib/icons.d.ts.map +1 -1
  51. package/dist/lib/icons.js +55 -19
  52. package/dist/lib/icons.js.map +1 -1
  53. package/dist/pages/page.d.ts +1 -1
  54. package/dist/pages/page.d.ts.map +1 -1
  55. package/dist/pages/page.js +47 -10
  56. package/dist/pages/page.js.map +1 -1
  57. package/dist/standalone/alfakit-component.js +2 -1
  58. package/dist/standalone/alfakit-component.js.map +1 -1
  59. package/dist/standalone/alfakit-toaster.js +2 -1
  60. package/dist/standalone/alfakit-toaster.js.map +1 -1
  61. package/dist/standalone/runtime.js +1 -11
  62. package/dist/standalone/runtime.js.map +1 -1
  63. package/dist/standalone/toaster-claims.js +16 -0
  64. package/dist/standalone/toaster-claims.js.map +1 -0
  65. package/dist/starter-kit/widgets-loaded.js +63 -6
  66. package/dist/starter-kit/widgets-loaded.js.map +1 -1
  67. package/dist/starter-kit/widgets-triggers.js +1 -1
  68. package/dist/starter-kit/widgets-triggers.js.map +1 -1
  69. package/dist/themes.css +92 -0
  70. package/package.json +2 -2
  71. package/src/components/cms/blocks.tsx +7 -1
  72. package/src/components/cms/head.tsx +19 -4
  73. package/src/components/cms/page.tsx +17 -2
  74. package/src/components/fields/file-upload.tsx +5 -4
  75. package/src/components/fields/rich-editor.tsx +7 -2
  76. package/src/components/fields/slider.tsx +3 -1
  77. package/src/components/panel/app-sidebar.tsx +28 -7
  78. package/src/components/panel/global-search.tsx +4 -1
  79. package/src/components/panel/shortcut-keys.tsx +1 -1
  80. package/src/components/table/list-tabs.tsx +3 -1
  81. package/src/components/table/table-views.tsx +3 -1
  82. package/src/context.tsx +46 -1
  83. package/src/create-alfakit.tsx +13 -2
  84. package/src/layouts/app-layout.tsx +16 -3
  85. package/src/lib/discovery.tsx +23 -8
  86. package/src/lib/icons.tsx +90 -24
  87. package/src/pages/page.tsx +85 -27
  88. package/src/standalone/runtime.ts +2 -14
  89. package/src/standalone/toaster-claims.ts +16 -0
  90. package/src/starter-kit/widgets-loaded.tsx +84 -1
  91. package/src/starter-kit/widgets-triggers.tsx +4 -1
@@ -101,7 +101,9 @@ export function SliderField({ component }: { component: SliderSchema }) {
101
101
  }
102
102
 
103
103
  return (
104
- <FieldWrapper field={component} htmlFor={`${id}-thumb-0`} error={error}>
104
+ // The wrapper's label, hint and error ids derive from `id`, as `aria` does: a thumb is not
105
+ // labelable, so one thumb names itself through `aria-labelledby`.
106
+ <FieldWrapper field={component} htmlFor={id} error={error}>
105
107
  <div
106
108
  data-slot="slider"
107
109
  className={cn(
@@ -8,7 +8,7 @@ import {
8
8
  } from '@alfakit/core'
9
9
  import { Link } from '@inertiajs/react'
10
10
  import { ChevronRightIcon } from 'lucide-react'
11
- import type { ComponentProps, ReactNode } from 'react'
11
+ import { useState, type ComponentProps, type ReactNode } from 'react'
12
12
  import { useDirection, usePanel, useT } from '../../context'
13
13
  import { extraProps } from '../../lib/attributes'
14
14
  import { badgeColorClasses, colorClass } from '../../lib/colors'
@@ -114,12 +114,32 @@ function NavLink({
114
114
  )
115
115
  }
116
116
 
117
+ /**
118
+ * Open state of a collapsible menu entry: `defaultOpen` at first, and opened again when the
119
+ * entry becomes active. The sidebar stays mounted across visits, so it keeps what the user
120
+ * opened or closed, while a page reached from elsewhere still shows its menu entry.
121
+ */
122
+ function useOpenWhenActive(defaultOpen: boolean, active: boolean) {
123
+ const [open, setOpen] = useState(defaultOpen)
124
+ const [wasActive, setWasActive] = useState(active)
125
+
126
+ if (active !== wasActive) {
127
+ setWasActive(active)
128
+
129
+ if (active) setOpen(true)
130
+ }
131
+
132
+ return { open, onOpenChange: setOpen }
133
+ }
134
+
117
135
  /** Child items (any depth) with their own badges, new-tab links and nested children. */
118
136
  function NavSubItem({ item }: { item: NavigationItemSchema }) {
137
+ const collapsible = useOpenWhenActive(!!item.active, !!item.active)
138
+
119
139
  if (item.children && item.children.length > 0) {
120
140
  return (
121
141
  <SidebarMenuSubItem>
122
- <Collapsible defaultOpen={item.active} className="group/nav-sub">
142
+ <Collapsible {...collapsible} className="group/nav-sub">
123
143
  <CollapsibleTrigger asChild>
124
144
  <SidebarMenuSubButton asChild isActive={item.active}>
125
145
  <button type="button" {...extraProps(item.extraAttributes, 'w-full')}>
@@ -156,9 +176,11 @@ function NavSubItem({ item }: { item: NavigationItemSchema }) {
156
176
  }
157
177
 
158
178
  function NavItem({ item }: { item: NavigationItemSchema }) {
179
+ const collapsible = useOpenWhenActive(!!item.active, !!item.active)
180
+
159
181
  if (item.children && item.children.length > 0) {
160
182
  return (
161
- <Collapsible asChild defaultOpen={item.active} className="group/collapsible">
183
+ <Collapsible asChild {...collapsible} className="group/collapsible">
162
184
  <SidebarMenuItem>
163
185
  <CollapsibleTrigger asChild>
164
186
  <SidebarMenuButton
@@ -259,6 +281,8 @@ function NavGroup({ group }: { group: NavigationGroupSchema }) {
259
281
  const attributes = extraProps(group.extraSidebarAttributes)
260
282
  // A labelled group with an icon collapses into a flyout in the icon-only sidebar.
261
283
  const flyout = Boolean(group.label && group.icon)
284
+ const active = group.items.some((item) => item.active)
285
+ const collapsible = useOpenWhenActive(!group.collapsed || active, active)
262
286
  const content = (
263
287
  <SidebarGroupContent className={cn(flyout && 'group-data-[collapsible=icon]:hidden')}>
264
288
  <SidebarMenu>
@@ -288,10 +312,7 @@ function NavGroup({ group }: { group: NavigationGroupSchema }) {
288
312
  }
289
313
 
290
314
  return (
291
- <Collapsible
292
- defaultOpen={!group.collapsed || group.items.some((item) => item.active)}
293
- className="group/nav-group"
294
- >
315
+ <Collapsible {...collapsible} className="group/nav-group">
295
316
  <SidebarGroup {...attributes}>
296
317
  <CollapsibleTrigger asChild>
297
318
  <SidebarGroupLabel
@@ -133,7 +133,10 @@ export function GlobalSearch({ variant = 'topbar' }: { variant?: 'topbar' | 'sid
133
133
  </span>
134
134
  )}
135
135
  {hydrated && suffix?.type === 'keyBinding' && (
136
- <kbd dir="ltr" className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
136
+ <kbd
137
+ dir="ltr"
138
+ className="bg-muted text-foreground/70 hidden rounded px-1 font-mono text-[10px] sm:inline"
139
+ >
137
140
  {formatKeyBinding(suffix.keyBinding)}
138
141
  </kbd>
139
142
  )}
@@ -26,7 +26,7 @@ export function ShortcutKeys({ binding, className }: { binding: string; classNam
26
26
  )}
27
27
  <kbd
28
28
  dir="ltr"
29
- className="bg-muted text-muted-foreground rounded border px-1.5 font-mono text-[10px] leading-5"
29
+ className="bg-muted text-foreground/70 rounded border px-1.5 font-mono text-[10px] leading-5"
30
30
  >
31
31
  {step}
32
32
  </kbd>
@@ -69,7 +69,9 @@ export function ListTabs({
69
69
  cn(
70
70
  'inline-flex h-[calc(100%-1px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none',
71
71
  'focus-visible:border-ring focus-visible:ring-ring/50',
72
- active ? 'bg-background text-foreground shadow-sm' : 'hover:text-foreground',
72
+ active
73
+ ? 'bg-background text-foreground shadow-sm'
74
+ : 'text-foreground/70 hover:text-foreground',
73
75
  ),
74
76
  )}
75
77
  ref={(element) => {
@@ -240,7 +240,9 @@ export function TableViews({
240
240
  className={cn(
241
241
  'inline-flex h-[calc(100%-1px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none',
242
242
  'focus-visible:border-ring focus-visible:ring-ring/50',
243
- selected ? 'bg-background text-foreground shadow-sm' : 'hover:text-foreground',
243
+ selected
244
+ ? 'bg-background text-foreground shadow-sm'
245
+ : 'text-foreground/70 hover:text-foreground',
244
246
  )}
245
247
  onClick={() => !selected && visitTableView(table, item?.key ?? null)}
246
248
  onKeyDown={(event) => onTabKeyDown(event, index)}
package/src/context.tsx CHANGED
@@ -12,7 +12,15 @@ import {
12
12
  type Translator,
13
13
  } from '@alfakit/core'
14
14
  import { usePage } from '@inertiajs/react'
15
- import { createContext, useContext, useMemo, type ComponentType, type ReactNode } from 'react'
15
+ import {
16
+ createContext,
17
+ useContext,
18
+ useEffect,
19
+ useMemo,
20
+ useRef,
21
+ type ComponentType,
22
+ type ReactNode,
23
+ } from 'react'
16
24
 
17
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
26
  export type AnyComponent = ComponentType<any>
@@ -33,6 +41,14 @@ export interface AlfakitContextValue {
33
41
  * change). Panels leave it out (a partial reload); components outside panels fetch theirs.
34
42
  */
35
43
  refresh?: (targets: string[]) => Promise<void>
44
+ /**
45
+ * Whether every page is rendered only after the split built-ins and discovered components
46
+ * it names have loaded, on the server and before hydration (`createAlfakit()` resolves
47
+ * pages that way). Without it (starter kits, standalone components) those components show
48
+ * after hydration: a long-lived SSR server keeps code earlier requests loaded, a fresh
49
+ * browser has none of it, so rendering them early would make the markups differ.
50
+ */
51
+ preloadsPageComponents?: boolean
36
52
  }
37
53
 
38
54
  const AlfakitContext = createContext<AlfakitContextValue | null>(null)
@@ -52,6 +68,35 @@ function useOptionalPageProps(): AlfakitPageProps {
52
68
  }
53
69
  }
54
70
 
71
+ /**
72
+ * The Inertia page URL, or undefined outside an Inertia app. Persistent chrome (the panel
73
+ * layout stays mounted across visits) closes its phone sheets when it changes.
74
+ */
75
+ export function useOptionalPageUrl(): string | undefined {
76
+ try {
77
+ return usePage().url
78
+ } catch {
79
+ return undefined
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Calls `close` when the user moves to another page. The panel layout persists across
85
+ * visits, so menus and sheets that used to be reset by remounting close here instead.
86
+ * Partial reloads (deferred props, polling) keep the URL and leave them open.
87
+ */
88
+ export function useCloseOnVisit(close: () => void): void {
89
+ const url = useOptionalPageUrl()
90
+ const previous = useRef(url)
91
+
92
+ useEffect(() => {
93
+ if (previous.current === url) return
94
+
95
+ previous.current = url
96
+ close()
97
+ }, [url, close])
98
+ }
99
+
55
100
  /**
56
101
  * Outside panels there is no shared `alfakit` prop and no `page` prop: `<AlfakitComponent>`
57
102
  * provides both from its payload, and the hooks below read them first.
@@ -32,6 +32,7 @@ import { Direction } from 'radix-ui'
32
32
  import {
33
33
  StrictMode,
34
34
  createElement,
35
+ useEffect,
35
36
  useLayoutEffect,
36
37
  type ReactElement,
37
38
  type ReactNode,
@@ -49,8 +50,9 @@ import { AlfakitProvider, useAlfakit, type AlfakitContextValue, type AnyComponen
49
50
  import { panelLayouts } from './layouts/panel-layouts'
50
51
  import { discoverComponents } from './lib/discovery'
51
52
  import { preloadIcons } from './lib/icons'
52
- import { AlfakitPage } from './pages/page'
53
+ import { AlfakitPage, PanelLayoutHost } from './pages/page'
53
54
  import { builtInComponents, createDefaultRegistry } from './registry'
55
+ import { claimToaster } from './standalone/toaster-claims'
54
56
 
55
57
  /**
56
58
  * Rendered inside Inertia's page tree, so it follows the current page: the reading
@@ -91,10 +93,17 @@ export function AlfakitRoot({
91
93
  applyPanelTheme(panel)
92
94
  }, [panel])
93
95
 
96
+ // This root renders the toaster: starter kit header components and standalone components
97
+ // on the same page don't mount a second one.
98
+ useEffect(() => claimToaster(), [])
99
+
94
100
  return (
95
101
  <Direction.Provider dir={direction}>
96
102
  <TooltipProvider delayDuration={0}>
97
- <SchemaContractGuard panel={panel}>{children}</SchemaContractGuard>
103
+ {/* The panel layout stays mounted across visits between Alfakit pages. */}
104
+ <SchemaContractGuard panel={panel}>
105
+ <PanelLayoutHost>{children}</PanelLayoutHost>
106
+ </SchemaContractGuard>
98
107
  <ActionModals />
99
108
  <FlashToasts />
100
109
  <RealtimeNotifications />
@@ -181,6 +190,8 @@ export function bootAlfakit(
181
190
  registry,
182
191
  pages,
183
192
  translator,
193
+ // `resolve` below loads the page's split built-ins and discovered files first.
194
+ preloadsPageComponents: true,
184
195
  // The runner's endpoints are refreshed on every navigation from the shared props.
185
196
  actions: new ActionRunner({
186
197
  endpoints: { action: '', modal: '' },
@@ -6,14 +6,14 @@ import {
6
6
  type BreadcrumbSchema,
7
7
  type Width,
8
8
  } from '@alfakit/core'
9
- import { createElement, type CSSProperties, type ReactNode } from 'react'
9
+ import { createElement, useCallback, type CSSProperties, type ReactNode } from 'react'
10
10
  import { AppSidebar } from '../components/panel/app-sidebar'
11
11
  import { BottomNav } from '../components/panel/bottom-nav'
12
12
  import { TopbarLocaleSwitcher } from '../components/panel/locale-switcher'
13
13
  import { Slot } from '../components/panel/slot'
14
14
  import { Topbar } from '../components/panel/topbar'
15
- import { SidebarInset, SidebarProvider, SidebarTrigger } from '../components/ui/sidebar'
16
- import { useAlfakitProps, useRegistry } from '../context'
15
+ import { SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../components/ui/sidebar'
16
+ import { useAlfakitProps, useCloseOnVisit, useRegistry } from '../context'
17
17
  import { cn } from '../lib/utils'
18
18
  import { MAX_WIDTHS } from './max-widths'
19
19
  import { TopNavLayout } from './top-nav-layout'
@@ -48,6 +48,18 @@ export function MobileTopbar() {
48
48
  )
49
49
  }
50
50
 
51
+ /**
52
+ * The layout stays mounted across visits: the phone sidebar sheet closes when the user
53
+ * moves to another page.
54
+ */
55
+ function CloseMobileSidebarOnVisit() {
56
+ const { setOpenMobile } = useSidebar()
57
+
58
+ useCloseOnVisit(useCallback(() => setOpenMobile(false), [setOpenMobile]))
59
+
60
+ return null
61
+ }
62
+
51
63
  export function AppLayout({ children, breadcrumbs, maxContentWidth }: AppLayoutProps) {
52
64
  const props = useAlfakitProps()
53
65
  const registry = useRegistry()
@@ -73,6 +85,7 @@ export function AppLayout({ children, breadcrumbs, maxContentWidth }: AppLayoutP
73
85
  defaultOpen={sidebarDefaultOpen(props.alfakitSidebarOpen, layout)}
74
86
  style={sidebarWidthStyle(props.alfakit) as CSSProperties | undefined}
75
87
  >
88
+ <CloseMobileSidebarOnVisit />
76
89
  <Slot name="body.start" />
77
90
  <Slot name="layout.start" />
78
91
  {createElement(Sidebar)}
@@ -4,29 +4,44 @@ import {
4
4
  type DiscoveryModules,
5
5
  type DiscoveryOptions,
6
6
  } from '@alfakit/core'
7
- import { createElement, useEffect, useState } from 'react'
8
- import type { AnyComponent } from '../context'
7
+ import { createElement, useEffect, useState, useSyncExternalStore } from 'react'
8
+ import { useOptionalAlfakit, type AnyComponent } from '../context'
9
+
10
+ const subscribe = () => () => {}
9
11
 
10
12
  /**
11
13
  * A discovered component: rendered straight away once its code has loaded (the pages that
12
14
  * use it preload it, see `ComponentDiscovery.preloadFor()`), else as soon as it arrives
13
15
  * (through state, not `<Suspense>`, whose reveal React throttles by up to 300 ms).
14
16
  * Each mount keeps the path it started with, so loading never remounts a field.
17
+ *
18
+ * Where pages are not preloaded (starter kits, standalone components), the server render
19
+ * and hydration leave it out even when its code is loaded: a long-lived SSR server keeps
20
+ * what earlier requests loaded, a fresh browser has none of it (OM-B06).
15
21
  */
16
22
  export function discoveredComponent(entry: DiscoveredEntry<AnyComponent>): AnyComponent {
17
23
  function Discovered(props: Record<string, unknown>) {
24
+ const preloaded = useOptionalAlfakit()?.preloadsPageComponents === true
25
+ // False on the server and while hydrating, true for every later render and mount.
26
+ const hydrated = useSyncExternalStore(
27
+ subscribe,
28
+ () => true,
29
+ () => false,
30
+ )
31
+ const ready = preloaded || hydrated
18
32
  const [state, setState] = useState<{ component?: AnyComponent; error?: unknown }>(() => ({
19
- component: entry.current,
33
+ component: ready ? entry.current : undefined,
20
34
  }))
35
+ const component = state.component ?? (ready ? entry.current : undefined)
21
36
 
22
37
  useEffect(() => {
23
- if (state.component) return
38
+ if (component) return
24
39
 
25
40
  let active = true
26
41
 
27
42
  entry.load().then(
28
- (component) => {
29
- if (active) setState({ component })
43
+ (loaded) => {
44
+ if (active) setState({ component: loaded })
30
45
  },
31
46
  (error: unknown) => {
32
47
  if (active) setState({ error: error ?? new Error(`Failed to load ${entry.key}`) })
@@ -36,11 +51,11 @@ export function discoveredComponent(entry: DiscoveredEntry<AnyComponent>): AnyCo
36
51
  return () => {
37
52
  active = false
38
53
  }
39
- }, [state.component])
54
+ }, [component])
40
55
 
41
56
  if (state.error !== undefined) throw state.error
42
57
 
43
- return state.component ? createElement(state.component, props) : null
58
+ return component ? createElement(component, props) : null
44
59
  }
45
60
 
46
61
  Discovered.displayName = `Discovered(${entry.key})`
package/src/lib/icons.tsx CHANGED
@@ -1,61 +1,126 @@
1
1
  import { iconAlias, iconAliasOverride, iconSource, isIconAlias } from '@alfakit/core'
2
2
  import type { LucideProps } from 'lucide-react'
3
3
  import type { DynamicIcon, IconName as LucideIconName } from 'lucide-react/dynamic.mjs'
4
- import { createElement, useEffect, useState, type ComponentProps, type ComponentType } from 'react'
4
+ import {
5
+ createElement,
6
+ useEffect,
7
+ useState,
8
+ type ComponentProps,
9
+ type ComponentType,
10
+ type ReactNode,
11
+ } from 'react'
5
12
  import { useOptionalRegistry } from '../context'
13
+ import { useHydrated } from '../hooks/use-hydrated'
6
14
  import { cn } from './utils'
7
15
 
8
16
  type DynamicIconComponent = typeof DynamicIcon
17
+ type LucideGlyph = ComponentType<LucideProps>
18
+ type IconImports = Record<string, () => Promise<{ default: LucideGlyph }>>
9
19
 
10
20
  /**
11
21
  * `lucide-react/dynamic` carries a lazy import for every Lucide icon (~2,000 entries), so it
12
22
  * is not part of any entry chunk: panels (`createAlfakit()`, starter kit pages) start loading
13
23
  * it while they boot, since every page draws named icons, and elsewhere (a host's sidebar)
14
- * it loads with the first icon. Named icons render asynchronously either way.
24
+ * it loads with the first icon.
15
25
  */
16
- let dynamicIcon: DynamicIconComponent | undefined
17
- let pendingDynamicIcon: Promise<DynamicIconComponent> | undefined
26
+ let pendingImports: Promise<IconImports> | undefined
18
27
 
19
- function loadDynamicIcon(): Promise<DynamicIconComponent> {
20
- pendingDynamicIcon ??= import('lucide-react/dynamic.mjs').then(
21
- (module) => (dynamicIcon = module.DynamicIcon),
28
+ function loadIconImports(): Promise<IconImports> {
29
+ pendingImports ??= import('lucide-react/dynamic.mjs').then(
30
+ (module) => module.dynamicIconImports as unknown as IconImports,
22
31
  (error: unknown) => {
23
- pendingDynamicIcon = undefined
32
+ pendingImports = undefined
24
33
 
25
34
  throw error
26
35
  },
27
36
  )
28
37
 
29
- return pendingDynamicIcon
38
+ return pendingImports
39
+ }
40
+
41
+ /**
42
+ * Loaded icons by name (`null`: not a Lucide icon), shared by every `<Icon>`: an icon that
43
+ * loaded once draws synchronously in every later mount (the page after a visit, new table
44
+ * rows), instead of rendering empty for a frame on each mount as `DynamicIcon` does.
45
+ */
46
+ const loadedIcons = new Map<string, LucideGlyph | null>()
47
+ const pendingIcons = new Map<string, Promise<LucideGlyph | null>>()
48
+
49
+ function loadIcon(name: string): Promise<LucideGlyph | null> {
50
+ const loaded = loadedIcons.get(name)
51
+
52
+ if (loaded !== undefined) return Promise.resolve(loaded)
53
+
54
+ let pending = pendingIcons.get(name)
55
+
56
+ if (!pending) {
57
+ pending = loadIconImports()
58
+ .then((imports) => {
59
+ const load = Object.prototype.hasOwnProperty.call(imports, name) ? imports[name] : undefined
60
+
61
+ return load ? load().then((module) => module.default) : null
62
+ })
63
+ .then(
64
+ (icon) => {
65
+ loadedIcons.set(name, icon)
66
+ pendingIcons.delete(name)
67
+
68
+ return icon
69
+ },
70
+ (error: unknown) => {
71
+ // A failed chunk is tried again by the next mount.
72
+ pendingIcons.delete(name)
73
+
74
+ throw error
75
+ },
76
+ )
77
+ pendingIcons.set(name, pending)
78
+ }
79
+
80
+ return pending
30
81
  }
31
82
 
32
83
  /** Start loading `lucide-react/dynamic` ahead of the first named icon (browser only). */
33
84
  export function preloadIcons(): void {
34
- if (typeof window !== 'undefined') loadDynamicIcon().catch(() => undefined)
85
+ if (typeof window !== 'undefined') loadIconImports().catch(() => undefined)
86
+ }
87
+
88
+ type LucideIconProps = Omit<ComponentProps<DynamicIconComponent>, 'name' | 'fallback'> & {
89
+ name: string
90
+ fallback?: () => ReactNode
35
91
  }
36
92
 
37
- /** `DynamicIcon`, rendering its `fallback` (or nothing) until `lucide-react/dynamic` loads. */
38
- function LazyDynamicIcon(props: ComponentProps<DynamicIconComponent>) {
39
- const [Dynamic, setDynamic] = useState(() => dynamicIcon)
93
+ /**
94
+ * A Lucide icon by name, rendering `fallback` (or nothing) while it loads and for unknown
95
+ * names. An icon that has loaded renders at once, except during the server render and
96
+ * hydration: the server draws no named icons, so the hydrated markup matches it.
97
+ */
98
+ function LucideIcon({ name, fallback, ...props }: LucideIconProps) {
99
+ const hydrated = useHydrated()
100
+ const [, setLoaded] = useState(0)
101
+ const Loaded = hydrated ? loadedIcons.get(name) : undefined
40
102
 
41
103
  useEffect(() => {
42
- if (Dynamic) return
104
+ if (loadedIcons.has(name)) return
43
105
 
44
106
  let active = true
45
107
 
46
- loadDynamicIcon().then(
47
- (component) => active && setDynamic(() => component),
48
- () => undefined,
108
+ loadIcon(name).then(
109
+ () => {
110
+ if (active) setLoaded((count) => count + 1)
111
+ },
112
+ (error: unknown) => console.error(error),
49
113
  )
50
114
 
51
115
  return () => {
52
116
  active = false
53
117
  }
54
- }, [Dynamic])
118
+ }, [name])
55
119
 
56
- if (Dynamic) return <Dynamic {...props} />
120
+ // A module-level cache entry (one component per icon name), not created during render.
121
+ if (Loaded) return createElement(Loaded, props as LucideProps)
57
122
 
58
- return props.fallback ? createElement(props.fallback) : null
123
+ return fallback ? fallback() : null
59
124
  }
60
125
 
61
126
  /** A statically imported glyph (`SearchIcon` from `lucide-react`). */
@@ -72,7 +137,8 @@ export type IconProps = Omit<ComponentProps<DynamicIconComponent>, 'name' | 'fal
72
137
  * app's replacement (`Icons::register()`) or `fallback`. Unknown Lucide names fall back to
73
138
  * `fallback`, if any. `fallback` may also be a statically imported glyph
74
139
  * (`fallback={SearchIcon}`): renderer chrome uses it so an unreplaced alias draws at once,
75
- * without loading anything. Register a `panel:icon` component
140
+ * without loading anything. Named icons load on demand; once loaded, later mounts draw them
141
+ * synchronously. Register a `panel:icon` component
76
142
  * (`createAlfakit({ components: { 'panel:icon': MyIcon } })`) to render every named icon
77
143
  * with another icon set; it receives `name`, `fallback` and `className`.
78
144
  */
@@ -129,14 +195,14 @@ export function Icon({ name, fallback: fallbackProp, className, ...props }: Icon
129
195
  }
130
196
 
131
197
  return (
132
- <LazyDynamicIcon
133
- name={source.name as LucideIconName}
198
+ <LucideIcon
199
+ name={source.name}
134
200
  className={classes}
135
201
  fallback={
136
202
  Glyph
137
203
  ? () => <Glyph className={classes} />
138
204
  : fallback
139
- ? () => <LazyDynamicIcon name={fallback} className={classes} />
205
+ ? () => <LucideIcon name={fallback} className={classes} />
140
206
  : undefined
141
207
  }
142
208
  {...props}