import { isServer } from "@solidjs/web"; import { type Accessor, createEffect, createMemo, createSignal, } from "solid-js"; /** * Image loading status reported by the probe. Mirrors Base UI React's * `ImageLoadingStatus` union (`packages/react/src/avatar/image/useImageLoadingStatus.ts`). * * It survives the move to async-native (`solidify.md` §4) because the Avatar * compound coordinates `` and its sibling `` * through a shared status signal in root context — sibling parts, not nested * subtrees, so a ``/`` boundary can't replace it. The union * is a *derived view* of the async probe below, not a hand-rolled state * machine: a failed image is a domain state (→ fallback), not an exception * that should escape to `` — the same messages-vs-exceptions split * `createValidator` draws. */ export type ImageLoadingStatus = "idle" | "loading" | "loaded" | "error"; /** * Inputs to {@link useImageLoadingStatus}. Each entry is an accessor so the * primitive can react to upstream prop changes without being re-created. * Matching the React helper, only `src`, `referrerPolicy`, and `crossOrigin` * participate in re-probing. */ export interface UseImageLoadingStatusOptions { src: Accessor; referrerPolicy: Accessor; crossOrigin: Accessor; } /** * Probe a single image off-screen via `new window.Image()`, returning a * Promise that resolves once the resource loads and rejects when it errors. * This lets a consumer decide whether to actually mount a real `` based * on the outcome — avoiding the broken-image flash that happens when the * visible `` itself is asked to load a 404. * * The Promise is the unit the async `createMemo` in {@link useImageLoadingStatus} * hands to the reactive graph; recomputing the memo (on a `src` change) simply * abandons the previous probe — its eventual `onload`/`onerror` resolves an * orphaned Promise nobody is awaiting, so there's no manual "is this still the * active probe?" latch to maintain. */ function loadImage( src: string, referrerPolicy: string | undefined, crossOrigin: string | undefined, ): Promise { return new Promise((resolve, reject) => { const image = new window.Image(); image.onload = () => resolve(); image.onerror = () => reject(new Error(`Failed to load image: ${src}`)); if (referrerPolicy) { image.referrerPolicy = referrerPolicy; } // `crossOrigin` stays camelCase here — we're writing to a DOM property // on the probe element, not a JSX attribute. The JSX prop is // `crossorigin`; the IDL property is `crossOrigin`. image.crossOrigin = crossOrigin === undefined ? null : (crossOrigin as string | null); image.src = src; // Fast path for cached/decoded images — `complete` is synchronously // true if the resource is already in the browser cache, in which case // `onload`/`onerror` may never fire. if (image.complete) { if (image.naturalWidth > 0) { resolve(); } else { reject(new Error(`Failed to load image: ${src}`)); } } }); } /** * Runs a standalone image probe whose async outcome drives a discrete * `ImageLoadingStatus` accessor. * * Solid 2 shape (`solidify.md` §4 — RFC 05 async-native): * * - `probe` is an async `createMemo` returning {@link loadImage}'s Promise. * It re-runs whenever `src`/`referrerPolicy`/`crossOrigin` flip; the prior * in-flight probe is discarded by the graph (last-write-wins), so no manual * `isActive` latch is needed. * - The bundled `createEffect(compute, { effect, error })` reads `probe()` in * its compute half. While the Promise is pending the compute *suspends* — * the `effect` (apply) half does not run, and no `ASYNC_OUTSIDE_LOADING_BOUNDARY` * diagnostic fires (verified: a suspended *computation* without a `` * ancestor is silent; only a pending *read in render* requires the boundary). * On resolve the apply half lands `"loaded"`; on reject the `error` half * lands `"error"` — caught from the compute phase per RFC 01's bundle * contract, so the rejection is handled here rather than escaping upward. * - A separate split effect tracks the probe inputs and lands `"loading"` * synchronously the moment they change to a non-empty `src` (the bundle * effect can't — it's suspended until the probe settles). An empty `src` * lands `"error"` directly, preserving the React helper's "no loading flash * for a missing src" behaviour (the memo also rejects for empty `src`, so * the two agree). * * The returned `status` accessor is a plain signal read; callers can drive * further effects off it (mirror into a root context, forward into a consumer * callback). */ export function useImageLoadingStatus( options: UseImageLoadingStatusOptions, ): Accessor { // `ownedWrite: true` — `setStatus` is called from the apply/error halves of // `createEffect` (owned scope). Without it, dev-mode emits // SIGNAL_WRITE_IN_OWNED_SCOPE. const [status, setStatus] = createSignal("idle", { name: "useImageLoadingStatus.status", ownedWrite: true, }); // The RC's synchronous server renderer evaluates memos during render. Image // probing is browser-only; keep the initial idle status so the fallback is // rendered on the server and the client owns the first real probe. if (isServer) return status; // Async probe — source of truth. A non-empty `src` resolves on load / // rejects on error; an empty `src` is a domain "error" state, surfaced as a // rejected Promise so the bundle's `error` half routes it to `"error"`. const probe = createMemo( () => { const src = options.src(); if (!src) { return Promise.reject(new Error("Avatar image: missing src")); } return loadImage(src, options.referrerPolicy(), options.crossOrigin()); }, { name: "useImageLoadingStatus.probe" }, ); // Land "loading" (or "error" for a missing src) synchronously when the // inputs change. The bundle effect below is suspended while the probe is // pending, so it can't surface the in-flight state itself. createEffect( () => options.src(), (src) => { setStatus(src ? "loading" : "error"); }, ); // Resolve the settled probe into the terminal status. Reading `probe()` // suspends the compute while pending; the apply half fires only once the // image has loaded, the error half only on failure. createEffect( () => { probe(); return undefined; }, { effect: () => { setStatus("loaded"); }, error: (_err, cleanup) => { setStatus("error"); cleanup(); }, }, ); return status; }