From 953b043934e4145fce4019b972bb537f8dc5b890 Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:03:48 +0530 Subject: [PATCH] fix(raycast): preserve input over deferred selection --- .github/workflows/ci.yml | 8 + apps/raycast-extension/package.json | 1 + apps/raycast-extension/src/add-memory.tsx | 27 +-- .../raycast-extension/src/deferred-prefill.ts | 86 +++++++ .../raycast-extension/src/search-memories.tsx | 44 ++-- .../tests/deferred-prefill.test.ts | 211 ++++++++++++++++++ .../tests/fixtures/search-memories-command.ts | 105 +++++++++ .../tests/search-memories.test.ts | 25 +++ 8 files changed, 470 insertions(+), 37 deletions(-) create mode 100644 apps/raycast-extension/src/deferred-prefill.ts create mode 100644 apps/raycast-extension/tests/deferred-prefill.test.ts create mode 100644 apps/raycast-extension/tests/fixtures/search-memories-command.ts create mode 100644 apps/raycast-extension/tests/search-memories.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..f3161df7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,13 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Install Raycast extension dependencies + working-directory: apps/raycast-extension + run: npm ci --ignore-scripts + + - name: Test Raycast extension + working-directory: apps/raycast-extension + run: npm test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json index ed821577..8a9f441b 100644 --- a/apps/raycast-extension/package.json +++ b/apps/raycast-extension/package.json @@ -64,6 +64,7 @@ "typescript": "^5.8.2" }, "scripts": { + "test": "bun test tests", "build": "ray build", "dev": "ray develop", "fix-lint": "ray lint --fix", diff --git a/apps/raycast-extension/src/add-memory.tsx b/apps/raycast-extension/src/add-memory.tsx index 812c1c4a..54204d35 100644 --- a/apps/raycast-extension/src/add-memory.tsx +++ b/apps/raycast-extension/src/add-memory.tsx @@ -8,8 +8,9 @@ import { Icon, getSelectedText, } from "@raycast/api" -import { useState, useEffect } from "react" +import { useEffect, useRef, useState } from "react" import { addMemory, fetchProjects } from "./api" +import { createDeferredPrefillOwner } from "./deferred-prefill" import { usePromise } from "@raycast/utils" import { withSupermemory } from "./withSupermemory" @@ -23,24 +24,18 @@ function Command() { const [isSubmitting, setIsSubmitting] = useState(false) const [initialContent, setInitialContent] = useState("") const { pop } = useNavigation() + const prefillOwnerRef = useRef< + ReturnType | undefined + >(undefined) + prefillOwnerRef.current ??= createDeferredPrefillOwner(setInitialContent) + const prefillOwner = prefillOwnerRef.current const { isLoading, data: projects = [] } = usePromise(fetchProjects) useEffect(() => { - async function loadSelectedText() { - try { - const selectedText = await getSelectedText() - if (selectedText) { - setInitialContent(selectedText) - } - } catch { - // No text selected or error getting selected text - silently fail - // User can still manually enter content - } - } - - loadSelectedText() - }, []) + const request = prefillOwner.start(getSelectedText) + return request.cancel + }, [prefillOwner]) async function handleSubmit(values: FormValues) { if (!values.content.trim()) { @@ -91,7 +86,7 @@ function Command() { value={initialContent} placeholder="Enter the memory content..." info="The main content of your memory. This is required." - onChange={(value) => setInitialContent(value)} + onChange={prefillOwner.updateFromUser} /> void, +) { + let prefillOwnsValue = true + + return { + updateFromUser(value: string) { + prefillOwnsValue = false + applyValue(value) + }, + start(readValue: () => Promise) { + let active = true + const completion = (async () => { + try { + const value = await readValue() + if (active && prefillOwnsValue && value) { + applyValue(value) + } + } catch { + // Selection is unavailable; keep the current value unchanged. + } + })() + + return { + completion, + cancel() { + active = false + }, + } + }, + } +} + +export const SEARCH_QUERY_DEBOUNCE_MS = 300 + +type CancelScheduledQuery = () => void +type ScheduleQuery = ( + callback: () => void, + delayMs: number, +) => CancelScheduledQuery + +const scheduleQuery: ScheduleQuery = (callback, delayMs) => { + const timeout = setTimeout(callback, delayMs) + return () => clearTimeout(timeout) +} + +export function createSearchInputAdapter( + applySearchText: (value: string) => void, + applySearchQuery: (value: string) => void, + schedule: ScheduleQuery = scheduleQuery, +) { + let cancelScheduledQuery: CancelScheduledQuery | undefined + + const applyValue = (value: string) => { + applySearchText(value) + cancelScheduledQuery?.() + cancelScheduledQuery = undefined + + if (!value.trim()) { + applySearchQuery(value) + return + } + + cancelScheduledQuery = schedule(() => { + cancelScheduledQuery = undefined + applySearchQuery(value) + }, SEARCH_QUERY_DEBOUNCE_MS) + } + + const prefillOwner = createDeferredPrefillOwner(applyValue) + + return { + getListProps(searchText: string) { + return { + onSearchTextChange: prefillOwner.updateFromUser, + searchText, + throttle: false as const, + } + }, + startPrefill: prefillOwner.start, + cancelPendingQuery() { + cancelScheduledQuery?.() + cancelScheduledQuery = undefined + }, + } +} diff --git a/apps/raycast-extension/src/search-memories.tsx b/apps/raycast-extension/src/search-memories.tsx index dbc47ceb..1d5a91ec 100644 --- a/apps/raycast-extension/src/search-memories.tsx +++ b/apps/raycast-extension/src/search-memories.tsx @@ -8,8 +8,9 @@ import { Toast, getSelectedText, } from "@raycast/api" -import { useState, useEffect } from "react" +import { useEffect, useRef, useState } from "react" import { searchMemories, type SearchResult } from "./api" +import { createSearchInputAdapter } from "./deferred-prefill" import { usePromise } from "@raycast/utils" import { withSupermemory } from "./withSupermemory" @@ -53,23 +54,25 @@ const truncateContent = (content: string, maxLength = 100) => { } export default withSupermemory(Command) -function Command() { +export function Command() { const [searchText, setSearchText] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const searchInputRef = useRef< + ReturnType | undefined + >(undefined) + searchInputRef.current ??= createSearchInputAdapter( + setSearchText, + setSearchQuery, + ) + const searchInput = searchInputRef.current useEffect(() => { - async function loadSelectedText() { - try { - const selectedText = await getSelectedText() - if (selectedText) { - setSearchText(selectedText) - } - } catch { - // No text selected or error getting selected text - silently fail - } + const request = searchInput.startPrefill(getSelectedText) + return () => { + request.cancel() + searchInput.cancelPendingQuery() } - - loadSelectedText() - }, []) + }, [searchInput]) const { isLoading, data: searchResults = [] } = usePromise( async (query: string) => { @@ -89,7 +92,7 @@ function Command() { } return results }, - [searchText], + [searchQuery], ) const formatDate = (dateString: string) => { @@ -106,14 +109,13 @@ function Command() { } } - const hasSearched = !isLoading && !searchResults.length + const isSearching = isLoading || searchText !== searchQuery + const hasSearched = !isSearching && !searchResults.length return ( {hasSearched && !searchText.trim() ? ( - ) : isLoading && searchText.trim() ? ( + ) : isSearching && searchText.trim() ? ( () { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + + return { promise, reject, resolve } +} + +function createManualScheduler() { + const tasks: Array<{ + active: boolean + callback: () => void + delayMs: number + }> = [] + + return { + schedule(callback: () => void, delayMs: number) { + const task = { active: true, callback, delayMs } + tasks.push(task) + return () => { + task.active = false + } + }, + flush() { + for (const task of tasks) { + if (task.active) { + task.active = false + task.callback() + } + } + }, + get activeCount() { + return tasks.filter((task) => task.active).length + }, + get delays() { + return tasks.map((task) => task.delayMs) + }, + } +} + +describe("createDeferredPrefillOwner", () => { + test("applies an untouched prefill", async () => { + let value = "" + const deferred = createDeferred() + const owner = createDeferredPrefillOwner((nextValue) => { + value = nextValue + }) + const request = owner.start(() => deferred.promise) + + deferred.resolve("selected text") + await request.completion + + expect(value).toBe("selected text") + }) + + test("does not overwrite text entered while the prefill is pending", async () => { + let value = "" + const deferred = createDeferred() + const owner = createDeferredPrefillOwner((nextValue) => { + value = nextValue + }) + const request = owner.start(() => deferred.promise) + + owner.updateFromUser("typed text") + deferred.resolve("selected text") + await request.completion + + expect(value).toBe("typed text") + }) + + test("does not restore a prefill after typed text is cleared", async () => { + let value = "" + const deferred = createDeferred() + const owner = createDeferredPrefillOwner((nextValue) => { + value = nextValue + }) + const request = owner.start(() => deferred.promise) + + owner.updateFromUser("typed text") + owner.updateFromUser("") + deferred.resolve("selected text") + await request.completion + + expect(value).toBe("") + }) + + test("keeps the current value when the prefill rejects", async () => { + let value = "current text" + const deferred = createDeferred() + const owner = createDeferredPrefillOwner((nextValue) => { + value = nextValue + }) + const request = owner.start(() => deferred.promise) + + deferred.reject(new Error("selection unavailable")) + await request.completion + + expect(value).toBe("current text") + }) + + test("does not apply a prefill after its consumer unmounts", async () => { + let value = "" + const deferred = createDeferred() + const owner = createDeferredPrefillOwner((nextValue) => { + value = nextValue + }) + const request = owner.start(() => deferred.promise) + + request.cancel() + deferred.resolve("selected text") + await request.completion + + expect(value).toBe("") + }) + + test("cancels only the current request", async () => { + let value = "" + const firstDeferred = createDeferred() + const secondDeferred = createDeferred() + const owner = createDeferredPrefillOwner((nextValue) => { + value = nextValue + }) + const firstRequest = owner.start(() => firstDeferred.promise) + + firstRequest.cancel() + const secondRequest = owner.start(() => secondDeferred.promise) + firstDeferred.resolve("stale selected text") + secondDeferred.resolve("selected text") + await Promise.all([firstRequest.completion, secondRequest.completion]) + + expect(value).toBe("selected text") + }) +}) + +describe("createSearchInputAdapter", () => { + test("claims input ownership before a deferred prefill can resolve", async () => { + let searchText = "" + let searchQuery = "" + const scheduler = createManualScheduler() + const selectedText = createDeferred() + const adapter = createSearchInputAdapter( + (value) => { + searchText = value + }, + (value) => { + searchQuery = value + }, + scheduler.schedule, + ) + const request = adapter.startPrefill(() => selectedText.promise) + const listProps = adapter.getListProps(searchText) + + expect(listProps.throttle).toBe(false) + listProps.onSearchTextChange("typed text") + expect(searchText).toBe("typed text") + expect(searchQuery).toBe("") + + selectedText.resolve("selected text") + await request.completion + expect(searchText).toBe("typed text") + + scheduler.flush() + expect(searchQuery).toBe("typed text") + expect(scheduler.delays).toEqual([SEARCH_QUERY_DEBOUNCE_MS]) + }) + + test("coalesces rapid input into one search query", () => { + const searchQueries: string[] = [] + const scheduler = createManualScheduler() + const adapter = createSearchInputAdapter( + () => {}, + (value) => searchQueries.push(value), + scheduler.schedule, + ) + const { onSearchTextChange } = adapter.getListProps("") + + onSearchTextChange("t") + onSearchTextChange("ty") + onSearchTextChange("typed") + expect(scheduler.activeCount).toBe(1) + + scheduler.flush() + expect(searchQueries).toEqual(["typed"]) + }) + + test("cancels a pending search query on unmount", () => { + const searchQueries: string[] = [] + const scheduler = createManualScheduler() + const adapter = createSearchInputAdapter( + () => {}, + (value) => searchQueries.push(value), + scheduler.schedule, + ) + + adapter.getListProps("").onSearchTextChange("typed text") + adapter.cancelPendingQuery() + scheduler.flush() + + expect(searchQueries).toEqual([]) + }) +}) diff --git a/apps/raycast-extension/tests/fixtures/search-memories-command.ts b/apps/raycast-extension/tests/fixtures/search-memories-command.ts new file mode 100644 index 00000000..e3788320 --- /dev/null +++ b/apps/raycast-extension/tests/fixtures/search-memories-command.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict" +import { mock } from "bun:test" + +function RaycastComponent() { + return null +} + +const stateUpdates: unknown[] = [] +const effects: Array<() => undefined | (() => void)> = [] +let resolveSelectedText!: (value: string) => void +const selectedText = new Promise((resolve) => { + resolveSelectedText = resolve +}) +const List = Object.assign(RaycastComponent, { + EmptyView: RaycastComponent, + Item: RaycastComponent, +}) +const Action = Object.assign(RaycastComponent, { + CopyToClipboard: RaycastComponent, + OpenInBrowser: RaycastComponent, + Push: RaycastComponent, +}) +const jsx = (type: unknown, props: Record) => ({ props, type }) + +mock.module("react", () => ({ + useEffect(effect: () => undefined | (() => void)) { + effects.push(effect) + }, + useRef(initialValue: T) { + return { current: initialValue } + }, + useState(initialValue: T) { + return [ + initialValue, + (value: T) => { + stateUpdates.push(value) + }, + ] as const + }, +})) +mock.module("react/jsx-runtime", () => ({ + Fragment: Symbol.for("react.fragment"), + jsx, + jsxs: jsx, +})) +mock.module("react/jsx-dev-runtime", () => ({ + Fragment: Symbol.for("react.fragment"), + jsxDEV: jsx, +})) +mock.module("@raycast/api", () => ({ + Action, + ActionPanel: RaycastComponent, + Detail: RaycastComponent, + getPreferenceValues: () => ({ apiKey: "test-key" }), + getSelectedText: () => selectedText, + Icon: { + Document: "document", + ExclamationMark: "exclamation-mark", + Eye: "eye", + Gear: "gear", + Link: "link", + MagnifyingGlass: "magnifying-glass", + }, + List, + openExtensionPreferences: () => undefined, + showToast: async () => undefined, + Toast: { Style: { Success: "success" } }, +})) +mock.module("@raycast/utils", () => ({ + usePromise: () => ({ data: [], isLoading: false }), +})) + +const { Command } = await import("../../src/search-memories") +const rendered = Command() as unknown as { + props: { + onSearchTextChange: (value: string) => void + throttle?: boolean + } + type: unknown +} +const cleanups = effects.map((effect) => effect()) + +try { + assert.equal(rendered.type, List) + let deliverThrottledChange: (() => void) | undefined + const deliverChange = () => rendered.props.onSearchTextChange("typed text") + if (rendered.props.throttle) { + deliverThrottledChange = deliverChange + } else { + deliverChange() + } + + resolveSelectedText("selected text") + await Promise.resolve() + await Promise.resolve() + + assert.equal(rendered.props.throttle, false) + assert(stateUpdates.includes("typed text")) + assert(!stateUpdates.includes("selected text")) + deliverThrottledChange?.() +} finally { + for (const cleanup of cleanups) cleanup?.() +} + +console.log("search command wiring passed") diff --git a/apps/raycast-extension/tests/search-memories.test.ts b/apps/raycast-extension/tests/search-memories.test.ts new file mode 100644 index 00000000..b28ca5fe --- /dev/null +++ b/apps/raycast-extension/tests/search-memories.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test" +import { fileURLToPath } from "node:url" + +test("wires physical search input before selected text resolves", async () => { + const fixturePath = fileURLToPath( + new URL("./fixtures/search-memories-command.ts", import.meta.url), + ) + const packageDirectory = fileURLToPath(new URL("..", import.meta.url)) + const child = Bun.spawn({ + cmd: [process.execPath, fixturePath], + cwd: packageDirectory, + stderr: "pipe", + stdout: "pipe", + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + + if (exitCode !== 0) { + throw new Error(`Search command fixture failed:\n${stderr || stdout}`) + } + expect(stdout).toContain("search command wiring passed") +})