diff --git a/scripts/test-use-fetch-stale-runs.mjs b/scripts/test-use-fetch-stale-runs.mjs new file mode 100644 index 00000000..77166639 --- /dev/null +++ b/scripts/test-use-fetch-stale-runs.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +let activeHost = null; + +const fakeReact = { + useCallback: (...args) => activeHost.useCallback(...args), + useEffect: (...args) => activeHost.useEffect(...args), + useMemo: (...args) => activeHost.useMemo(...args), + useRef: (...args) => activeHost.useRef(...args), + useState: (...args) => activeHost.useState(...args), +}; + +const windowShim = { + electron: { + httpRequest: () => Promise.reject(new Error('httpRequest stub not configured')), + }, +}; + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request === 'react') return fakeReact; + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + Array, + Blob, + console, + Date, + Error, + FormData, + Headers, + JSON, + Map, + Math, + module, + Object, + Promise, + queueMicrotask, + RegExp, + require: localRequire, + setTimeout, + clearTimeout, + String, + Symbol, + URL, + URLSearchParams, + window: windowShim, + exports: module.exports, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +class HookHost { + constructor(renderHook) { + this.renderHook = renderHook; + this.hookIndex = 0; + this.hooks = []; + this.pendingEffects = []; + this.output = undefined; + this.isRendering = false; + this.isFlushingEffects = false; + this.needsRender = false; + this.unmounted = false; + } + + render() { + if (this.unmounted) return this.output; + this.hookIndex = 0; + this.pendingEffects = []; + this.isRendering = true; + const previousHost = activeHost; + activeHost = this; + try { + this.output = this.renderHook(); + } finally { + activeHost = previousHost; + this.isRendering = false; + } + this.flushEffects(); + return this.output; + } + + flushEffects() { + this.isFlushingEffects = true; + try { + for (const { index, effect } of this.pendingEffects) { + const record = this.hooks[index]; + if (record.cleanup) record.cleanup(); + const cleanup = effect(); + record.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + } finally { + this.isFlushingEffects = false; + } + + if (this.needsRender && !this.unmounted) { + this.needsRender = false; + this.render(); + } + } + + scheduleRender() { + if (this.unmounted) return; + if (this.isRendering || this.isFlushingEffects) { + this.needsRender = true; + return; + } + this.render(); + } + + useState(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { + state: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + const record = this.hooks[index]; + const next = typeof nextValue === 'function' ? nextValue(record.state) : nextValue; + if (Object.is(record.state, next)) return; + record.state = next; + this.scheduleRender(); + }; + return [this.hooks[index].state, setState]; + } + + useRef(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { current: initialValue }; + } + return this.hooks[index]; + } + + useEffect(effect, deps) { + const index = this.hookIndex++; + const record = this.hooks[index] || {}; + const changed = !record.deps || !depsEqual(record.deps, deps); + this.hooks[index] = { ...record, deps }; + if (changed) { + this.pendingEffects.push({ index, effect }); + } + } + + useCallback(callback, deps) { + return this.useMemo(() => callback, deps); + } + + useMemo(factory, deps) { + const index = this.hookIndex++; + const record = this.hooks[index]; + if (record && depsEqual(record.deps, deps)) return record.value; + const value = factory(); + this.hooks[index] = { value, deps }; + return value; + } + + unmount() { + this.unmounted = true; + for (const record of this.hooks) { + if (record?.cleanup) { + record.cleanup(); + record.cleanup = undefined; + } + } + } +} + +function depsEqual(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps || previousDeps.length !== nextDeps.length) return false; + return previousDeps.every((value, index) => Object.is(value, nextDeps[index])); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function httpResponse(body, status = 200) { + return { + status, + statusText: status >= 200 && status < 300 ? 'OK' : 'Error', + headers: { 'content-type': 'application/json' }, + url: 'https://api.test/items', + bodyText: JSON.stringify(body), + }; +} + +const { useFetch } = loadTsModule('src/renderer/src/raycast-api/hooks/use-fetch.ts'); + +test('useFetch keeps the latest revalidate result when an older request resolves later', async () => { + const pending = []; + const requestedUrls = []; + const onDataCalls = []; + + windowShim.electron.httpRequest = (request) => { + requestedUrls.push(request.url); + const run = deferred(); + pending.push(run); + return run.promise; + }; + + const host = new HookHost(() => useFetch('https://api.test/items', { + onData: (data) => onDataCalls.push(data.value), + })); + + host.render(); + await flushAsync(); + assert.equal(pending.length, 1); + + host.output.revalidate(); + await flushAsync(); + assert.equal(pending.length, 2); + assert.deepEqual(requestedUrls, ['https://api.test/items', 'https://api.test/items']); + + pending[1].resolve(httpResponse({ value: 'latest' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(host.output.isLoading, false); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve(httpResponse({ value: 'stale' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(host.output.error, undefined); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useFetch refetches when a function URL resolves to a new request input', async () => { + const pending = []; + const requestedUrls = []; + let query = 'one'; + + windowShim.electron.httpRequest = (request) => { + requestedUrls.push(request.url); + const run = deferred(); + pending.push(run); + return run.promise; + }; + + const host = new HookHost(() => useFetch(() => `https://api.test/search?q=${query}`)); + + host.render(); + await flushAsync(); + assert.equal(pending.length, 1); + assert.deepEqual(requestedUrls, ['https://api.test/search?q=one']); + + query = 'two'; + host.render(); + await flushAsync(); + assert.equal(pending.length, 2); + assert.deepEqual(requestedUrls, [ + 'https://api.test/search?q=one', + 'https://api.test/search?q=two', + ]); + + pending[1].resolve(httpResponse({ value: 'latest' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(pending.length, 2); + + pending[0].resolve(httpResponse({ value: 'stale' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(pending.length, 2); + + host.unmount(); +}); diff --git a/src/renderer/src/raycast-api/hooks/use-fetch.ts b/src/renderer/src/raycast-api/hooks/use-fetch.ts index 95f882d0..ac9a0022 100644 --- a/src/renderer/src/raycast-api/hooks/use-fetch.ts +++ b/src/renderer/src/raycast-api/hooks/use-fetch.ts @@ -46,10 +46,13 @@ export function useFetch( const [error, setError] = useState(undefined); const mountedRef = useRef(true); + const runIdRef = useRef(0); + const lastInitialRequestKeyRef = useRef(undefined); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + runIdRef.current += 1; }; }, []); @@ -58,17 +61,26 @@ export function useFetch( urlRef.current = url; optionsRef.current = options; - const fetchData = useCallback(async (pageNum: number, currentCursor?: string) => { + const resolveUrl = useCallback(( + requestUrl: typeof url, + pageNum: number, + currentCursor?: string, + ) => typeof requestUrl === 'function' + ? requestUrl({ page: pageNum, cursor: currentCursor, lastItem: undefined }) + : requestUrl, []); + + const fetchData = useCallback(async (pageNum: number, currentCursor?: string, resolvedUrlOverride?: string) => { const opts = optionsRef.current; if (opts?.execute === false || !mountedRef.current) return; + const runId = ++runIdRef.current; + const isCurrentRun = () => mountedRef.current && runIdRef.current === runId; + setIsLoading(true); setError(undefined); try { - const resolvedUrl = typeof urlRef.current === 'function' - ? urlRef.current({ page: pageNum, cursor: currentCursor, lastItem: undefined }) - : urlRef.current; + const resolvedUrl = resolvedUrlOverride ?? resolveUrl(urlRef.current, pageNum, currentCursor); const ipcRes = await window.electron.httpRequest({ url: resolvedUrl, @@ -76,6 +88,7 @@ export function useFetch( headers: opts?.headers, body: normalizeRequestBody(opts?.body) as string | undefined, }); + if (!isCurrentRun()) return; const res = { ok: ipcRes.status >= 200 && ipcRes.status < 300, @@ -90,9 +103,10 @@ export function useFetch( if (!res.ok) throw new Error(`HTTP ${res.status}`); const parsed = opts?.parseResponse ? await opts.parseResponse(res) : await res.json(); - if (!mountedRef.current) return; + if (!isCurrentRun()) return; const mapped = opts?.mapResult ? opts.mapResult(parsed) : parsed; + if (!isCurrentRun()) return; if (mapped && typeof mapped === 'object' && 'data' in mapped) { const paginatedResult = mapped as { data: T; hasMore?: boolean; cursor?: string }; setHasMore(paginatedResult.hasMore ?? false); @@ -112,16 +126,15 @@ export function useFetch( opts?.onData?.(mapped as T); } } catch (err) { - if (!mountedRef.current) return; + if (!isCurrentRun()) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); opts?.onError?.(e); } finally { - if (mountedRef.current) setIsLoading(false); + if (isCurrentRun()) setIsLoading(false); } - }, []); + }, [resolveUrl]); - const urlString = typeof url === 'string' ? url : 'function'; const optionsKey = useMemo(() => { try { return JSON.stringify({ @@ -137,16 +150,32 @@ export function useFetch( useEffect(() => { if (options?.execute === false) { + runIdRef.current += 1; + lastInitialRequestKeyRef.current = undefined; setIsLoading(false); setError(undefined); setAllData(options?.initialData); return; } + + let initialResolvedUrl: string | undefined; + let initialUrlKey: string; + try { + initialResolvedUrl = resolveUrl(url, 0, undefined); + initialUrlKey = initialResolvedUrl; + } catch (err) { + initialUrlKey = `error:${err instanceof Error ? err.message : String(err)}`; + } + + const initialRequestKey = `${optionsKey}\n${initialUrlKey}`; + if (lastInitialRequestKeyRef.current === initialRequestKey) return; + lastInitialRequestKeyRef.current = initialRequestKey; + setPage(0); setCursor(undefined); setAllData(options?.initialData); - fetchData(0, undefined); - }, [fetchData, urlString, optionsKey]); + fetchData(0, undefined, initialResolvedUrl); + }, [fetchData, url, optionsKey]); const revalidate = useCallback(() => { setPage(0);