mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
fix(search): quick search answers with an envelope, not a list
Typing one character into the search box blanked every explorer in the estate.
The DOM collapsed to the error boundary — "Oops! Something went wrong" — with
TypeError: e.data?.forEach is not a function
thrown from the itemsGroups useMemo in
ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggest.tsx:95.
/search/quick is served in two shapes. Upstream Blockscout answers with a bare
list; eth.blockscout.com/api/v2/search/quick?q=1 returns []. Our backend
(luxfi/explorer 1.5.3) answers with the same paginated envelope as /search:
api-explore.lux.network/search/quick?q=1
HTTP 200 {"items":[{"block_number":1,"type":"block"}],"next_page_params":null}
The hanzo, zoo and pars backends return that byte-for-byte too, which is why a
single bundle offset crashes every hostname. The frontend declared the payload
as Array<SearchResultItem> and iterated it, so the envelope object met .forEach.
This is not the rate limiter. The explorer does draw 429s from api.lux.network,
but useFetch rejects on any non-ok response, so an error body never reaches
query.data — it stays undefined and `data?.forEach` short-circuits. Only a 200
carrying a non-array JSON body can produce this crash.
Proven against the live v1.1.13 bundle by rewriting that one response in flight
and changing nothing else: envelope, DOM 132 chars, crash; the same page fed a
bare list, DOM 2081 chars, no console errors, suggest panel renders "Blocks 1".
So the wire type now admits both shapes and unwrapItems is the single place that
collapses them, applied as the select of the quick_search query — the one point
where the resource enters the app. Every consumer downstream (suggest groups,
the desktop "show all results" count, mobile) is handed an array by
construction rather than each guarding for itself. A body in neither shape
yields an empty list, which the component already renders as "No results found",
so a future contract change degrades instead of blanking the page.
zetaChainCCTXQuery.data?.items.length is guarded the same way: the optional
chain stopped at data and left .items.length to throw "Cannot read properties
of undefined (reading 'length')" — the second error in the report — for any
response missing items.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -16,7 +16,7 @@ import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesRespon
|
||||
import type {
|
||||
OptimisticL2DepositsItem,
|
||||
} from 'types/api/optimisticL2';
|
||||
import type { SearchRedirectResult, SearchResult, SearchResultFilters, SearchResultItem } from 'types/api/search';
|
||||
import type { QuickSearchResponse, SearchRedirectResult, SearchResult, SearchResultFilters } from 'types/api/search';
|
||||
import type { HomeStats } from 'types/api/stats';
|
||||
import type {
|
||||
Transaction,
|
||||
@@ -295,7 +295,7 @@ R extends 'general:homepage_indexing_status' ? IndexingStatus :
|
||||
R extends 'general:homepage_zkevm_latest_batch' ? number :
|
||||
R extends 'general:homepage_zksync_latest_batch' ? number :
|
||||
R extends 'general:homepage_arbitrum_latest_batch' ? number :
|
||||
R extends 'general:quick_search' ? Array<SearchResultItem> :
|
||||
R extends 'general:quick_search' ? QuickSearchResponse :
|
||||
R extends 'general:search' ? SearchResult :
|
||||
R extends 'general:search_check_redirect' ? SearchRedirectResult :
|
||||
R extends 'general:config_backend' ? BackendConfig :
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import unwrapItems from './unwrapItems';
|
||||
|
||||
// Recorded verbatim from https://api-explore.lux.network/search/quick?q=1
|
||||
// (HTTP 200, application/json). Typing into the search box fed this object to
|
||||
// `query.data?.forEach(...)`, which threw `forEach is not a function` and took
|
||||
// the whole app down with "Oops! Something went wrong".
|
||||
const PRODUCTION_PAYLOAD = {
|
||||
items: [ { block_number: 1, type: 'block' } ],
|
||||
next_page_params: null,
|
||||
};
|
||||
|
||||
describe('unwrapItems', () => {
|
||||
it('unwraps the paginated envelope our backend returns', () => {
|
||||
expect(unwrapItems(PRODUCTION_PAYLOAD)).toEqual([ { block_number: 1, type: 'block' } ]);
|
||||
});
|
||||
|
||||
it('reproduces the crash the envelope used to cause', () => {
|
||||
// what the component did before the fix
|
||||
expect(() => (PRODUCTION_PAYLOAD as unknown as Array<unknown>).forEach(() => {})).toThrow(TypeError);
|
||||
// what it does now
|
||||
expect(() => unwrapItems(PRODUCTION_PAYLOAD).forEach(() => {})).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes through the bare list upstream returns', () => {
|
||||
const items = [ { type: 'block' }, { type: 'token' } ];
|
||||
expect(unwrapItems(items)).toBe(items);
|
||||
expect(unwrapItems([])).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 'null', null ],
|
||||
[ 'undefined', undefined ],
|
||||
[ 'an error body', { message: 'Rate limit exceeded' } ],
|
||||
[ 'a 429 text body', 'Too Many Requests' ],
|
||||
[ 'a null items field', { items: null } ],
|
||||
[ 'a non-list items field', { items: { '0': 'a' } } ],
|
||||
[ 'a number', 429 ],
|
||||
])('yields an empty list for %s', (_label, payload) => {
|
||||
const result = unwrapItems(payload);
|
||||
expect(result).toEqual([]);
|
||||
expect(() => result.forEach(() => {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Collapses a list payload to a plain array.
|
||||
*
|
||||
* The same resource is served in two shapes across backend builds: a bare list,
|
||||
* or that list wrapped in the paginated `{ items: [ ... ] }` envelope. Callers
|
||||
* want to iterate, not to care which one arrived, so this is the single place
|
||||
* that knows about the envelope.
|
||||
*
|
||||
* Anything else on the wire — an error body, `null`, a scalar — yields an empty
|
||||
* list rather than a value the caller will try to iterate and crash on.
|
||||
*/
|
||||
export default function unwrapItems<T>(payload: unknown): Array<T> {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as Array<T>;
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object') {
|
||||
const items = (payload as { items?: unknown }).items;
|
||||
if (Array.isArray(items)) {
|
||||
return items as Array<T>;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -165,6 +165,12 @@ export interface SearchResult {
|
||||
} | null;
|
||||
}
|
||||
|
||||
// `/search/quick` is served in two shapes: upstream returns a bare list, our
|
||||
// backend returns the same paginated envelope as `/search`. Both are on the
|
||||
// wire, so the wire type admits both and `lib/api/unwrapItems` is the single
|
||||
// place that collapses them into the array the app works with.
|
||||
export type QuickSearchResponse = Array<SearchResultItem> | SearchResult;
|
||||
|
||||
export interface SearchResultFilters {
|
||||
q: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PopoverBody, PopoverContent, PopoverFooter, PopoverRoot, PopoverTrigger } from '@luxfi/ui/popover';
|
||||
import { useClickAway } from '@uidotdev/usehooks';
|
||||
import { debounce } from 'es-toolkit';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -10,9 +11,8 @@ import { route } from 'nextjs-routes';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import * as mixpanel from 'lib/mixpanel/index';
|
||||
import { getRecentSearchKeywords, saveToRecentKeywords } from 'lib/recentSearchKeywords';
|
||||
import { Link } from 'toolkit/next/link';
|
||||
import { PopoverBody, PopoverContent, PopoverFooter, PopoverRoot, PopoverTrigger } from '@luxfi/ui/popover';
|
||||
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
|
||||
import { Link } from 'toolkit/next/link';
|
||||
|
||||
import SearchBarBackdrop from './SearchBarBackdrop';
|
||||
import SearchBarInput from './SearchBarInput';
|
||||
@@ -137,7 +137,7 @@ const SearchBarDesktop = ({ isHeroBanner }: Props) => {
|
||||
|
||||
const showAllResultsLink = searchTerm.trim().length > 0 && (
|
||||
(query.data && query.data.length >= 50) ||
|
||||
(zetaChainCCTXQuery.data && zetaChainCCTXQuery.data?.items.length > 10)
|
||||
(zetaChainCCTXQuery.data && zetaChainCCTXQuery.data?.items?.length > 10)
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -48,7 +48,7 @@ const SearchBarSuggest = ({ query, zetaChainCCTXQuery, externalSearchItem, searc
|
||||
|
||||
const handleScroll = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || (!query.data?.length && !zetaChainCCTXQuery.data?.items.length)) {
|
||||
if (!container || (!query.data?.length && !zetaChainCCTXQuery.data?.items?.length)) {
|
||||
return;
|
||||
}
|
||||
const topLimit = container.getBoundingClientRect().y + TABS_HEIGHT;
|
||||
@@ -86,7 +86,7 @@ const SearchBarSuggest = ({ query, zetaChainCCTXQuery, externalSearchItem, searc
|
||||
}, [ handleScroll ]);
|
||||
|
||||
const itemsGroups = React.useMemo(() => {
|
||||
if (!query.data && !zetaChainCCTXQuery.data?.items.length && !marketplaceApps.displayedApps) {
|
||||
if (!query.data && !zetaChainCCTXQuery.data?.items?.length && !marketplaceApps.displayedApps) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const SearchBarSuggest = ({ query, zetaChainCCTXQuery, externalSearchItem, searc
|
||||
map.app = marketplaceApps.displayedApps;
|
||||
}
|
||||
|
||||
if (zetaChainCCTXQuery.data?.items.length) {
|
||||
if (zetaChainCCTXQuery.data?.items?.length) {
|
||||
map.zetaChainCCTX = zetaChainCCTXQuery.data.items;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import type { Mock } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook } from 'vitest/lib';
|
||||
|
||||
import useQuickSearchQuery from './useQuickSearchQuery';
|
||||
|
||||
vi.mock('lib/api/useApiQuery', () => ({ 'default': vi.fn(() => ({ data: undefined })) }));
|
||||
vi.mock('lib/hooks/useDebounce', () => ({ 'default': (value: unknown) => value }));
|
||||
|
||||
const mockUseApiQuery = useApiQuery as Mock<typeof useApiQuery>;
|
||||
|
||||
// Recorded verbatim from https://api-explore.lux.network/search/quick?q=1 (HTTP 200).
|
||||
const PRODUCTION_PAYLOAD = {
|
||||
items: [ { block_number: 1, type: 'block' } ],
|
||||
next_page_params: null,
|
||||
};
|
||||
|
||||
function getQuickSearchSelect() {
|
||||
renderHook(() => useQuickSearchQuery());
|
||||
|
||||
const call = mockUseApiQuery.mock.calls.find(([ resource ]) => resource === 'general:quick_search');
|
||||
expect(call).toBeDefined();
|
||||
|
||||
// the response has to be settled into an array here, at the API boundary —
|
||||
// every consumer downstream iterates it
|
||||
const select = call?.[1]?.queryOptions?.select;
|
||||
expect(select).toBeInstanceOf(Function);
|
||||
|
||||
return select as (data: unknown) => unknown;
|
||||
}
|
||||
|
||||
describe('useQuickSearchQuery', () => {
|
||||
it('hands consumers an array when the backend returns the paginated envelope', () => {
|
||||
// Before the fix the envelope reached SearchBarSuggest untouched and
|
||||
// `query.data?.forEach(...)` threw, blanking the whole explorer.
|
||||
expect(getQuickSearchSelect()(PRODUCTION_PAYLOAD)).toEqual([ { block_number: 1, type: 'block' } ]);
|
||||
});
|
||||
|
||||
it('hands consumers an array when the backend returns a bare list', () => {
|
||||
const items = [ { type: 'block' } ];
|
||||
expect(getQuickSearchSelect()(items)).toEqual(items);
|
||||
});
|
||||
|
||||
it('degrades to an empty list instead of crashing on an error body', () => {
|
||||
const select = getQuickSearchSelect();
|
||||
for (const payload of [ null, undefined, 'Too Many Requests', { message: 'nope' } ]) {
|
||||
expect(select(payload)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { QuickSearchResultItem } from 'types/client/search';
|
||||
|
||||
import config from 'configs/app';
|
||||
import multichainConfig from 'configs/multichain';
|
||||
import { isBech32Address, fromBech32Address } from 'lib/address/bech32';
|
||||
import unwrapItems from 'lib/api/unwrapItems';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import useDebounce from 'lib/hooks/useDebounce';
|
||||
import { getExternalSearchItem } from 'lib/search/externalSearch';
|
||||
@@ -18,10 +21,13 @@ export default function useQuickSearchQuery() {
|
||||
return Boolean(multichainConfig());
|
||||
}, []);
|
||||
|
||||
const mainQuery = useApiQuery('general:quick_search', {
|
||||
const mainQuery = useApiQuery<'general:quick_search', unknown, Array<QuickSearchResultItem>>('general:quick_search', {
|
||||
queryParams: { q: isBech32Address(debouncedSearchTerm) ? fromBech32Address(debouncedSearchTerm) : debouncedSearchTerm },
|
||||
queryOptions: {
|
||||
enabled: debouncedSearchTerm.trim().length > 0 && !isMultichain,
|
||||
// the backend answers either with a bare list or with the paginated
|
||||
// envelope; consumers below iterate, so settle on the array right here
|
||||
select: (data) => unwrapItems<QuickSearchResultItem>(data),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user