All files / hooks/use-script/src cache.ts

96.72% Statements 59/61
85.71% Branches 36/42
100% Functions 13/13
100% Lines 52/52

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201                                                      1x       33x         26x             4x                                           237x               9x 9x 9x 9x   10x                       32x 32x     26x         26x   26x         4x   22x 22x 22x   22x 21x   22x 4x 6x     22x 22x 22x     26x             26x         26x 23x 23x 23x 23x 23x 23x     26x         4x 4x 4x 4x 4x 4x   4x                                     33x   32x 32x   32x 32x     32x 4x                     30x    
import type { ScriptStatus } from "./types";
 
/**
 * A tracked script in the shared registry. One entry exists per unique `src`,
 * shared by every mounted `useScript` for that source.
 */
interface ScriptEntry {
  /** Current shared status for this `src`. */
  status: ScriptStatus;
  /**
   * `useSyncExternalStore` change callbacks — one per mounted subscriber. The
   * set's `size` doubles as the ref-count that gates DOM removal on unmount.
   */
  subscribers: Set<() => void>;
  /** The `<script>` node this entry owns (created or adopted). */
  element: HTMLScriptElement | null;
  /** `load` listener attached to `element`, if any (so cleanup can detach it). */
  onLoad: (() => void) | null;
  /** `error` listener attached to `element`, if any. */
  onError: (() => void) | null;
}
 
/**
 * Module-level cache keyed by `src`. Because it lives at module scope, every
 * `useScript(sameSrc)` across the whole app shares one entry — and therefore one
 * `<script>` tag and one status — no matter how many components mount.
 */
const registry = new Map<string, ScriptEntry>();
 
/** SSR guard — the registry only ever touches the DOM in a browser. */
function isBrowser(): boolean {
  return typeof document !== "undefined";
}
 
/** Escape a `src` for safe interpolation into an attribute selector. */
function escapeSelector(src: string): string {
  return typeof CSS !== "undefined" && typeof CSS.escape === "function"
    ? CSS.escape(src)
    : src.replace(/["\\]/g, "\\$&");
}
 
/** Coerce an arbitrary `data-status` attribute to a known {@link ScriptStatus}. */
function normalizeStatus(raw: string | null): ScriptStatus {
  return raw === "idle" || raw === "loading" || raw === "error"
    ? raw
    : // `"ready"` and anything unrecognized (missing, empty, garbage) → ready:
      // a tag already in the DOM is assumed to have executed.
      "ready";
}
 
/**
 * Read the current shared status for `src` without subscribing. Returns `idle`
 * when nothing has been loaded for that source yet.
 *
 * Useful for reading a script's status imperatively (outside React) once some
 * component has begun loading it.
 *
 * @example
 * ```ts
 * if (getScriptStatus("https://cdn.example.com/sdk.js") === "ready") {
 *   window.SDK.init();
 * }
 * ```
 */
export function getScriptStatus(src: string): ScriptStatus {
  return registry.get(src)?.status ?? "idle";
}
 
/**
 * Update an entry's status, mirror it to the DOM node's `data-status` attribute,
 * and notify every subscriber so all mounted hooks re-render together.
 */
function setStatus(src: string, status: ScriptStatus): void {
  const entry = registry.get(src);
  Iif (!entry) return;
  entry.status = status;
  entry.element?.setAttribute("data-status", status);
  // Copy before iterating so a subscriber can't mutate the set mid-notify.
  for (const notify of Array.from(entry.subscribers)) notify();
}
 
/**
 * Ensure a script entry exists for `src`, creating (or adopting) the DOM node the
 * first time. Idempotent: repeated calls for the same `src` reuse the one entry,
 * which is what makes multiple components share a single `<script>` tag.
 */
function ensureEntry(
  src: string,
  attributes: Record<string, string> | undefined
): ScriptEntry {
  const existing = registry.get(src);
  if (existing) return existing;
 
  // Look for a tag another `useScript`, SSR, or a third-party lib already added.
  let element = document.querySelector<HTMLScriptElement>(
    `script[src="${escapeSelector(src)}"]`
  );
 
  let status: ScriptStatus;
  let createdHere = false;
 
  if (element) {
    // Adopt a pre-existing tag. If it carries a valid `data-status` we trust it;
    // otherwise we cannot know whether it already fired `load` (attaching a
    // listener now would never fire for an already-executed script), so we treat
    // an untracked tag as `ready`. Documented behavior, matching usehooks-ts.
    status = normalizeStatus(element.getAttribute("data-status"));
  } else {
    element = document.createElement("script");
    element.src = src;
    element.setAttribute("data-status", "loading");
    // Default to async unless the caller manages loading explicitly.
    if (!attributes || (!("async" in attributes) && !("defer" in attributes))) {
      element.async = true;
    }
    if (attributes) {
      for (const [name, value] of Object.entries(attributes)) {
        element.setAttribute(name, value);
      }
    }
    document.head.appendChild(element);
    status = "loading";
    createdHere = true;
  }
 
  const entry: ScriptEntry = {
    status,
    subscribers: new Set(),
    element,
    onLoad: null,
    onError: null,
  };
  registry.set(src, entry);
 
  // Only attach listeners when the resource may still resolve — i.e. a tag we
  // just created, or a pre-existing tag explicitly marked `loading`. An adopted
  // `ready`/`error` (or listener-less) tag gets none, since its event has passed.
  if (createdHere || status === "loading") {
    const onLoad = () => setStatus(src, "ready");
    const onError = () => setStatus(src, "error");
    element.addEventListener("load", onLoad);
    element.addEventListener("error", onError);
    entry.onLoad = onLoad;
    entry.onError = onError;
  }
 
  return entry;
}
 
/** Detach listeners, remove the DOM node, and drop the entry from the registry. */
function removeEntry(src: string): void {
  const entry = registry.get(src);
  Iif (!entry) return;
  Eif (entry.element) {
    Eif (entry.onLoad) entry.element.removeEventListener("load", entry.onLoad);
    Eif (entry.onError) entry.element.removeEventListener("error", entry.onError);
    entry.element.remove();
  }
  registry.delete(src);
}
 
/**
 * Subscribe a hook instance to `src`. Creates/adopts the shared `<script>` on the
 * first subscriber, registers `onStoreChange`, and returns an unsubscribe that
 * ref-count-aware-removes the tag when the last subscriber leaves (and only if
 * `removeOnUnmount` was requested).
 *
 * Designed to be the `subscribe` fn of `useSyncExternalStore`: SSR-safe (no-ops
 * without a DOM) and StrictMode-safe (double subscribe/unsubscribe converges to a
 * single tag with no leaked listeners).
 */
export function subscribeToScript(
  src: string,
  attributes: Record<string, string> | undefined,
  getRemoveOnUnmount: () => boolean,
  onStoreChange: () => void
): () => void {
  if (!isBrowser()) return () => {};
 
  const entry = ensureEntry(src, attributes);
  entry.subscribers.add(onStoreChange);
 
  return () => {
    entry.subscribers.delete(onStoreChange);
    // Read `removeOnUnmount` at cleanup time (not subscribe time) so toggling it
    // mid-mount takes effect without forcing a re-subscribe / re-injection.
    if (entry.subscribers.size === 0 && getRemoveOnUnmount()) {
      removeEntry(src);
    }
  };
}
 
/**
 * Test-only: clear the shared registry (does not remove DOM nodes). Package-internal
 * — intentionally not re-exported from `index.ts`, so it never reaches the public
 * or umbrella surface.
 */
export function __clearScriptRegistryForTests(): void {
  registry.clear();
}