diff --git a/frontend/src/components/ComponentPickerModal.tsx b/frontend/src/components/ComponentPickerModal.tsx index 7f1d83a8..670549aa 100644 --- a/frontend/src/components/ComponentPickerModal.tsx +++ b/frontend/src/components/ComponentPickerModal.tsx @@ -9,7 +9,7 @@ * - Click to select and add component */ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { ComponentRegistry } from '../services/ComponentRegistry'; @@ -33,7 +33,12 @@ interface CardHoverApi { import type { BoardKind } from '../types/board'; import { BOARD_KIND_LABELS } from '../types/board'; import { isProBoardKind } from '../lib/proBoardGate'; -import { getProBoard, listProBoards } from '../lib/proBoardRegistry'; +import { + getProBoard, + listProBoards, + subscribeProBoards, + getProBoardsVersion, +} from '../lib/proBoardRegistry'; import { ONLINE_ONLY_BOARD_ADS, ONLINE_ONLY_COMPONENT_ADS, @@ -204,12 +209,30 @@ export const ComponentPickerModal: React.FC = ({ } return components; - }, [searchQuery, selectedCategory, registry, isLoading]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, selectedCategory, registry, isLoading, registryVersion]); + + // Late-overlay registrations must re-render an already-mounted picker: + // the @pro import is dynamic, so boards/components can register AFTER the + // first render. Without these subscriptions the memos below freeze on the + // pre-registration state (boards missing, ONLINE ads instead of the real + // components - and which one you got depended on a reload race). + const proBoardsVersion = useSyncExternalStore( + subscribeProBoards, + getProBoardsVersion, + getProBoardsVersion, + ); + const registryVersion = useSyncExternalStore( + registry.subscribe, + registry.getVersion, + registry.getVersion, + ); // Boards list: static OSS kinds + overlay-registered boards (proBoardRegistry). const allBoards = useMemo(() => { return [...ALL_BOARDS, ...(listProBoards().map((d) => d.kind) as BoardKind[])]; - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [proBoardsVersion]); // Online-only component ads: shown where the real component would sit, and // hidden automatically in any build whose registry has the real component @@ -223,7 +246,8 @@ export const ComponentPickerModal: React.FC = ({ (selectedCategory === 'all' || ad.category === selectedCategory) && (!q || ad.label.toLowerCase().includes(q)), ); - }, [registry, isLoading, searchQuery, selectedCategory]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [registry, isLoading, searchQuery, selectedCategory, registryVersion, proBoardsVersion]); // Get available categories const categories = useMemo(() => { diff --git a/frontend/src/lib/proBoardRegistry.ts b/frontend/src/lib/proBoardRegistry.ts index 248d5fd4..841d9228 100644 --- a/frontend/src/lib/proBoardRegistry.ts +++ b/frontend/src/lib/proBoardRegistry.ts @@ -96,6 +96,21 @@ export interface ProBoardDef { const registry = new Map(); +// Registration happens when the overlay's dynamic import lands - potentially +// AFTER a consumer rendered and memoized its board list. Subscribers re-render +// on every registration (same contract as proRoutes / registerProExamples). +let version = 0; +const listeners = new Set<() => void>(); + +export function subscribeProBoards(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +export function getProBoardsVersion(): number { + return version; +} + export function registerProBoards(defs: ProBoardDef[]): void { for (const def of defs) { registry.set(def.kind, def); @@ -106,6 +121,8 @@ export function registerProBoards(defs: ProBoardDef[]): void { (BOARD_KIND_FQBN as Record)[kind] = def.fqbn; if (def.supportsMicroPython) BOARD_SUPPORTS_MICROPYTHON.add(kind); } + version++; + for (const l of listeners) l(); } export function getProBoard(kind: string): ProBoardDef | undefined { diff --git a/frontend/src/services/ComponentRegistry.ts b/frontend/src/services/ComponentRegistry.ts index 3a65e544..035411c3 100644 --- a/frontend/src/services/ComponentRegistry.ts +++ b/frontend/src/services/ComponentRegistry.ts @@ -273,8 +273,24 @@ export class ComponentRegistry { byId.set(extra.id, extra); } this.processMetadata(Array.from(byId.values())); + // The overlay merges AFTER its dynamic import lands - notify subscribers so + // an already-open picker recomputes (ONLINE ad cards vs real components). + this._version++; + for (const l of this._changeListeners) l(); } + private _version = 0; + private _changeListeners = new Set<() => void>(); + + /** Monotonic counter bumped on every mergeComponents() - pair with subscribe + * in useSyncExternalStore so late overlay merges re-render consumers. */ + getVersion = (): number => this._version; + + subscribe = (cb: () => void): (() => void) => { + this._changeListeners.add(cb); + return () => this._changeListeners.delete(cb); + }; + /** * Get components by category */