fix: use plugin slot offer snapshots for useSyncExternalStore
This commit is contained in:
@@ -12,11 +12,8 @@ interface PluginSlotProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
|
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
|
||||||
const offers = useSyncExternalStore(
|
const getSnapshot = () => offersForSlot(name);
|
||||||
subscribe,
|
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
() => offersForSlot(name),
|
|
||||||
() => [],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (offers.length === 0) return null;
|
if (offers.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import type { SlotName } from '@/lib/plugin-types';
|
|||||||
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
|
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
|
||||||
|
|
||||||
export function usePluginSlotOffers(slotName: SlotName) {
|
export function usePluginSlotOffers(slotName: SlotName) {
|
||||||
return useSyncExternalStore(
|
const getSnapshot = () => offersForSlot(slotName);
|
||||||
subscribe,
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
() => offersForSlot(slotName),
|
|
||||||
() => [],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// Process-wide registry of active sandboxed plugins. The loader populates it
|
// Process-wide registry of active sandboxed plugins. The loader populates it
|
||||||
// after a successful boot; PluginIframeSlot reads it to spawn slot iframes
|
// after a successful boot; PluginIframeSlot reads it to spawn slot iframes
|
||||||
// and to call evaluateShouldShow on the background instance.
|
// 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 { Disposable, InstalledPlugin, SlotName } from '../plugin-types';
|
||||||
import type { SandboxInstance } from './host-bridge';
|
import type { SandboxInstance } from './host-bridge';
|
||||||
@@ -20,21 +24,36 @@ export interface ActivePlugin {
|
|||||||
hookDisposables: Disposable[];
|
hookDisposables: Disposable[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSlotOffer {
|
||||||
|
pluginId: string;
|
||||||
|
order: number;
|
||||||
|
hasShouldShow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const active = new Map<string, ActivePlugin>();
|
const active = new Map<string, ActivePlugin>();
|
||||||
const listeners = new Set<() => void>();
|
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<SlotName, readonly ResolvedSlotOffer[]>();
|
||||||
|
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 {
|
export function register(entry: ActivePlugin): void {
|
||||||
active.set(entry.plugin.id, entry);
|
active.set(entry.plugin.id, entry);
|
||||||
emit();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deregister(pluginId: string): ActivePlugin | undefined {
|
export function deregister(pluginId: string): ActivePlugin | undefined {
|
||||||
const e = active.get(pluginId);
|
const e = active.get(pluginId);
|
||||||
if (!e) return undefined;
|
if (!e) return undefined;
|
||||||
active.delete(pluginId);
|
active.delete(pluginId);
|
||||||
emit();
|
invalidate();
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +65,15 @@ export function all(): ActivePlugin[] {
|
|||||||
return [...active.values()];
|
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 }> {
|
* Returns the cached, frozen list of plugins offering this slot, sorted by
|
||||||
const out: Array<{ pluginId: string; order: number; hasShouldShow: boolean }> = [];
|
* `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 entry of active.values()) {
|
||||||
for (const offer of entry.slotOffers) {
|
for (const offer of entry.slotOffers) {
|
||||||
if (offer.name === slot) {
|
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);
|
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 {
|
export function subscribe(listener: () => void): () => void {
|
||||||
|
|||||||
Reference in New Issue
Block a user