{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-geolocation",
  "title": "useGeolocation",
  "description": "Reads or continuously watches the browser's geolocation behind an explicit request()/stop() pair, with hydration-safe capability detection, a queried permission state, and separate denied / timeout / position-unavailable / unsupported outcomes.",
  "files": [
    {
      "path": "src/registry/hooks/use-geolocation.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * idle        — supported, nothing in flight, and permission is either granted or unknown.\n * prompt      — the Permissions API says \"never asked\": calling `request()` opens the system dialog.\n *               This is the signal an explain-before-you-ask card (feedback/permission-prompt) waits on.\n * loading     — one `getCurrentPosition` or one `watchPosition` is out, no fix back yet.\n * ready       — `coords` holds a fix (in watch mode every update lands back on this state).\n * denied      — the user or the browser refused. The browser will not ask again by itself; only site settings undo it.\n * unavailable — no Geolocation API in this environment at all (SSR, a non-HTTPS page, an old browser).\n */\nexport type GeolocationStatus = \"idle\" | \"prompt\" | \"loading\" | \"ready\" | \"denied\" | \"unavailable\"\n\n/** Why it failed. Branch UI copy on `kind`, never on `message` — that text comes from the browser and is not localised. */\nexport type GeolocationErrorKind = \"permission-denied\" | \"position-unavailable\" | \"timeout\" | \"unsupported\"\n\n/** A plain-object snapshot of `GeolocationCoordinates` — JSON-serialisable, safe to stash in localStorage. */\nexport interface GeolocationCoords {\n  latitude: number\n  longitude: number\n  /** Horizontal accuracy radius, in metres. Bigger means blurrier — cell/IP fixes are routinely kilometres wide. */\n  accuracy: number\n  /** The next four are `null` when the device does not report them — most desktop browsers give only lat/lon and accuracy. */\n  altitude: number | null\n  altitudeAccuracy: number | null\n  heading: number | null\n  speed: number | null\n}\n\nexport interface GeolocationErrorInfo {\n  kind: GeolocationErrorKind\n  /** `GeolocationPositionError.code` (1/2/3); 0 when the API itself is missing. */\n  code: number\n  /** The browser's own wording (can be empty, in which case a built-in fallback is used). **Not** UI copy. */\n  message: string\n}\n\nexport interface UseGeolocationOptions {\n  /** `true` tracks continuously with `watchPosition` (until `stop()` or unmount); `false` reads once with `getCurrentPosition`. Default `false`. */\n  watch?: boolean\n  /** Fire one request on mount. Default `false` — **never auto-requests by default**, so opening a page cannot pop the system dialog. */\n  immediate?: boolean\n  /** Ask for GPS-grade accuracy. More precise, slower, hungrier for battery. Default `false`. */\n  enableHighAccuracy?: boolean\n  /** Per-request timeout in ms. Default 10000 — tighter than the platform's \"never time out\", so `loading` cannot hang forever.\n   *  `Infinity` means \"effectively no timeout\": it is clamped to 0x7fffffff, see the clamping note below. */\n  timeout?: number\n  /** How stale a cached fix may be, in ms. Default 0 (always a fresh fix); `Infinity` takes whatever is cached. */\n  maximumAge?: number\n  /** Fires on every fix (many times over in watch mode). Held in a latest-ref, so it never enters a dependency array. */\n  onSuccess?: (coords: GeolocationCoords, timestamp: number) => void\n  /** Fires on every failure, including repeated timeouts during a watch. */\n  onError?: (error: GeolocationErrorInfo) => void\n}\n\nexport interface UseGeolocationResult {\n  /** The last successful fix; `null` if there has never been one. **Not** cleared when the status flips to `denied` — it is a stale value, decide for yourself whether it still counts. */\n  coords: GeolocationCoords | null\n  /** Timestamp of that fix (epoch ms, from the browser). */\n  timestamp: number | null\n  /** The last failure; cleared back to `null` on the next success. */\n  error: GeolocationErrorInfo | null\n  status: GeolocationStatus\n  /** Whether this browser exposes the Geolocation API. Always `false` on the server and on the hydration frame. */\n  isSupported: boolean\n  /** Start a request (a continuous watch when `watch` is true). Stable reference, safe in a dependency array. */\n  request: () => void\n  /** Stop the watch and void a single request still in flight. Stable reference. */\n  stop: () => void\n}\n\ninterface GeolocationState {\n  coords: GeolocationCoords | null\n  timestamp: number | null\n  error: GeolocationErrorInfo | null\n  status: GeolocationStatus\n}\n\nconst DEFAULT_TIMEOUT = 10000\n\n/** `PositionOptions.timeout` / `maximumAge` are WebIDL `unsigned long`: hand over `Infinity` and it converts to 0. */\nconst MAX_UNSIGNED_MS = 0x7fffffff\n\nconst ERROR_KIND_BY_CODE: Record<number, GeolocationErrorKind> = {\n  1: \"permission-denied\",\n  2: \"position-unavailable\",\n  3: \"timeout\",\n}\n\nconst FALLBACK_MESSAGE: Record<GeolocationErrorKind, string> = {\n  \"permission-denied\": \"Location permission was denied for this site.\",\n  \"position-unavailable\": \"The device could not determine a position right now.\",\n  timeout: \"The location request took too long and was aborted.\",\n  unsupported: \"The Geolocation API is not available here (it needs a secure HTTPS page).\",\n}\n\nconst UNSUPPORTED_ERROR: GeolocationErrorInfo = {\n  kind: \"unsupported\",\n  code: 0,\n  message: FALLBACK_MESSAGE.unsupported,\n}\n\nfunction toErrorInfo(error: GeolocationPositionError): GeolocationErrorInfo {\n  const kind = ERROR_KIND_BY_CODE[error.code] ?? \"position-unavailable\"\n  return { kind, code: error.code, message: error.message || FALLBACK_MESSAGE[kind] }\n}\n\nfunction snapshotCoords(coords: GeolocationCoordinates): GeolocationCoords {\n  return {\n    latitude: coords.latitude,\n    longitude: coords.longitude,\n    accuracy: coords.accuracy,\n    altitude: coords.altitude,\n    altitudeAccuracy: coords.altitudeAccuracy,\n    heading: coords.heading,\n    speed: coords.speed,\n  }\n}\n\n/** Where to rest when nothing is in flight. `denied` outranks everything — it is the one the user has to act on. */\nfunction restingStatus(permission: PermissionState | \"unknown\", hasCoords: boolean): GeolocationStatus {\n  if (permission === \"denied\") return \"denied\"\n  if (hasCoords) return \"ready\"\n  return permission === \"prompt\" ? \"prompt\" : \"idle\"\n}\n\nfunction detectSupport(): boolean {\n  return (\n    typeof navigator !== \"undefined\" &&\n    typeof navigator.geolocation !== \"undefined\" &&\n    typeof navigator.geolocation.getCurrentPosition === \"function\"\n  )\n}\n\nconst subscribeNoop = () => () => {}\n\n/**\n * Read or continuously track the browser's geolocation, folding the callback-style\n * `getCurrentPosition` / `watchPosition` APIs into one state machine plus two\n * commands (`request` / `stop`).\n *\n * **Feature detection never happens during render**: reading `navigator.geolocation`\n * in the render body makes the server (no navigator) and the client's first frame\n * disagree, which is a hydration error outright. `isSupported` goes through\n * `useSyncExternalStore`, whose server snapshot is always `false`; after hydration\n * React re-renders once with the real snapshot, without a warning.\n *\n * **Never auto-requests by default**: `immediate` defaults to `false`. The permission\n * dialog is a one-shot — pop it on page load and most people hit Block, after which\n * the browser never asks again. To explain before asking, pair this with\n * `feedback/permission-prompt`: `status === \"prompt\"` is exactly the \"this shot has\n * not been fired yet\" signal.\n *\n * **Permission state is queried, not guessed**: after mount it awaits\n * `navigator.permissions.query({ name: \"geolocation\" })` (the query itself opens no\n * dialog) and rests at `prompt` / `idle` / `denied` accordingly, then listens to its\n * `change` event, so a permission edited in site settings is picked up on its own.\n * When the Permissions API is missing or rejects this name (older Safari) it degrades\n * silently: it stays at `idle` and only the request itself reveals the answer.\n *\n * **A dismissed dialog and a permanent Block are different**: both arrive as\n * `PERMISSION_DENIED` (code 1). Assume the worst and show `denied`, then re-query the\n * permission: if the browser still reports `prompt`, the dialog was merely dismissed\n * and asking again is fair game, so the status is corrected back to `prompt` (the\n * error is kept, otherwise a failed request gives no feedback at all). Browsers that\n * cannot report a permission state stay conservatively at `denied`.\n *\n * **Late callbacks are always dropped**: `mountedRef` is set `true` in the effect body\n * (setting it false only in cleanup leaves a live instance permanently \"unmounted\"\n * under StrictMode's mount→cleanup→mount), and a token counter turns callbacks\n * superseded by `stop()` or a newer request into no-ops. `getCurrentPosition` has no\n * cancel API at all, so `stop()` can only keep its result from landing — a browser\n * limitation, not a shortcut taken here.\n */\nexport function useGeolocation(options: UseGeolocationOptions = {}): UseGeolocationResult {\n  const {\n    watch = false,\n    immediate = false,\n    enableHighAccuracy = false,\n    timeout = DEFAULT_TIMEOUT,\n    maximumAge = 0,\n    onSuccess,\n    onError,\n  } = options\n\n  // clamp the numbers: NaN / negatives make browser behaviour unpredictable, so fall back to the default.\n  // 0 is legal: `timeout: 0` + `maximumAge: Infinity` is the idiomatic \"cache only, fail instantly otherwise\".\n  // the upper bound has to be clamped by hand: both `PositionOptions` fields are WebIDL\n  // `unsigned long`, so handing over `Infinity` ToUint32s to **0** — \"never time out\" becomes\n  // \"time out immediately\", \"any cached fix will do\" becomes \"fresh fixes only\", exactly backwards.\n  // clamping to 0x7fffffff (~24.8 days) preserves the intent on every engine.\n  const safeTimeout =\n    Number.isNaN(timeout) || timeout < 0 ? DEFAULT_TIMEOUT : Math.min(timeout, MAX_UNSIGNED_MS)\n  const safeMaximumAge = Number.isNaN(maximumAge) || maximumAge < 0 ? 0 : Math.min(maximumAge, MAX_UNSIGNED_MS)\n\n  const isSupported = React.useSyncExternalStore(subscribeNoop, detectSupport, () => false)\n\n  const [state, setState] = React.useState<GeolocationState>(() => ({\n    coords: null,\n    timestamp: null,\n    error: null,\n    // immediate's loading is seated here rather than setState'd from an effect (which\n    // react-hooks/set-state-in-effect forbids); the effect only fires the request.\n    status: immediate ? \"loading\" : \"idle\",\n  }))\n\n  const mountedRef = React.useRef(false)\n  const watchIdRef = React.useRef<number | null>(null)\n  const tokenRef = React.useRef(0)\n  const permissionRef = React.useRef<PermissionState | \"unknown\">(\"unknown\")\n\n  // latest-ref: neither the callbacks nor the call-time options enter a dependency\n  // array, so an inline arrow or an inline options literal never tears the effect down\n  // and back up. The writes live in a post-render effect (no ref writes during render),\n  // see the dependency-array-less sync effect below.\n  const onSuccessRef = React.useRef(onSuccess)\n  const onErrorRef = React.useRef(onError)\n\n  const clearActiveWatch = React.useCallback(() => {\n    if (watchIdRef.current !== null) {\n      navigator.geolocation.clearWatch(watchIdRef.current)\n      watchIdRef.current = null\n    }\n  }, [])\n\n  const applyPermission = React.useCallback(\n    (permission: PermissionState) => {\n      permissionRef.current = permission\n      // permission revoked mid-watch (edited in site settings): this watch is dead.\n      // most browsers also deliver a PERMISSION_DENIED callback, but that is not\n      // guaranteed — tear it down here.\n      if (permission === \"denied\") clearActiveWatch()\n      setState(prev => {\n        // don't steal the status while a request is in flight; its own success/error callback closes it out.\n        if (prev.status === \"loading\") return prev\n        const next = restingStatus(permission, prev.coords !== null)\n        // only a real re-grant clears the old permission error. Back at \"prompt\" (Block\n        // flipped to Ask, or the dialog was merely dismissed) the error is kept —\n        // otherwise a failed request leaves no trace and the button looks broken.\n        const nextError = permission === \"granted\" && prev.error?.kind === \"permission-denied\" ? null : prev.error\n        if (next === prev.status && nextError === prev.error) return prev\n        return { ...prev, status: next, error: nextError }\n      })\n    },\n    [clearActiveWatch],\n  )\n\n  const refreshPermission = React.useCallback(() => {\n    if (typeof navigator === \"undefined\" || typeof navigator.permissions?.query !== \"function\") return\n    navigator.permissions.query({ name: \"geolocation\" }).then(\n      result => {\n        if (mountedRef.current) applyPermission(result.state)\n      },\n      () => {},\n    )\n  }, [applyPermission])\n\n  // the step that actually fires the request, with no setState in it — so the immediate\n  // effect can call it without breaking set-state-in-effect; the caller (request / the\n  // lazy initial value) owns the status.\n  const beginRequest = () => {\n    clearActiveWatch()\n    const token = ++tokenRef.current\n    const positionOptions: PositionOptions = {\n      enableHighAccuracy,\n      timeout: safeTimeout,\n      maximumAge: safeMaximumAge,\n    }\n\n    const handleSuccess = (position: GeolocationPosition) => {\n      // the component may have unmounted while the dialog was still open, or stop() may have superseded this callback.\n      if (!mountedRef.current || token !== tokenRef.current) return\n      const coords = snapshotCoords(position.coords)\n      permissionRef.current = \"granted\"\n      setState({ coords, timestamp: position.timestamp, error: null, status: \"ready\" })\n      onSuccessRef.current?.(coords, position.timestamp)\n    }\n\n    const handleError = (error: GeolocationPositionError) => {\n      if (!mountedRef.current || token !== tokenRef.current) return\n      const info = toErrorInfo(error)\n      if (info.kind === \"permission-denied\") {\n        permissionRef.current = \"denied\"\n        // a denied watch just repeats the same error; nothing worth keeping.\n        clearActiveWatch()\n        // a dismissed dialog and a permanent Block both report code 1. Show the worst\n        // case (denied) first, then re-ask the Permissions API: if it still says\n        // \"prompt\" the dialog was only dismissed and asking again is fine, so\n        // applyPermission corrects the status back to prompt (keeping the error) and the\n        // user is spared a site-settings walkthrough they never needed.\n        refreshPermission()\n      }\n      setState(prev => ({\n        ...prev,\n        error: info,\n        // timeout / position-unavailable leave permission untouched, so fall back to the\n        // resting status: still ready when an older fix exists (stale coords + error read\n        // as \"the last refresh failed\"), otherwise back to prompt / idle.\n        status: restingStatus(permissionRef.current, prev.coords !== null),\n      }))\n      onErrorRef.current?.(info)\n    }\n\n    if (watch) {\n      watchIdRef.current = navigator.geolocation.watchPosition(handleSuccess, handleError, positionOptions)\n    } else {\n      navigator.geolocation.getCurrentPosition(handleSuccess, handleError, positionOptions)\n    }\n  }\n\n  const beginRequestRef = React.useRef(beginRequest)\n  React.useEffect(() => {\n    onSuccessRef.current = onSuccess\n    onErrorRef.current = onError\n    beginRequestRef.current = beginRequest\n  })\n\n  React.useEffect(() => {\n    // set true on every mount: dev StrictMode runs mount → cleanup → mount, and\n    // flipping it false only in cleanup leaves the live instance looking unmounted.\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n      clearActiveWatch()\n    }\n  }, [clearActiveWatch])\n\n  // permission subscription: the query pops no dialog, it only asks what the state is right now.\n  React.useEffect(() => {\n    if (!isSupported || typeof navigator.permissions?.query !== \"function\") return\n\n    let cancelled = false\n    let status: PermissionStatus | null = null\n\n    const handleChange = (event: Event) => {\n      if (cancelled || !mountedRef.current) return\n      applyPermission((event.target as PermissionStatus).state)\n    }\n\n    navigator.permissions.query({ name: \"geolocation\" }).then(\n      result => {\n        if (cancelled || !mountedRef.current) return\n        status = result\n        status.addEventListener(\"change\", handleChange)\n        applyPermission(result.state)\n      },\n      () => {\n        // older Safari rejects this permission name outright. Degrade silently: rest at\n        // idle and let request() report the real answer.\n      },\n    )\n\n    return () => {\n      cancelled = true\n      status?.removeEventListener(\"change\", handleChange)\n    }\n  }, [isSupported, applyPermission])\n\n  // immediate: the lazy initial value already parked the status at loading; this only fires the request.\n  React.useEffect(() => {\n    if (!immediate || !isSupported) return\n    beginRequestRef.current()\n  }, [immediate, isSupported])\n\n  const request = React.useCallback(() => {\n    // detect here instead of reading `isSupported` above: `request()` runs during an\n    // event, where detection is always accurate. Reading state / a ref is the trap — a\n    // consumer calling request() from a **child's** mount effect (child effects run\n    // before the parent's) would read an unwritten ref and report a capable browser as\n    // unavailable.\n    if (!detectSupport()) {\n      setState(prev => ({ ...prev, status: \"unavailable\", error: UNSUPPORTED_ERROR }))\n      onErrorRef.current?.(UNSUPPORTED_ERROR)\n      return\n    }\n    setState(prev => ({ ...prev, status: \"loading\", error: null }))\n    beginRequestRef.current()\n  }, [])\n\n  const stop = React.useCallback(() => {\n    clearActiveWatch()\n    // getCurrentPosition has no cancel API: bump the token so a late result cannot land.\n    tokenRef.current += 1\n    setState(prev =>\n      prev.status === \"loading\"\n        ? { ...prev, status: restingStatus(permissionRef.current, prev.coords !== null) }\n        : prev,\n    )\n  }, [clearActiveWatch])\n\n  return {\n    coords: state.coords,\n    timestamp: state.timestamp,\n    error: state.error,\n    // unsupported means the status is always unavailable — SSR and the hydration frame take this path too, so idle never flashes.\n    status: isSupported ? state.status : \"unavailable\",\n    isSupported,\n    request,\n    stop,\n  }\n}\n\nexport default useGeolocation\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}