import { type Accessor, createSignal, onCleanup, untrack } from "solid-js"; import { contains } from "../internals/dom/contains"; import { Timeout } from "../internals/use-timeout"; import type { ToastManagerAddOptions, ToastManagerPromiseOptions, ToastManagerUpdateOptions, ToastObject, } from "./use-toast-manager"; /** * Tier-3b port of Base UI React's `ToastStore` * (`packages/react/src/toast/store.ts`). The Solid port mirrors the React * store's shape but swaps the `ReactStore`/selector plumbing for plain * Solid signals. Every external reader (`Toast.Viewport`, `Toast.Root`, * `Toast.Close`) subscribes to the signals directly through accessors — * there are no selector memos because each reader only cares about one or * two slots. * * Scope differences vs React: * * - Heights are measured on `Toast.Root` and flow into stack CSS vars. * - Pause/resume follows React: when ANY of `hovering` / `focused` is true, * or `isWindowFocused` is false, timers are paused; when all three * release the lock, timers resume from their recorded remaining delay. */ type UpdateBehavior = { resetTimer?: boolean | undefined; markUpdated?: boolean | undefined; }; interface TimerInfo { timeout?: Timeout | undefined; start: number; delay: number; remaining: number; callback: () => void; } /** The shape that consumers read (via accessors) from the store. */ export interface ToastStoreReaders { toasts: Accessor[]>; hovering: Accessor; focused: Accessor; expanded: Accessor; isEmpty: Accessor; prevFocusElement: Accessor; viewport: Accessor; isWindowFocused: Accessor; } export interface ToastStore extends ToastStoreReaders { readonly timeout: Accessor; readonly limit: Accessor; addToast: (options: ToastManagerAddOptions) => string; updateToast: (id: string, options: ToastManagerUpdateOptions) => void; updateToastInternal: ( id: string, updates: Partial, "id">>, behavior?: UpdateBehavior, ) => void; closeToast: (id?: string) => void; removeToast: (id: string, opts?: { skipOnRemove?: boolean }) => void; promiseToast: ( value: Promise, options: ToastManagerPromiseOptions, ) => Promise; setHovering: (next: boolean) => void; setFocused: (next: boolean) => void; setIsWindowFocused: (next: boolean) => void; setViewport: (el: HTMLElement | null) => void; setPrevFocusElement: (el: HTMLElement | null) => void; restoreFocusToPrevElement: () => void; pauseTimers: () => void; resumeTimers: () => void; // Internal — only used by the provider to sync timeout/limit into the // store from its props. Kept off the public manager surface. setTimeoutOption: (next: number) => void; setLimitOption: (next: number) => void; /** Touch outside the viewport — collapse expanded state and resume timers. */ handleDocumentPointerDown: (event: PointerEvent) => void; } let nextToastCounter = 0; function generateToastId(): string { nextToastCounter += 1; return `toast-${nextToastCounter}-${Math.random().toString(36).slice(2, 8)}`; } function resolvePromiseOption( option: | string | ToastManagerUpdateOptions | ((result: Value) => string | ToastManagerUpdateOptions) | undefined, value?: Value, ): ToastManagerUpdateOptions { if (option === undefined) return {}; const resolved = typeof option === "function" ? (option as (v: Value) => string | ToastManagerUpdateOptions)( value as Value, ) : option; if (typeof resolved === "string") return { title: resolved }; return resolved; } export interface CreateToastStoreOptions { timeout: number; limit: number; } /** * Factory — owns all store signals + timers for the owning component. The * returned object is stable for the scope's lifetime; owner-dispose tears * every pending timer down via `onCleanup`. * * Signals opt into `ownedWrite` because the store's setters are invoked * from unowned surfaces (event handlers, timers) but the signals are * created inside the provider's owned scope. */ export function createToastStore(initial: CreateToastStoreOptions): ToastStore { // Solid 2 signals update readers on the next flush — reads between // synchronous writes still see the stale value. Our multi-add / close // paths reshape the list multiple times per call site (e.g. two // `manager.add()` in the same frame), so we keep a private mutable // mirror and notify the signal after every mutation. // // `createStore` was explored as a replacement (see `spikes/toast-store/`) // — proxy reads give same-tick visibility natively, but its `` // reconciliation preserves item proxies across array reassignment, // which breaks reorder behaviour when an id moves position. The mirror // stays until we rewrite the add/close paths as fine-grained draft // mutations (splice/push) rather than whole-array replacements. let toastsMirror: ToastObject[] = []; const [toasts, setToasts] = createSignal[]>( toastsMirror, { ownedWrite: true, // Identity-based equality: every write produces a fresh array, so // the signal must treat "different array reference" as a change. // Default `isEqual` is a value compare — we shortcut that here. equals: false, }, ); const writeList = (next: ToastObject[]): void => { toastsMirror = next; setToasts(next); }; const readListRaw = (): ToastObject[] => toastsMirror; const [hovering, setHoveringSignal] = createSignal(false, { ownedWrite: true, }); const [focused, setFocusedSignal] = createSignal(false, { ownedWrite: true }); const [isWindowFocused, setIsWindowFocusedSignal] = createSignal(true, { ownedWrite: true, }); const [prevFocusElement, setPrevFocusElement] = createSignal(null, { ownedWrite: true }); const [viewport, setViewport] = createSignal(null, { ownedWrite: true, }); const [timeoutOption, setTimeoutOption] = createSignal(initial.timeout, { ownedWrite: true, }); const [limitOption, setLimitOption] = createSignal(initial.limit, { ownedWrite: true, }); const timers = new Map(); let timersPaused = false; onCleanup(() => { for (const [, t] of timers) t.timeout?.clear(); timers.clear(); }); const hasDifferingHeights = (): boolean => { const heights = readListRaw() .map((t) => t.height ?? 0) .filter((h) => h > 0); return heights.length > 0 && new Set(heights).size > 1; }; const getExpanded = (): boolean => hovering() || focused() || hasDifferingHeights(); const getExpandedOrOutOfFocus = (): boolean => hovering() || focused() || !isWindowFocused(); const shouldStartPaused = (): boolean => untrack(getExpandedOrOutOfFocus); const scheduleTimer = (id: string, delay: number, cb: () => void): void => { const startActive = !shouldStartPaused(); const t = startActive ? Timeout.create() : undefined; t?.start(delay, () => { timers.delete(id); cb(); }); timers.set(id, { timeout: t, start: startActive ? Date.now() : 0, delay, remaining: delay, callback: cb, }); }; const pauseTimers = (): void => { if (timersPaused) return; timersPaused = true; for (const [, t] of timers) { if (t.timeout) { t.timeout.clear(); const elapsed = Date.now() - t.start; const remaining = t.delay - elapsed; t.remaining = remaining > 0 ? remaining : 0; t.timeout = undefined; } } }; const resumeTimers = (): void => { if (!timersPaused) return; timersPaused = false; for (const [id, t] of timers) { const remaining = t.remaining > 0 ? t.remaining : t.delay; const handle = Timeout.create(); t.timeout = handle; t.start = Date.now(); handle.start(remaining, () => { timers.delete(id); t.callback(); }); } }; const setHovering = (next: boolean): void => { setHoveringSignal(next); if (next) pauseTimers(); else if (!untrack(focused) && untrack(isWindowFocused)) resumeTimers(); }; const setFocused = (next: boolean): void => { setFocusedSignal(next); if (next) pauseTimers(); else if (!untrack(hovering) && untrack(isWindowFocused)) resumeTimers(); }; const setIsWindowFocused = (next: boolean): void => { setIsWindowFocusedSignal(next); if (!next) pauseTimers(); else if (!untrack(hovering) && !untrack(focused)) resumeTimers(); }; const findIndex = (id: string): number => readListRaw().findIndex((t) => t.id === id); const writeToasts = (next: ToastObject[]): void => { writeList(next); if (next.length === 0) { setHoveringSignal(false); setFocusedSignal(false); } }; const removeToast = ( id: string, opts: { skipOnRemove?: boolean } = {}, ): void => { const index = findIndex(id); if (index === -1) return; const current = readListRaw(); const toast = current[index]; if (!opts.skipOnRemove) toast?.onRemove?.(); const next = current.slice(); next.splice(index, 1); writeToasts(next); }; const updateToastInternal = ( id: string, updates: Partial, "id">>, behavior: UpdateBehavior = {}, ): void => { const current = readListRaw(); const index = current.findIndex((t) => t.id === id); if (index === -1) return; const prev = current[index]; if (!prev) return; if (prev.transitionStatus === "ending") return; // Snapshot the fields the timer logic below needs from the PRE-update // toast — we mutate `prev` in place further down, so reading these after // the mutation would see the new values. const prevTimeout = prev.timeout; const prevType = prev.type; const nextToast = { ...prev, ...updates, id: prev.id, ...(behavior.markUpdated ? { updateKey: (prev.updateKey ?? 0) + 1 } : {}), } satisfies ToastObject; // Skip no-op internal writes so ResizeObserver ↔ store ↔ style does not // spin when only `height` / `transitionStatus` are unchanged. if ( !behavior.markUpdated && (nextToast.height ?? 0) === (prev.height ?? 0) && nextToast.transitionStatus === prev.transitionStatus && nextToast.limited === prev.limited && nextToast.type === prev.type && nextToast.timeout === prev.timeout ) { return; } // Mutate the EXISTING toast object in place rather than swapping in a // fresh `{ ...prev, ...updates }` reference. Consumers render the queue // with a keyed ``, which keys rows by item // IDENTITY — replacing the object reference makes `` drop and // rebuild the row, re-firing `Toast.Root`'s mount `ref` → // `recalculateHeight()` → another internal write → another rebuild. In // happy-dom the no-op guard above collapses that after one pass (height // is constant `0`), but in a real browser sub-pixel height jitter keeps // each measurement different, so the rebuild/measure cycle spins at // 100% CPU. Preserving identity keeps the row mounted; the `toast` memo // in `ToastRoot` (created with `equals: false`) still re-notifies style // readers off the queue signal. Add / close / reorder still allocate // new references where a genuine identity change is intended. Object.assign(prev, updates, { id: prev.id }); if (behavior.markUpdated) { prev.updateKey = (prev.updateKey ?? 0) + 1; } const nextList = current.slice(); writeToasts(nextList); const defaultTimeout = untrack(timeoutOption); const nextDuration = nextToast.timeout ?? defaultTimeout; const prevDuration = prevTimeout ?? defaultTimeout; const timeoutUpdated = Object.hasOwn(updates, "timeout"); const shouldHaveTimer = nextToast.transitionStatus !== "ending" && nextToast.type !== "loading" && nextDuration > 0; const hasTimer = timers.has(id); const changed = prevDuration !== nextDuration; const wasLoading = prevType === "loading"; if (!shouldHaveTimer && hasTimer) { timers.get(id)?.timeout?.clear(); timers.delete(id); return; } if ( shouldHaveTimer && (!hasTimer || changed || timeoutUpdated || wasLoading || behavior.resetTimer) ) { timers.get(id)?.timeout?.clear(); timers.delete(id); scheduleTimer(id, nextDuration, () => closeToast(id)); if (untrack(getExpandedOrOutOfFocus)) pauseTimers(); } }; const addToast = (options: ToastManagerAddOptions): string => { const defaultTimeout = untrack(timeoutOption); const currentLimit = untrack(limitOption); const id = options.id ?? generateToastId(); const current = readListRaw(); if (options.id) { const existing = current.find((t) => t.id === options.id); if (existing) { if (existing.transitionStatus === "ending") { removeToast(options.id, { skipOnRemove: true }); } else { const { id: _ignored, transitionStatus: _ts, ...updates } = options; updateToastInternal(options.id, updates, { resetTimer: true, markUpdated: true, }); return options.id; } } } const toastToAdd: ToastObject = { ...options, id, updateKey: 0, transitionStatus: "starting", }; const withNew = [toastToAdd, ...readListRaw()]; const active = withNew.filter((t) => t.transitionStatus !== "ending"); let nextList: ToastObject[]; if (active.length > currentLimit) { const excess = active.length - currentLimit; const oldestActive = active.slice(-excess); const limitedIds = new Set(oldestActive.map((t) => t.id)); nextList = withNew.map((t) => { const limited = limitedIds.has(t.id); return t.limited === limited ? t : { ...t, limited }; }); } else { nextList = withNew.map((t) => (t.limited ? { ...t, limited: false } : t)); } writeToasts(nextList); const duration = toastToAdd.timeout ?? defaultTimeout; if (toastToAdd.type !== "loading" && duration > 0) { scheduleTimer(id, duration, () => closeToast(id)); } if (untrack(getExpandedOrOutOfFocus)) pauseTimers(); return id; }; const updateToast = ( id: string, updates: ToastManagerUpdateOptions, ): void => { updateToastInternal(id, updates, { markUpdated: true }); }; const closeToast = (id?: string): void => { const closeAll = id === undefined; const current = readListRaw(); if (!closeAll && !current.some((t) => t.id === id)) return; let toastsToClose: ToastObject[]; if (closeAll) { toastsToClose = current; for (const [, t] of timers) t.timeout?.clear(); timers.clear(); } else { toastsToClose = current.filter((t) => t.id === id); const t = timers.get(id); if (t) { t.timeout?.clear(); timers.delete(id); } } const currentLimit = untrack(limitOption); let activeIndex = 0; const nextList = current.map((item) => { if (closeAll || item.id === id) { return { ...item, transitionStatus: "ending" as const, height: 0 }; } if (item.transitionStatus === "ending") return item; const isLimited = activeIndex >= currentLimit; activeIndex += 1; return item.limited === isLimited ? item : { ...item, limited: isLimited }; }); writeList(nextList); if (closeAll || current.length === 1) { setHoveringSignal(false); setFocusedSignal(false); } for (const toast of toastsToClose) { if (toast.transitionStatus !== "ending") toast.onClose?.(); } // Focus recovery — delegate to the provider's current viewport + // prevFocusElement. We keep the check simple: if active element is // inside the viewport, refocus the previous element. No next-toast // focus traversal (tier-3b simplification). const vp = untrack(viewport); if (vp?.contains(vp.ownerDocument.activeElement)) { restoreFocusToPrevElement(); } }; const restoreFocusToPrevElement = (): void => { const el = untrack(prevFocusElement); try { el?.focus({ preventScroll: true }); } catch { // happy-dom can throw on .focus() for detached nodes — swallow. } }; const promiseToast = ( value: Promise, options: ToastManagerPromiseOptions, ): Promise => { const loading = resolvePromiseOption(options.loading); const id = addToast({ ...loading, type: "loading" }); const handled = value .then((result) => { const success = resolvePromiseOption(options.success, result); updateToast(id, { ...success, type: "success", timeout: success.timeout, }); return result; }) .catch((error: unknown) => { const err = resolvePromiseOption(options.error, error as Value); updateToast(id, { ...err, type: "error", timeout: err.timeout }); return Promise.reject(error); }); const optionsAsAny = "setPromise" in options ? options : ({ setPromise: undefined } satisfies { setPromise?: unknown }); if ( Object.hasOwn(optionsAsAny, "setPromise") && typeof optionsAsAny.setPromise === "function" ) { (optionsAsAny.setPromise as (p: Promise) => void)(handled); } return handled; }; const isEmpty = (): boolean => toasts().length === 0; const handleDocumentPointerDown = (event: PointerEvent): void => { if (event.pointerType !== "touch") return; const vp = untrack(viewport); if (!vp) return; const target = event.target as Element | null; if (contains(vp, target)) return; setHovering(false); setFocused(false); }; return { toasts, hovering, focused, expanded: getExpanded, isEmpty, prevFocusElement, viewport, isWindowFocused, timeout: timeoutOption, limit: limitOption, addToast, updateToast, updateToastInternal, closeToast, removeToast, promiseToast, setHovering, setFocused, setIsWindowFocused, setViewport, setPrevFocusElement, restoreFocusToPrevElement, pauseTimers, resumeTimers, setTimeoutOption, setLimitOption, handleDocumentPointerDown, }; }