Add advanced search UI (#10992)
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {
|
||||
buildSearchPostsV2Filters,
|
||||
extractSearchPostsParams,
|
||||
} from '#/state/queries/search-posts-params'
|
||||
|
||||
describe(`extractSearchPostsParams`, () => {
|
||||
const tests: {
|
||||
name: string
|
||||
input: string
|
||||
output: ReturnType<typeof extractSearchPostsParams>
|
||||
}[] = [
|
||||
{
|
||||
name: `passes bare text through untouched`,
|
||||
input: `hello world`,
|
||||
output: {q: `hello world`},
|
||||
},
|
||||
{
|
||||
name: `lifts from: into author and strips it from q`,
|
||||
input: `cats from:alice`,
|
||||
output: {q: `cats`, author: `alice`},
|
||||
},
|
||||
{
|
||||
name: `lifts to: into mentions (alias) and strips it from q`,
|
||||
input: `cats to:alice`,
|
||||
output: {q: `cats`, mentions: `alice`},
|
||||
},
|
||||
{
|
||||
name: `accumulates multiple hashtags into tag[]`,
|
||||
input: `#cats #dogs`,
|
||||
output: {q: ``, tag: [`cats`, `dogs`]},
|
||||
},
|
||||
{
|
||||
name: `keeps quoted phrases in q`,
|
||||
input: `"no clues" from:alice`,
|
||||
output: {q: `"no clues"`, author: `alice`},
|
||||
},
|
||||
{
|
||||
name: `keeps OR groups in q`,
|
||||
input: `(cats OR dogs) lang:en`,
|
||||
output: {q: `(cats OR dogs)`, lang: `en`},
|
||||
},
|
||||
{
|
||||
name: `extracts a valid since date`,
|
||||
input: `cats since:2024-01-01`,
|
||||
output: {q: `cats`, since: `2024-01-01`},
|
||||
},
|
||||
{
|
||||
name: `leaves an invalid since date in q`,
|
||||
input: `cats since:garbage`,
|
||||
output: {q: `cats since:garbage`},
|
||||
},
|
||||
{
|
||||
name: `leaves unsupported operators in q`,
|
||||
input: `cats replies:only media:true`,
|
||||
output: {q: `cats replies:only media:true`},
|
||||
},
|
||||
{
|
||||
name: `lifts all supported operators at once`,
|
||||
input: `term from:alice mentions:bob domain:bsky.app url:bsky.app/x lang:en since:2024-01-01 until:2024-02-01 #tag`,
|
||||
output: {
|
||||
q: `term`,
|
||||
author: `alice`,
|
||||
mentions: `bob`,
|
||||
domain: `bsky.app`,
|
||||
url: `bsky.app/x`,
|
||||
lang: `en`,
|
||||
since: `2024-01-01`,
|
||||
until: `2024-02-01`,
|
||||
tag: [`tag`],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `keeps the first value for a repeated singular operator`,
|
||||
input: `from:alice from:bob`,
|
||||
output: {q: ``, author: `alice`},
|
||||
},
|
||||
// CJK (and other space-free scripts) carries no whitespace, so the
|
||||
// whitespace-based tokenizer must keep it intact as bare query text.
|
||||
{
|
||||
name: `passes bare CJK text through untouched`,
|
||||
input: `東京`,
|
||||
output: {q: `東京`},
|
||||
},
|
||||
{
|
||||
name: `lifts an operator from a CJK query and keeps the CJK text`,
|
||||
input: `東京 from:alice`,
|
||||
output: {q: `東京`, author: `alice`},
|
||||
},
|
||||
{
|
||||
name: `treats a whole CJK phrase as a single token`,
|
||||
input: `寿司 ラーメン`,
|
||||
output: {q: `寿司 ラーメン`},
|
||||
},
|
||||
{
|
||||
name: `lifts a CJK hashtag into tag[]`,
|
||||
input: `#日本 ramen`,
|
||||
output: {q: `ramen`, tag: [`日本`]},
|
||||
},
|
||||
]
|
||||
|
||||
it.each(tests)(`$name`, ({input, output}) => {
|
||||
expect(extractSearchPostsParams(input)).toEqual(output)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`buildSearchPostsV2Filters`, () => {
|
||||
it(`maps embedded operators alone into v2 plural params`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters({
|
||||
author: `alice`,
|
||||
domain: `bsky.app`,
|
||||
lang: `en`,
|
||||
tag: [`cats`],
|
||||
}),
|
||||
).toEqual({
|
||||
authors: [`alice`],
|
||||
domains: [`bsky.app`],
|
||||
languages: [`en`],
|
||||
hashtags: [`cats`],
|
||||
})
|
||||
})
|
||||
|
||||
it(`maps dialog filters alone, including v2-only booleans`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters(
|
||||
{},
|
||||
{author: `bob carol`, media: `true`, replies: `none`},
|
||||
),
|
||||
).toEqual({
|
||||
authors: [`bob`, `carol`],
|
||||
hasMedia: true,
|
||||
excludeReplies: true,
|
||||
})
|
||||
})
|
||||
|
||||
it(`unions list values from both sources without clobbering`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters(
|
||||
{author: `alice`, tag: [`cats`]},
|
||||
{author: `bob carol`, tag: `dogs`},
|
||||
),
|
||||
).toEqual({
|
||||
authors: [`alice`, `bob`, `carol`],
|
||||
hashtags: [`cats`, `dogs`],
|
||||
})
|
||||
})
|
||||
|
||||
it(`dedupes overlapping values across sources`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters({author: `alice`}, {author: `alice bob`}),
|
||||
).toEqual({
|
||||
authors: [`alice`, `bob`],
|
||||
})
|
||||
})
|
||||
|
||||
it(`prefers the dialog filter for scalar fields, falling back to embedded`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters(
|
||||
{lang: `en`, since: `2024-01-01`},
|
||||
{lang: `ja`},
|
||||
),
|
||||
).toEqual({
|
||||
languages: [`ja`],
|
||||
since: `2024-01-01T00:00:00Z`,
|
||||
})
|
||||
})
|
||||
|
||||
it(`normalizes date-only since/until to midnight UTC timestamps`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters({}, {since: `2024-01-01`, until: `2024-02-01`}),
|
||||
).toEqual({
|
||||
since: `2024-01-01T00:00:00Z`,
|
||||
until: `2024-02-01T00:00:00Z`,
|
||||
})
|
||||
})
|
||||
|
||||
it(`leaves a timestamp with an explicit time component unchanged`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters({}, {until: `2024-02-01T12:30:00Z`}),
|
||||
).toEqual({
|
||||
until: `2024-02-01T12:30:00Z`,
|
||||
})
|
||||
})
|
||||
|
||||
it(`passes exclude lists through from dialog filters`, () => {
|
||||
expect(
|
||||
buildSearchPostsV2Filters(
|
||||
{author: `alice`},
|
||||
{excludeAuthor: `bob carol`, excludeTag: `spam`},
|
||||
),
|
||||
).toEqual({
|
||||
authors: [`alice`],
|
||||
excludeAuthors: [`bob`, `carol`],
|
||||
excludeHashtags: [`spam`],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Pure helpers for lifting structured `app.bsky.feed.searchPosts` params out of
|
||||
* a free-text query. Kept free of React Native imports so it can be unit
|
||||
* tested in isolation (the search-posts query hook re-exports these).
|
||||
*/
|
||||
|
||||
import {type AppBskyFeedSearchPostsV2} from '@atproto/api'
|
||||
|
||||
import {
|
||||
filtersToApiParams,
|
||||
type SearchFilters,
|
||||
} from '#/screens/Search/searchParams'
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}/
|
||||
|
||||
export type ExtractedSearchParams = {
|
||||
q: string
|
||||
author?: string
|
||||
mentions?: string
|
||||
domain?: string
|
||||
url?: string
|
||||
lang?: string
|
||||
since?: string
|
||||
until?: string
|
||||
tag?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a query into whitespace-delimited tokens, keeping quoted phrases
|
||||
* ("a b") and parenthesized OR groups ((a OR b)) intact so they pass through to
|
||||
* `q` untouched. Shared with the advanced-search dialog's parser (view layer),
|
||||
* which imports it from here so the two stay in sync.
|
||||
*/
|
||||
export function tokenizeQuery(raw: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let i = 0
|
||||
const n = raw.length
|
||||
while (i < n) {
|
||||
if (/\s/.test(raw[i])) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const start = i
|
||||
if (raw[i] === '(') {
|
||||
let depth = 0
|
||||
while (i < n) {
|
||||
if (raw[i] === '(') depth++
|
||||
else if (raw[i] === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
i++
|
||||
break
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
tokens.push(raw.slice(start, i))
|
||||
continue
|
||||
}
|
||||
let buf = ''
|
||||
while (i < n && !/\s/.test(raw[i]) && raw[i] !== '(') {
|
||||
if (raw[i] === '"') {
|
||||
buf += raw[i++]
|
||||
while (i < n && raw[i] !== '"') buf += raw[i++]
|
||||
if (i < n) buf += raw[i++]
|
||||
} else {
|
||||
buf += raw[i++]
|
||||
}
|
||||
}
|
||||
if (buf) tokens.push(buf)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the operators that `app.bsky.feed.searchPosts` accepts as structured
|
||||
* params out of the free-text query, so the backend filters on them directly.
|
||||
* Recognized operators are stripped from `q`; everything else (free text,
|
||||
* quoted phrases, OR groups, negations, and unsupported operators like
|
||||
* `replies:`, `media:`) is left in `q` verbatim. Singular params keep the first
|
||||
* value seen; `tag` accumulates (the lexicon AND-matches multiple tags).
|
||||
*/
|
||||
export function extractSearchPostsParams(query: string): ExtractedSearchParams {
|
||||
const result: ExtractedSearchParams = {q: ''}
|
||||
const remaining: string[] = []
|
||||
const tags: string[] = []
|
||||
|
||||
for (const token of tokenizeQuery(query)) {
|
||||
if (token.startsWith('#') && token.length > 1 && !token.includes(':')) {
|
||||
tags.push(token.slice(1))
|
||||
continue
|
||||
}
|
||||
|
||||
const colonIdx = token.indexOf(':')
|
||||
if (colonIdx === -1) {
|
||||
remaining.push(token)
|
||||
continue
|
||||
}
|
||||
|
||||
const op = token.slice(0, colonIdx)
|
||||
const value = token.slice(colonIdx + 1)
|
||||
if (!value) {
|
||||
remaining.push(token)
|
||||
continue
|
||||
}
|
||||
|
||||
switch (op) {
|
||||
case 'from':
|
||||
result.author ??= value
|
||||
break
|
||||
case 'mentions':
|
||||
case 'to':
|
||||
result.mentions ??= value
|
||||
break
|
||||
case 'domain':
|
||||
result.domain ??= value
|
||||
break
|
||||
case 'url':
|
||||
result.url ??= value
|
||||
break
|
||||
case 'lang':
|
||||
result.lang ??= value
|
||||
break
|
||||
case 'since':
|
||||
if (DATE_RE.test(value)) result.since ??= value
|
||||
else remaining.push(token)
|
||||
break
|
||||
case 'until':
|
||||
if (DATE_RE.test(value)) result.until ??= value
|
||||
else remaining.push(token)
|
||||
break
|
||||
default:
|
||||
// Unsupported operator (to:, replies:, media:, etc.) - keep in q.
|
||||
remaining.push(token)
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.length) result.tag = tags
|
||||
result.q = remaining.join(' ')
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates two optional value lists, dropping empties and duplicates while
|
||||
* preserving order. Used to union the back-compat operators embedded in the
|
||||
* query string with the explicit dialog filters so neither source clobbers the
|
||||
* other.
|
||||
*/
|
||||
function mergeList(a?: string[], b?: string[]): string[] | undefined {
|
||||
const merged = [...new Set([...(a ?? []), ...(b ?? [])])]
|
||||
return merged.length ? merged : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `app.bsky.feed.searchPostsV2` query params (minus q/limit/cursor/
|
||||
* sort, which the caller owns) from the operators embedded in the query string
|
||||
* plus the structured advanced-search dialog filters. The two sources are
|
||||
* merged rather than overriding each other: list fields union their values, and
|
||||
* scalar fields prefer the explicit dialog filter, falling back to the embedded
|
||||
* operator. v2 renames v1's singular operators to plural arrays, and `lang` to
|
||||
* `language`.
|
||||
*/
|
||||
export function buildSearchPostsV2Filters(
|
||||
embedded: Omit<ExtractedSearchParams, 'q'>,
|
||||
filters?: SearchFilters,
|
||||
): AppBskyFeedSearchPostsV2.QueryParams {
|
||||
const apiFilters = filters ? filtersToApiParams(filters) : {}
|
||||
const params: AppBskyFeedSearchPostsV2.QueryParams = {}
|
||||
|
||||
const authors = mergeList(
|
||||
embedded.author ? [embedded.author] : undefined,
|
||||
apiFilters.authors,
|
||||
)
|
||||
if (authors) params.authors = authors
|
||||
|
||||
const mentions = mergeList(
|
||||
embedded.mentions ? [embedded.mentions] : undefined,
|
||||
apiFilters.mentions,
|
||||
)
|
||||
if (mentions) params.mentions = mentions
|
||||
|
||||
const domains = mergeList(
|
||||
embedded.domain ? [embedded.domain] : undefined,
|
||||
apiFilters.domains,
|
||||
)
|
||||
if (domains) params.domains = domains
|
||||
|
||||
const urls = mergeList(
|
||||
embedded.url ? [embedded.url] : undefined,
|
||||
apiFilters.urls,
|
||||
)
|
||||
if (urls) params.urls = urls
|
||||
|
||||
const hashtags = mergeList(embedded.tag, apiFilters.hashtags)
|
||||
if (hashtags) params.hashtags = hashtags
|
||||
|
||||
const language = apiFilters.language ?? embedded.lang
|
||||
// TODO At the moment, the language selector is single-select. -dsb
|
||||
if (language) params.languages = [language]
|
||||
|
||||
const since = parseTimestamp(apiFilters.since ?? embedded.since)
|
||||
if (since) params.since = since
|
||||
|
||||
const until = parseTimestamp(apiFilters.until ?? embedded.until)
|
||||
if (until) params.until = until
|
||||
|
||||
/*
|
||||
* Exclude lists have no embedded query-string source (operators like `from:`
|
||||
* are always include), so they pass straight through from the dialog filters.
|
||||
*/
|
||||
if (apiFilters.excludeAuthors)
|
||||
params.excludeAuthors = apiFilters.excludeAuthors
|
||||
if (apiFilters.excludeMentions)
|
||||
params.excludeMentions = apiFilters.excludeMentions
|
||||
if (apiFilters.excludeDomains)
|
||||
params.excludeDomains = apiFilters.excludeDomains
|
||||
if (apiFilters.excludeUrls) params.excludeUrls = apiFilters.excludeUrls
|
||||
if (apiFilters.excludeHashtags)
|
||||
params.excludeHashtags = apiFilters.excludeHashtags
|
||||
|
||||
if (apiFilters.hasMedia) params.hasMedia = true
|
||||
if (apiFilters.hasVideo) params.hasVideo = true
|
||||
if (apiFilters.following) params.following = true
|
||||
if (apiFilters.excludeReplies) params.excludeReplies = true
|
||||
if (apiFilters.repliesOnly) params.repliesOnly = true
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent with timestamp parsing in @atproto/api. Only the date is used; the
|
||||
* time is appended here since the lexicon expects a datetime value.
|
||||
*/
|
||||
const parseTimestamp = (value: string | undefined): string | undefined => {
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
if (isNaN(date.getTime())) return undefined
|
||||
return date.toISOString().split('.')[0] + 'Z'
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {useCallback, useMemo, useRef} from 'react'
|
||||
import {type AppBskyFeedSearchPostsV2, moderatePost} from '@atproto/api'
|
||||
import {
|
||||
type InfiniteData,
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SearchFilters} from '#/screens/Search/searchParams'
|
||||
import {
|
||||
buildSearchPostsV2Filters,
|
||||
extractSearchPostsParams,
|
||||
} from './search-posts-params'
|
||||
|
||||
/**
|
||||
* V2 search shares the `'search-posts'` query-key root with the original hook
|
||||
* (src/state/queries/search-posts.ts) so the shadow-cache generators there -
|
||||
* findAllPostsInQueryData / findAllProfilesInQueryData - discover V2 results
|
||||
* too. This module is only used behind the AdvancedSearchV2Enable gate; the
|
||||
* original hook is unchanged.
|
||||
*/
|
||||
const searchPostsQueryKeyRoot = 'search-posts'
|
||||
const searchPostsV2QueryKey = ({
|
||||
query,
|
||||
sort,
|
||||
filters,
|
||||
}: {
|
||||
query: string
|
||||
sort?: string
|
||||
filters?: SearchFilters
|
||||
}) => [searchPostsQueryKeyRoot, query, sort, filters]
|
||||
|
||||
export function useSearchPostsV2Query({
|
||||
query,
|
||||
sort,
|
||||
enabled,
|
||||
filters,
|
||||
}: {
|
||||
query: string
|
||||
sort?: 'top' | 'latest'
|
||||
enabled?: boolean
|
||||
filters?: SearchFilters
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const selectArgs = useMemo(
|
||||
() => ({
|
||||
isSearchingSpecificUser: /from:(\w+)/.test(query) || !!filters?.author,
|
||||
moderationOpts,
|
||||
}),
|
||||
[query, filters?.author, moderationOpts],
|
||||
)
|
||||
const lastRun = useRef<{
|
||||
data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
|
||||
args: typeof selectArgs
|
||||
result: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
|
||||
} | null>(null)
|
||||
|
||||
return useInfiniteQuery<
|
||||
AppBskyFeedSearchPostsV2.OutputSchema,
|
||||
Error,
|
||||
InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>,
|
||||
QueryKey,
|
||||
string | undefined
|
||||
>({
|
||||
queryKey: searchPostsV2QueryKey({query, sort, filters}),
|
||||
queryFn: async ({pageParam}) => {
|
||||
/*
|
||||
* Operators embedded in the query string (e.g. for back-compat links) are
|
||||
* merged with the explicit structured filters from the advanced search
|
||||
* dialog; see buildSearchPostsV2Filters for how the two sources combine.
|
||||
*/
|
||||
const {q, ...embedded} = extractSearchPostsParams(query)
|
||||
const builtFilters = buildSearchPostsV2Filters(embedded, filters)
|
||||
/*
|
||||
* v2 defaults to a recent-post window; the Latest tab keeps that, while
|
||||
* Top searches the full index. But an explicit since/until date filter
|
||||
* must search the full index too, otherwise the recent window would
|
||||
* silently override the user's date range and return nothing for older
|
||||
* dates.
|
||||
*/
|
||||
const hasDateFilter = !!(builtFilters.since || builtFilters.until)
|
||||
const res = await agent.app.bsky.feed.searchPostsV2({
|
||||
...builtFilters,
|
||||
query: q,
|
||||
limit: 25,
|
||||
cursor: pageParam,
|
||||
/*
|
||||
* v2 calls the recency sort 'recent'; the rest of the app still uses
|
||||
* the v1 'latest' label.
|
||||
*/
|
||||
sort: sort === 'latest' ? 'recent' : sort,
|
||||
allTime: sort !== 'latest' || hasDateFilter,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
enabled: enabled ?? !!moderationOpts,
|
||||
select: useCallback(
|
||||
(data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>) => {
|
||||
const {moderationOpts, isSearchingSpecificUser} = selectArgs
|
||||
|
||||
/*
|
||||
* If a user applies the `from:<user>` filter, don't apply any
|
||||
* moderation. Note that if we add any more filtering logic below, we
|
||||
* may need to adjust this.
|
||||
*/
|
||||
if (isSearchingSpecificUser) {
|
||||
return data
|
||||
}
|
||||
|
||||
/*
|
||||
* Keep track of the last run and whether we can reuse some already
|
||||
* selected pages from there.
|
||||
*/
|
||||
let reusedPages = []
|
||||
if (lastRun.current) {
|
||||
const {
|
||||
data: lastData,
|
||||
args: lastArgs,
|
||||
result: lastResult,
|
||||
} = lastRun.current
|
||||
let canReuse = true
|
||||
for (let key in selectArgs) {
|
||||
if (selectArgs.hasOwnProperty(key)) {
|
||||
if (
|
||||
(selectArgs as Record<string, unknown>)[key] !==
|
||||
(lastArgs as Record<string, unknown>)[key]
|
||||
) {
|
||||
// Can't do reuse anything if any input has changed.
|
||||
canReuse = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (canReuse) {
|
||||
for (let i = 0; i < data.pages.length; i++) {
|
||||
if (data.pages[i] && lastData.pages[i] === data.pages[i]) {
|
||||
reusedPages.push(lastResult.pages[i])
|
||||
continue
|
||||
}
|
||||
// Stop as soon as pages stop matching up.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
...data,
|
||||
pages: [
|
||||
...reusedPages,
|
||||
...data.pages.slice(reusedPages.length).map(page => {
|
||||
return {
|
||||
...page,
|
||||
posts: page.posts.filter(post => {
|
||||
const mod = moderatePost(post, moderationOpts!)
|
||||
return !mod.ui('contentList').filter
|
||||
}),
|
||||
}
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
lastRun.current = {data, result, args: selectArgs}
|
||||
|
||||
return result
|
||||
},
|
||||
[selectArgs],
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -97,7 +97,10 @@ export function useSearchPostsQuery({
|
||||
let canReuse = true
|
||||
for (let key in selectArgs) {
|
||||
if (selectArgs.hasOwnProperty(key)) {
|
||||
if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
|
||||
if (
|
||||
(selectArgs as Record<string, unknown>)[key] !==
|
||||
(lastArgs as Record<string, unknown>)[key]
|
||||
) {
|
||||
// Can't do reuse anything if any input has changed.
|
||||
canReuse = false
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user