From 6b3344c710515ecab919e7587c0df85383d8aaa5 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:37:56 +0200 Subject: [PATCH] perf(utils): guard useStreamJSON lifecycle --- scripts/test-use-stream-json-lifecycle.mjs | 365 ++++++++++++++++++ .../src/raycast-api/hooks/use-stream-json.ts | 126 +++++- 2 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 scripts/test-use-stream-json-lifecycle.mjs diff --git a/scripts/test-use-stream-json-lifecycle.mjs b/scripts/test-use-stream-json-lifecycle.mjs new file mode 100644 index 00000000..5e18d6be --- /dev/null +++ b/scripts/test-use-stream-json-lifecycle.mjs @@ -0,0 +1,365 @@ +#!/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'); +const hookPath = path.resolve('src/renderer/src/raycast-api/hooks/use-stream-json.ts'); + +let activeHost = null; +let activeFetch = 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 { useStreamJSON } = loadUseStreamJson(); + +function loadUseStreamJson() { + const source = fs.readFileSync(hookPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: hookPath, + }); + + const module = { exports: {} }; + const sandbox = { + AbortController, + AbortSignal, + Array, + Blob, + console, + Error, + FormData, + Headers, + JSON, + Map, + Math, + module, + Object, + Promise, + RegExp, + Request, + Response, + String, + URL, + URLSearchParams, + exports: module.exports, + fetch: (...args) => { + if (!activeFetch) throw new Error('test fetch is not installed'); + return activeFetch(...args); + }, + queueMicrotask, + require: (request) => { + if (request === 'react') return fakeReact; + return require(request); + }, + setTimeout, + clearTimeout, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: hookPath }); + 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; + this.stateUpdatesAfterUnmount = 0; + } + + 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; + if (this.unmounted) { + this.stateUpdatesAfterUnmount += 1; + 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 createFetchMock() { + const requests = []; + + return { + requests, + fetch(url, init = {}) { + const pending = deferred(); + const request = { + url, + init, + promise: pending.promise, + resolveJson(value, responseOptions) { + pending.resolve(jsonResponse(value, responseOptions)); + }, + reject(error) { + pending.reject(error); + }, + }; + requests.push(request); + return pending.promise; + }, + }; +} + +function jsonResponse(value, { ok = true, status = 200 } = {}) { + return { + ok, + status, + async json() { + return value; + }, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsync() { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve(); + } +} + +test('useStreamJSON keeps the latest revalidation result and callbacks', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onData = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onData: (item) => onData.push(item.id), + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + + fetchMock.requests[1].resolveJson([{ id: 'latest' }]); + await flushAsync(); + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + assert.deepEqual(onData, ['latest']); + + fetchMock.requests[0].resolveJson([{ id: 'stale' }]); + await flushAsync(); + + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + assert.deepEqual(onData, ['latest']); + assert.equal(fetchMock.requests[0].init.signal?.aborted, true, 'previous fetch signal should be aborted'); + + host.unmount(); +}); + +test('useStreamJSON suppresses stale errors from older runs', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onError = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onError: (error) => onError.push(error.message), + })); + + host.render(); + await flushAsync(); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + + fetchMock.requests[1].resolveJson([{ id: 'latest' }]); + await flushAsync(); + fetchMock.requests[0].reject(new Error('stale failure')); + await flushAsync(); + + assert.equal(host.output.error, undefined); + assert.deepEqual(onError, []); + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + + host.unmount(); +}); + +test('useStreamJSON aborts and ignores active work after unmount', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onData = []; + const onError = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onData: (item) => onData.push(item.id), + onError: (error) => onError.push(error.message), + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + + host.unmount(); + assert.equal(fetchMock.requests[0].init.signal?.aborted, true, 'active fetch signal should be aborted on unmount'); + + fetchMock.requests[0].resolveJson([{ id: 'late-after-unmount' }]); + await flushAsync(); + + assert.equal(host.stateUpdatesAfterUnmount, 0); + assert.deepEqual(onData, []); + assert.deepEqual(onError, []); +}); + +test('useStreamJSON composes caller signal with lifecycle aborts', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const callerController = new AbortController(); + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + signal: callerController.signal, + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + const firstSignal = fetchMock.requests[0].init.signal; + assert.ok(firstSignal instanceof AbortSignal, 'fetch should receive an abort signal'); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + assert.equal(firstSignal.aborted, true, 'lifecycle abort should cancel the previous composed signal'); + assert.equal(callerController.signal.aborted, false, 'lifecycle abort should not abort the caller-owned signal'); + + const secondSignal = fetchMock.requests[1].init.signal; + assert.ok(secondSignal instanceof AbortSignal, 'revalidated fetch should receive an abort signal'); + callerController.abort(); + assert.equal(secondSignal.aborted, true, 'caller abort should cancel the composed signal'); + + host.unmount(); +}); diff --git a/src/renderer/src/raycast-api/hooks/use-stream-json.ts b/src/renderer/src/raycast-api/hooks/use-stream-json.ts index 6983704e..e879d69d 100644 --- a/src/renderer/src/raycast-api/hooks/use-stream-json.ts +++ b/src/renderer/src/raycast-api/hooks/use-stream-json.ts @@ -5,21 +5,62 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +type UseStreamJSONOptions = RequestInit & { + filter?: (item: T) => boolean; + transform?: (item: any) => T; + dataPath?: string | RegExp; + pageSize?: number; + initialData?: T[]; + keepPreviousData?: boolean; + execute?: boolean; + onError?: (error: Error) => void; + onData?: (data: T) => void; + onWillExecute?: (args: [string, RequestInit]) => void; + failureToastOptions?: any; +}; + +function abortWithSignalReason(controller: AbortController, signal: AbortSignal) { + if (controller.signal.aborted) return; + try { + controller.abort(signal.reason); + } catch { + controller.abort(); + } +} + +function composeAbortSignal(lifecycleSignal: AbortSignal, callerSignal?: AbortSignal | null) { + const controller = new AbortController(); + const cleanups: Array<() => void> = []; + const observedSignals = new Set(); + + const observe = (signal?: AbortSignal | null) => { + if (!signal || observedSignals.has(signal)) return; + observedSignals.add(signal); + + if (signal.aborted) { + abortWithSignalReason(controller, signal); + return; + } + + const abort = () => abortWithSignalReason(controller, signal); + signal.addEventListener('abort', abort, { once: true }); + cleanups.push(() => signal.removeEventListener('abort', abort)); + }; + + observe(lifecycleSignal); + observe(callerSignal); + + return { + signal: controller.signal, + cleanup: () => { + for (const cleanup of cleanups) cleanup(); + }, + }; +} + export function useStreamJSON( url: string | Request, - options?: RequestInit & { - filter?: (item: T) => boolean; - transform?: (item: any) => T; - dataPath?: string | RegExp; - pageSize?: number; - initialData?: T[]; - keepPreviousData?: boolean; - execute?: boolean; - onError?: (error: Error) => void; - onData?: (data: T) => void; - onWillExecute?: (args: [string, RequestInit]) => void; - failureToastOptions?: any; - } + options?: UseStreamJSONOptions ) { const pageSize = options?.pageSize ?? 20; const [allItems, setAllItems] = useState(options?.initialData || []); @@ -27,22 +68,59 @@ export function useStreamJSON( const [error, setError] = useState(undefined); const [displayCount, setDisplayCount] = useState(pageSize); + const mountedRef = useRef(true); + const runIdRef = useRef(0); + const abortControllerRef = useRef(null); const optionsRef = useRef(options); optionsRef.current = options; + const abortCurrentRun = useCallback(() => { + const controller = abortControllerRef.current; + if (!controller) return; + controller.abort(); + abortControllerRef.current = null; + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + runIdRef.current += 1; + abortCurrentRun(); + }; + }, [abortCurrentRun]); + const fetchAndParse = useCallback(async () => { const opts = optionsRef.current; - if (opts?.execute === false) return; + const runId = runIdRef.current + 1; + runIdRef.current = runId; + abortCurrentRun(); + + if (opts?.execute === false || !mountedRef.current) return; + + const lifecycleController = new AbortController(); + const composedAbort = composeAbortSignal(lifecycleController.signal, opts?.signal); + const isCurrentRun = () => ( + mountedRef.current + && runIdRef.current === runId + && abortControllerRef.current === lifecycleController + ); + abortControllerRef.current = lifecycleController; setIsLoading(true); setError(undefined); try { const resolvedUrl = typeof url === 'string' ? url : url.url; - const res = await fetch(resolvedUrl, opts); + const fetchOptions: RequestInit = { + ...(opts || {}), + signal: composedAbort.signal, + }; + const res = await fetch(resolvedUrl, fetchOptions); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); + if (!isCurrentRun()) return; let items: any[]; if (opts?.dataPath) { @@ -59,17 +137,29 @@ export function useStreamJSON( if (!Array.isArray(items)) items = [items]; if (opts?.transform) items = items.map(opts.transform); if (opts?.filter) items = items.filter(opts.filter); + if (!isCurrentRun()) return; setAllItems(items as T[]); - items.forEach((item) => opts?.onData?.(item as T)); + for (const item of items) { + if (!isCurrentRun()) break; + opts?.onData?.(item as T); + } } catch (err) { + if (!isCurrentRun() || lifecycleController.signal.aborted) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); opts?.onError?.(e); } finally { - setIsLoading(false); + composedAbort.cleanup(); + const shouldFinishCurrentRun = isCurrentRun(); + if (abortControllerRef.current === lifecycleController) { + abortControllerRef.current = null; + } + if (shouldFinishCurrentRun) { + setIsLoading(false); + } } - }, [url]); + }, [url, abortCurrentRun]); useEffect(() => { fetchAndParse();