From 7dfa7e07e31109ba004d3273607ad8fd7cdfcd85 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 14 Aug 2026 22:38:38 -0700 Subject: [PATCH] Own response filter state where the filter runs (#549) --- .../responseViewers/HTMLOrTextViewer.tsx | 26 +++---- .../components/responseViewers/TextViewer.tsx | 77 +++++++------------ apps/yaak-client/hooks/useResponseFilter.ts | 53 +++++++++++++ 3 files changed, 88 insertions(+), 68 deletions(-) create mode 100644 apps/yaak-client/hooks/useResponseFilter.ts diff --git a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx b/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx index d71a61b5..91a3e31e 100644 --- a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx +++ b/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx @@ -1,7 +1,7 @@ import type { HttpResponse } from "@yaakapp-internal/models"; -import { useMemo, useState } from "react"; import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse"; import { useResponseBodyText } from "../../hooks/useResponseBodyText"; +import { useResponseFilter } from "../../hooks/useResponseFilter"; import { useSaveResponse } from "../../hooks/useSaveResponse"; import { languageFromContentType } from "../../lib/contentType"; import { getContentTypeFromHeaders } from "../../lib/model_util"; @@ -52,30 +52,17 @@ interface HttpTextViewerProps { } function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) { - const [currentFilter, setCurrentFilter] = useState(null); - const filteredBody = useResponseBodyText({ response, filter: currentFilter }); + const filter = useResponseFilter({ stateKey: `response.body.${response.requestId}` }); + const filteredBody = useResponseBodyText({ response, filter: filter.debouncedFilterText }); const saveResponse = useSaveResponse(response); const copyResponse = useCopyHttpResponse(response); const actionsDisabled = response.state !== "closed" && response.status >= 100; - const filterCallback = useMemo( - () => (filter: string) => { - setCurrentFilter(filter); - return { - data: filteredBody.data, - isPending: filteredBody.isPending, - error: !!filteredBody.error, - }; - }, - [filteredBody], - ); - return ( , ]} - onFilter={filterCallback} + filter={filter} + filterResult={{ + data: filteredBody.data, + isPending: filteredBody.isPending, + error: !!filteredBody.error, + }} /> ); } diff --git a/apps/yaak-client/components/responseViewers/TextViewer.tsx b/apps/yaak-client/components/responseViewers/TextViewer.tsx index a54aada8..533283ff 100644 --- a/apps/yaak-client/components/responseViewers/TextViewer.tsx +++ b/apps/yaak-client/components/responseViewers/TextViewer.tsx @@ -1,9 +1,8 @@ import classNames from "classnames"; import type { ReactNode } from "react"; -import { Children, useCallback, useMemo } from "react"; -import { createGlobalState } from "react-use"; -import { useDebouncedValue } from "@yaakapp-internal/ui"; +import { Children, useMemo } from "react"; import { useFormatText } from "../../hooks/useFormatText"; +import type { ResponseFilterApi } from "../../hooks/useResponseFilter"; import type { EditorProps } from "../core/Editor/Editor"; import { hyperlink } from "../core/Editor/hyperlink/extension"; import { Editor } from "../core/Editor/LazyEditor"; @@ -16,56 +15,34 @@ interface Props { text: string; language: EditorProps["language"]; stateKey: string | null; - filterStateKey?: string | null; pretty?: boolean; className?: string; footerActions?: ReactNode; - onFilter?: (filter: string) => { + /** Filter state, from useResponseFilter in whichever component runs the filter */ + filter?: ResponseFilterApi; + /** Result of applying `filter.debouncedFilterText` to the body */ + filterResult?: { data: string | null | undefined; isPending: boolean; error: boolean; }; } -const useFilterText = createGlobalState>({}); - export function TextViewer({ language, text, stateKey, - filterStateKey, pretty, className, footerActions, - onFilter, + filter, + filterResult, }: Props) { - const filterKey = filterStateKey ?? stateKey; - const [filterTextMap, setFilterTextMap] = useFilterText(); - const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null; - const debouncedFilterText = useDebouncedValue(filterText); - const setFilterText = useCallback( - (v: string | null) => { - if (!filterKey) return; - setFilterTextMap((m) => ({ ...m, [filterKey]: v })); - }, - [filterKey, setFilterTextMap], - ); - - const isSearching = filterText != null; - const filteredResponse = - onFilter && debouncedFilterText - ? onFilter(debouncedFilterText) - : { data: null, isPending: false, error: false }; - - const toggleSearch = useCallback(() => { - if (isSearching) { - setFilterText(null); - } else { - setFilterText(""); - } - }, [isSearching, setFilterText]); - - const canFilter = onFilter && (language === "json" || language === "xml" || language === "html"); + const canFilter = + filter != null && (language === "json" || language === "xml" || language === "html"); + const isSearching = filter?.isSearching ?? false; + const filterText = filter?.filterText ?? null; + const resultError = filterResult?.error ?? false; const actions = useMemo(() => { const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); @@ -76,8 +53,8 @@ export function TextViewer({ nodes.push(
e.key === "Escape" && toggleSearch()} - onChange={setFilterText} - stateKey={filterKey ? `filter.${filterKey}` : null} + onKeyDown={(e) => e.key === "Escape" && filter.toggleSearch()} + onChange={filter.setFilterText} + stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null} />
, ); @@ -98,10 +75,10 @@ export function TextViewer({ , ); @@ -110,14 +87,12 @@ export function TextViewer({ }, [ canFilter, footerActions, - filterKey, + filter, filterText, - filteredResponse.error, - filteredResponse.isPending, + filterResult?.isPending, + resultError, isSearching, language, - setFilterText, - toggleSearch, ]); const formattedBody = useFormatText({ text, language, pretty: pretty ?? false }); @@ -126,11 +101,11 @@ export function TextViewer({ } let body: string; - if (isSearching && filterText?.length > 0) { - if (filteredResponse.error) { + if (isSearching && filterText != null && filterText.length > 0) { + if (resultError) { body = ""; } else { - body = filteredResponse.data != null ? filteredResponse.data : ""; + body = filterResult?.data != null ? filterResult.data : ""; } } else { body = formattedBody; diff --git a/apps/yaak-client/hooks/useResponseFilter.ts b/apps/yaak-client/hooks/useResponseFilter.ts new file mode 100644 index 00000000..ea263c8c --- /dev/null +++ b/apps/yaak-client/hooks/useResponseFilter.ts @@ -0,0 +1,53 @@ +import { useDebouncedValue } from "@yaakapp-internal/ui"; +import { useCallback } from "react"; +import { createGlobalState } from "react-use"; + +/** What's typed in the filter box. `null` means the filter box is closed */ +const useFilterTextMap = createGlobalState>({}); + +export interface ResponseFilterApi { + stateKey: string | null; + /** What's typed in the filter box, or `null` when the box is closed */ + filterText: string | null; + /** The expression to actually filter with, lagging `filterText` by a debounce */ + debouncedFilterText: string | null; + isSearching: boolean; + setFilterText: (value: string | null) => void; + toggleSearch: () => void; +} + +/** + * Filter state for a response viewer, keyed so it persists across responses of the + * same request. + * + * Owned by the component that runs the filter, so the viewer can stay presentational. + * Evaluating the expression during the viewer's render (its previous shape) meant + * updating the parent mid-render, which React warns about. + */ +export function useResponseFilter({ stateKey }: { stateKey: string | null }): ResponseFilterApi { + const [filterTextMap, setFilterTextMap] = useFilterTextMap(); + const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null; + const debouncedFilterText = useDebouncedValue(filterText); + + const setFilterText = useCallback( + (v: string | null) => { + if (!stateKey) return; + setFilterTextMap((m) => ({ ...m, [stateKey]: v })); + }, + [stateKey, setFilterTextMap], + ); + + const isSearching = filterText != null; + const toggleSearch = useCallback(() => { + setFilterText(isSearching ? null : ""); + }, [isSearching, setFilterText]); + + return { + stateKey, + filterText, + debouncedFilterText, + isSearching, + setFilterText, + toggleSearch, + }; +}