ChemPal Documentation - v1.6.0
    Preparing search index...
    • Surfaces a pending extension update, picking the right detection strategy for how the extension was installed.

      On Web Store installs the browser stages the update itself and the service worker records it (see src/service-worker.ts); this hook only reads that record and watches for it live. On manual/unpacked installs it polls the GitHub releases API, throttled to one request per UPDATE_CHECK_INTERVAL_MS across all popup opens — the timestamp is written before the request so a popup dismissed mid-flight can't reset the throttle and walk into GitHub's unauthenticated rate limit.

      Dismissal is recorded per version, so a new release prompts again.

      Returns UseUpdateAvailable

      The pending UpdateNotice plus dismiss and applyUpdate actions.

      const { notice, dismiss, applyUpdate } = useUpdateAvailable();
      // notice → { version: "1.3.0", source: "manual", releaseUrl: "https://github.com/…" }
      applyUpdate(); // opens the release page
      export function useUpdateAvailable(): UseUpdateAvailable {
      const [notice, setNotice] = useState<UpdateNotice | undefined>(undefined);
      const dismissedRef = useRef<string | undefined>(undefined);

      useEffect(() => {
      let cancelled = false;

      /** Reads stored state and decides whether to prompt, polling if due. */
      const check = async () => {
      try {
      const stored = await cstorage.local.get([CACHE.UPDATE_CHECK, CACHE.UPDATE_PENDING]);
      const state: UpdateCheckState = isUpdateCheckState(stored[CACHE.UPDATE_CHECK])
      ? stored[CACHE.UPDATE_CHECK]
      : {};
      dismissedRef.current = state.dismissedVersion;

      // Web Store: the browser already staged an update, so there's nothing to
      // discover — but onUpdateAvailable reports only a version, so the notes
      // still have to be looked up (once per staged version).
      if (getInstallSource() === 'webstore') {
      const pending = stored[CACHE.UPDATE_PENDING];
      if (!isUpdatePendingState(pending) || pending.version === state.dismissedVersion) return;

      // Show the prompt immediately; the notes fill in behind it if they
      // aren't already cached, so a slow lookup never delays the prompt.
      const cached = readCachedNotes(state, pending.version);
      if (!cancelled) {
      setNotice({
      version: pending.version,
      source: 'webstore',
      releaseUrl: cached?.releaseUrl,
      notes: cached?.notes ?? [],
      });
      }
      if (cached) return;

      const fetched = await fetchNotesOnce(state, pending.version);
      if (!cancelled && fetched) {
      setNotice({ version: pending.version, source: 'webstore', ...fetched });
      }
      return;
      }

      // Manual install, still inside the throttle window: reuse the last result.
      const elapsed = Date.now() - (state.lastCheckedAt ?? 0);
      if (elapsed < UPDATE_CHECK_INTERVAL_MS) {
      const cachedVersion = state.latestVersion;
      if (!cancelled && cachedVersion && shouldPrompt(cachedVersion, state.dismissedVersion)) {
      setNotice({
      version: cachedVersion,
      source: 'manual',
      releaseUrl: state.releaseUrl,
      notes: readCachedNotes(state, cachedVersion)?.notes ?? [],
      });
      }
      return;
      }

      const update = await pollOnce(state);
      if (!cancelled && update && update.version !== state.dismissedVersion) {
      setNotice({ ...update, source: 'manual' });
      }
      } catch (error) {
      console.error('Failed to check for updates:', { error });
      }
      };

      void check();
      return () => {
      cancelled = true;
      };
      }, []);

      // Catch a Web Store update staged while the popup is already open.
      useEffect(() => {
      if (getInstallSource() !== 'webstore') return;
      const listener = (changes: Record<string, chrome.storage.StorageChange>, area: string) => {
      if (area !== 'local') return;
      const change = changes[CACHE.UPDATE_PENDING];
      if (!change || !isUpdatePendingState(change.newValue)) return;
      if (change.newValue.version === dismissedRef.current) return;
      // Notes are looked up by the mount effect on the next open; showing the
      // prompt without them is better than delaying it behind a fetch.
      setNotice({ version: change.newValue.version, source: 'webstore', notes: [] });
      };
      cstorage.onChanged.addListener(listener);
      return () => cstorage.onChanged.removeListener(listener);
      }, []);

      const dismiss = useCallback(() => {
      const dismissed = notice?.version;
      setNotice(undefined);
      if (!dismissed) return;
      dismissedRef.current = dismissed;
      void (async () => {
      try {
      const stored = await cstorage.local.get([CACHE.UPDATE_CHECK]);
      const state: UpdateCheckState = isUpdateCheckState(stored[CACHE.UPDATE_CHECK])
      ? stored[CACHE.UPDATE_CHECK]
      : {};
      // Leave UPDATE_PENDING alone — it belongs to the service worker.
      await cstorage.local.set({
      [CACHE.UPDATE_CHECK]: { ...state, dismissedVersion: dismissed },
      });
      } catch (error) {
      console.error('Failed to record update dismissal:', { error });
      }
      })();
      }, [notice]);

      const applyUpdate = useCallback(() => {
      if (!notice) return;
      // Reloading tears down this popup along with the extension.
      if (notice.source === 'webstore') {
      chrome.runtime.reload();
      return;
      }
      if (notice.releaseUrl) {
      chrome.tabs.create({ url: notice.releaseUrl, active: true });
      }
      }, [notice]);

      return { notice, dismiss, applyUpdate };
      }