Files
explore/lib/api/unwrapItems.spec.ts
zeekayandHanzo Dev 2c14a255f9 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]>
2026-08-04 09:50:41 -07:00

46 lines
1.7 KiB
TypeScript

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();
});
});