From e16f572252b954d455141b40d369c12cff7f1ed8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 11:00:05 +0200 Subject: [PATCH] fix: use plugin slot offer snapshots for useSyncExternalStore --- components/plugins/plugin-slot.tsx | 7 ++--- hooks/use-plugin-slot-offers.ts | 7 ++--- lib/plugin-sandbox/registry.ts | 41 +++++++++++++++++++++++++----- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/components/plugins/plugin-slot.tsx b/components/plugins/plugin-slot.tsx index ea00587a..babdc534 100644 --- a/components/plugins/plugin-slot.tsx +++ b/components/plugins/plugin-slot.tsx @@ -12,11 +12,8 @@ interface PluginSlotProps { } export function PluginSlot({ name, className, extraProps }: PluginSlotProps) { - const offers = useSyncExternalStore( - subscribe, - () => offersForSlot(name), - () => [], - ); + const getSnapshot = () => offersForSlot(name); + const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); if (offers.length === 0) return null; diff --git a/hooks/use-plugin-slot-offers.ts b/hooks/use-plugin-slot-offers.ts index 2501cc05..8a235812 100644 --- a/hooks/use-plugin-slot-offers.ts +++ b/hooks/use-plugin-slot-offers.ts @@ -9,9 +9,6 @@ import type { SlotName } from '@/lib/plugin-types'; import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry'; export function usePluginSlotOffers(slotName: SlotName) { - return useSyncExternalStore( - subscribe, - () => offersForSlot(slotName), - () => [], - ); + const getSnapshot = () => offersForSlot(slotName); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/lib/plugin-sandbox/registry.ts b/lib/plugin-sandbox/registry.ts index 85870543..6b4a4e28 100644 --- a/lib/plugin-sandbox/registry.ts +++ b/lib/plugin-sandbox/registry.ts @@ -1,6 +1,10 @@ // Process-wide registry of active sandboxed plugins. The loader populates it // after a successful boot; PluginIframeSlot reads it to spawn slot iframes // and to call evaluateShouldShow on the background instance. +// +// `offersForSlot` results are memoised per slot name so that +// `useSyncExternalStore` sees a stable reference between unrelated renders. +// The cache is invalidated whenever the set of active plugins changes. import type { Disposable, InstalledPlugin, SlotName } from '../plugin-types'; import type { SandboxInstance } from './host-bridge'; @@ -20,21 +24,36 @@ export interface ActivePlugin { hookDisposables: Disposable[]; } +export interface ResolvedSlotOffer { + pluginId: string; + order: number; + hasShouldShow: boolean; +} + const active = new Map(); const listeners = new Set<() => void>(); -function emit(): void { for (const l of listeners) try { l(); } catch { /* ignore */ } } +// Per-slot snapshot cache. Cleared on any registry mutation. +const offersCache = new Map(); +const EMPTY: readonly ResolvedSlotOffer[] = Object.freeze([]); + +function invalidate(): void { + offersCache.clear(); + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} export function register(entry: ActivePlugin): void { active.set(entry.plugin.id, entry); - emit(); + invalidate(); } export function deregister(pluginId: string): ActivePlugin | undefined { const e = active.get(pluginId); if (!e) return undefined; active.delete(pluginId); - emit(); + invalidate(); return e; } @@ -46,9 +65,15 @@ export function all(): ActivePlugin[] { return [...active.values()]; } -/** Returns active plugins ordered by `order`, that offer the requested slot. */ -export function offersForSlot(slot: SlotName): Array<{ pluginId: string; order: number; hasShouldShow: boolean }> { - const out: Array<{ pluginId: string; order: number; hasShouldShow: boolean }> = []; +/** + * Returns the cached, frozen list of plugins offering this slot, sorted by + * `order`. The returned array is referentially stable until the active-plugin + * set changes, so it is safe to pass to `useSyncExternalStore`. + */ +export function offersForSlot(slot: SlotName): readonly ResolvedSlotOffer[] { + const cached = offersCache.get(slot); + if (cached) return cached; + const out: ResolvedSlotOffer[] = []; for (const entry of active.values()) { for (const offer of entry.slotOffers) { if (offer.name === slot) { @@ -57,7 +82,9 @@ export function offersForSlot(slot: SlotName): Array<{ pluginId: string; order: } } out.sort((a, b) => a.order - b.order); - return out; + const snapshot = out.length === 0 ? EMPTY : Object.freeze(out); + offersCache.set(slot, snapshot); + return snapshot; } export function subscribe(listener: () => void): () => void {