浏览代码

Merge pull request #2332 from pyth-network/cprussin/dou-31-optimize-page-size-for-price-feed-pages

Optimize payload size for price feeds pages
Connor Prussin 9 月之前
父节点
当前提交
1b335a5652
共有 24 个文件被更改,包括 371 次插入259 次删除
  1. 1 0
      apps/insights/src/app/layout.ts
  2. 1 0
      apps/insights/src/app/price-feeds/[slug]/layout.ts
  3. 1 0
      apps/insights/src/app/price-feeds/[slug]/page.ts
  4. 1 0
      apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx
  5. 1 0
      apps/insights/src/app/price-feeds/page.ts
  6. 1 0
      apps/insights/src/app/publishers/[key]/layout.ts
  7. 1 0
      apps/insights/src/app/publishers/[key]/page.ts
  8. 1 0
      apps/insights/src/app/publishers/[key]/price-feeds/page.ts
  9. 1 0
      apps/insights/src/app/publishers/page.ts
  10. 29 0
      apps/insights/src/components/AssetClassTag/index.tsx
  11. 4 20
      apps/insights/src/components/PriceFeed/layout.tsx
  12. 30 29
      apps/insights/src/components/PriceFeed/price-feed-select.tsx
  13. 2 6
      apps/insights/src/components/PriceFeed/reference-data.tsx
  14. 73 42
      apps/insights/src/components/PriceFeedTag/index.tsx
  15. 33 21
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  16. 4 13
      apps/insights/src/components/PriceFeeds/index.tsx
  17. 58 60
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  18. 1 8
      apps/insights/src/components/Publisher/performance.tsx
  19. 1 7
      apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx
  20. 50 6
      apps/insights/src/components/Publisher/price-feeds-card.tsx
  21. 3 15
      apps/insights/src/components/Publisher/price-feeds.tsx
  22. 25 13
      apps/insights/src/components/Root/index.tsx
  23. 12 19
      apps/insights/src/components/Root/search-dialog.tsx
  24. 37 0
      apps/insights/src/hooks/use-price-feeds.tsx

+ 1 - 0
apps/insights/src/app/layout.ts

@@ -1,4 +1,5 @@
 export { Root as default } from "../components/Root";
 export { metadata, viewport } from "../metadata";
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/price-feeds/[slug]/layout.ts

@@ -6,4 +6,5 @@ export const metadata: Metadata = {
   title: "Price Feeds",
 };
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/price-feeds/[slug]/page.ts

@@ -1,3 +1,4 @@
 export { ChartPage as default } from "../../../components/PriceFeed/chart-page";
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx

@@ -1,3 +1,4 @@
 export { Publishers as default } from "../../../../components/PriceFeed/publishers";
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/price-feeds/page.ts

@@ -6,4 +6,5 @@ export const metadata: Metadata = {
   title: "Price Feeds",
 };
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/publishers/[key]/layout.ts

@@ -6,4 +6,5 @@ export const metadata: Metadata = {
   title: "Publishers",
 };
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/publishers/[key]/page.ts

@@ -1,3 +1,4 @@
 export { Performance as default } from "../../../components/Publisher/performance";
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/publishers/[key]/price-feeds/page.ts

@@ -1,3 +1,4 @@
 export { PriceFeeds as default } from "../../../../components/Publisher/price-feeds";
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 1 - 0
apps/insights/src/app/publishers/page.ts

@@ -6,4 +6,5 @@ export const metadata: Metadata = {
   title: "Publishers",
 };
 
+export const dynamic = "error";
 export const revalidate = 3600;

+ 29 - 0
apps/insights/src/components/AssetClassTag/index.tsx

@@ -0,0 +1,29 @@
+import { Badge } from "@pythnetwork/component-library/Badge";
+import type { ComponentProps } from "react";
+
+import { usePriceFeeds } from "../../hooks/use-price-feeds";
+
+type Props = ComponentProps<typeof Badge> & {
+  symbol: string;
+};
+
+export const AssetClassTag = ({ symbol }: Props) => {
+  const feed = usePriceFeeds().get(symbol);
+
+  if (feed) {
+    return (
+      <Badge variant="neutral" style="outline" size="xs">
+        {feed.assetClass.toUpperCase()}
+      </Badge>
+    );
+  } else {
+    throw new NoSuchFeedError(symbol);
+  }
+};
+
+class NoSuchFeedError extends Error {
+  constructor(symbol: string) {
+    super(`No feed exists named ${symbol}`);
+    this.name = "NoSuchFeedError";
+  }
+}

+ 4 - 20
apps/insights/src/components/PriceFeed/layout.tsx

@@ -13,7 +13,6 @@ import type { ReactNode } from "react";
 import styles from "./layout.module.scss";
 import { PriceFeedSelect } from "./price-feed-select";
 import { ReferenceData } from "./reference-data";
-import { toHex } from "../../hex";
 import { Cluster, getFeeds } from "../../services/pyth";
 import { FeedKey } from "../FeedKey";
 import {
@@ -26,7 +25,6 @@ import {
   YesterdaysPricesProvider,
   PriceFeedChangePercent,
 } from "../PriceFeedChangePercent";
-import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
 
@@ -38,12 +36,12 @@ type Props = {
 };
 
 export const PriceFeedLayout = async ({ children, params }: Props) => {
-  const [{ slug }, fees] = await Promise.all([
+  const [{ slug }, feeds] = await Promise.all([
     params,
     getFeeds(Cluster.Pythnet),
   ]);
   const symbol = decodeURIComponent(slug);
-  const feed = fees.find((item) => item.symbol === symbol);
+  const feed = feeds.find((item) => item.symbol === symbol);
 
   return feed ? (
     <div className={styles.priceFeedLayout}>
@@ -64,22 +62,8 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
           </div>
         </div>
         <div className={styles.headerRow}>
-          <PriceFeedSelect
-            feeds={fees
-              .filter((feed) => feed.symbol !== symbol)
-              .map((feed) => ({
-                id: feed.symbol,
-                key: toHex(feed.product.price_account),
-                displaySymbol: feed.product.display_symbol,
-                icon: <PriceFeedIcon symbol={feed.symbol} />,
-                assetClass: feed.product.asset_type,
-              }))}
-          >
-            <PriceFeedTag
-              symbol={feed.product.display_symbol}
-              description={feed.product.description}
-              icon={<PriceFeedIcon symbol={feed.symbol} />}
-            />
+          <PriceFeedSelect>
+            <PriceFeedTag symbol={feed.symbol} />
           </PriceFeedSelect>
           <div className={styles.rightGroup}>
             <FeedKey

+ 30 - 29
apps/insights/src/components/PriceFeed/price-feed-select.tsx

@@ -1,6 +1,5 @@
 "use client";
 
-import { Badge } from "@pythnetwork/component-library/Badge";
 import { DropdownCaretDown } from "@pythnetwork/component-library/DropdownCaretDown";
 import {
   Virtualizer,
@@ -20,39 +19,43 @@ import { type ReactNode, useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
 import styles from "./price-feed-select.module.scss";
+import { usePriceFeeds } from "../../hooks/use-price-feeds";
+import { AssetClassTag } from "../AssetClassTag";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
   children: ReactNode;
-  feeds: {
-    id: string;
-    key: string;
-    displaySymbol: string;
-    icon: ReactNode;
-    assetClass: string;
-  }[];
 };
 
-export const PriceFeedSelect = ({ children, feeds }: Props) => {
+export const PriceFeedSelect = ({ children }: Props) => {
+  const feeds = usePriceFeeds();
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
   const [search, setSearch] = useState("");
-  const sortedFeeds = useMemo(
-    () =>
-      feeds.sort((a, b) => collator.compare(a.displaySymbol, b.displaySymbol)),
-    [feeds, collator],
-  );
   const filteredFeeds = useMemo(
     () =>
       search === ""
-        ? sortedFeeds
-        : sortedFeeds.filter(
-            (feed) =>
-              filter.contains(feed.displaySymbol, search) ||
-              filter.contains(feed.assetClass, search) ||
-              filter.contains(feed.key, search),
-          ),
-    [sortedFeeds, search, filter],
+        ? feeds.entries()
+        : feeds
+            .entries()
+            .filter(
+              ([, { displaySymbol, assetClass, key }]) =>
+                filter.contains(displaySymbol, search) ||
+                filter.contains(assetClass, search) ||
+                filter.contains(key, search),
+            ),
+    [feeds, search, filter],
+  );
+  const sortedFeeds = useMemo(
+    () =>
+      // eslint-disable-next-line unicorn/no-useless-spread
+      [
+        ...filteredFeeds.map(([symbol, { displaySymbol }]) => ({
+          id: symbol,
+          displaySymbol,
+        })),
+      ].toSorted((a, b) => collator.compare(a.displaySymbol, b.displaySymbol)),
+    [filteredFeeds, collator],
   );
   return (
     <Select
@@ -80,22 +83,20 @@ export const PriceFeedSelect = ({ children, feeds }: Props) => {
           </SearchField>
           <Virtualizer layout={new ListLayout()}>
             <ListBox
-              items={filteredFeeds}
+              items={sortedFeeds}
               className={styles.listbox ?? ""}
               // eslint-disable-next-line jsx-a11y/no-autofocus
               autoFocus={false}
             >
-              {({ assetClass, id, displaySymbol, icon }) => (
+              {({ id, displaySymbol }) => (
                 <ListBoxItem
                   textValue={displaySymbol}
                   className={styles.priceFeed ?? ""}
                   href={`/price-feeds/${encodeURIComponent(id)}`}
-                  data-is-first={id === filteredFeeds[0]?.id ? "" : undefined}
+                  data-is-first={id === sortedFeeds[0]?.id ? "" : undefined}
                 >
-                  <PriceFeedTag compact symbol={displaySymbol} icon={icon} />
-                  <Badge variant="neutral" style="outline" size="xs">
-                    {assetClass.toUpperCase()}
-                  </Badge>
+                  <PriceFeedTag compact symbol={id} />
+                  <AssetClassTag symbol={id} />
                 </ListBoxItem>
               )}
             </ListBox>

+ 2 - 6
apps/insights/src/components/PriceFeed/reference-data.tsx

@@ -1,11 +1,11 @@
 "use client";
 
-import { Badge } from "@pythnetwork/component-library/Badge";
 import { Table } from "@pythnetwork/component-library/Table";
 import { useMemo } from "react";
 import { useCollator } from "react-aria";
 
 import styles from "./reference-data.module.scss";
+import { AssetClassTag } from "../AssetClassTag";
 import { LiveValue } from "../LivePrices";
 
 type Props = {
@@ -42,11 +42,7 @@ export const ReferenceData = ({ feed }: Props) => {
     () =>
       [
         ...Object.entries({
-          "Asset Type": (
-            <Badge variant="neutral" style="outline" size="xs">
-              {feed.assetClass.toUpperCase()}
-            </Badge>
-          ),
+          "Asset Type": <AssetClassTag symbol={feed.symbol} />,
           Base: feed.base,
           Description: feed.description,
           Symbol: feed.symbol,

+ 73 - 42
apps/insights/src/components/PriceFeedTag/index.tsx

@@ -1,34 +1,70 @@
+"use client";
+
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import clsx from "clsx";
 import { type ComponentProps, type ReactNode, Fragment } from "react";
 
 import styles from "./index.module.scss";
+import { usePriceFeeds } from "../../hooks/use-price-feeds";
+import { omitKeys } from "../../omit-keys";
 
-type OwnProps =
-  | { isLoading: true; compact?: boolean | undefined }
-  | ({
+type OwnProps = { compact?: boolean | undefined } & (
+  | { isLoading: true }
+  | {
       isLoading?: false;
       symbol: string;
-      icon: ReactNode;
-    } & (
-      | { compact: true }
-      | {
-          compact?: false;
-          description: string;
-        }
-    ));
+    }
+);
 
 type Props = Omit<ComponentProps<"div">, keyof OwnProps> & OwnProps;
 
-export const PriceFeedTag = ({ className, ...props }: Props) => {
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  const { compact, ...propsWithoutCompact } = props;
+export const PriceFeedTag = (props: Props) => {
+  return props.isLoading ? (
+    <PriceFeedTagImpl {...props} />
+  ) : (
+    <LoadedPriceFeedTag {...props} />
+  );
+};
+
+const LoadedPriceFeedTag = ({
+  symbol,
+  ...props
+}: Props & { isLoading?: false }) => {
+  const feed = usePriceFeeds().get(symbol);
+  if (feed) {
+    const [firstPart, ...rest] = feed.displaySymbol.split("/");
+    return (
+      <PriceFeedTagImpl
+        description={feed.description}
+        feedName={[firstPart ?? "", ...rest]}
+        icon={feed.icon}
+        {...props}
+      />
+    );
+  } else {
+    throw new NoSuchFeedError(symbol);
+  }
+};
+
+type OwnImplProps = { compact?: boolean | undefined } & (
+  | { isLoading: true }
+  | {
+      isLoading?: false;
+      feedName: [string, ...string[]];
+      icon: ReactNode;
+      description: string;
+    }
+);
+
+type ImplProps = Omit<ComponentProps<"div">, keyof OwnImplProps> & OwnImplProps;
+
+const PriceFeedTagImpl = ({ className, compact, ...props }: ImplProps) => {
   return (
     <div
       className={clsx(styles.priceFeedTag, className)}
-      data-compact={props.compact ? "" : undefined}
+      data-compact={compact ? "" : undefined}
       data-loading={props.isLoading ? "" : undefined}
-      {...propsWithoutCompact}
+      {...omitKeys(props, ["feedName", "icon", "description"])}
     >
       {props.isLoading ? (
         <Skeleton fill className={styles.icon} />
@@ -36,14 +72,22 @@ export const PriceFeedTag = ({ className, ...props }: Props) => {
         <div className={styles.icon}>{props.icon}</div>
       )}
       <div className={styles.nameAndDescription}>
-        {props.isLoading ? (
-          <div className={styles.name}>
+        <div className={styles.name}>
+          {props.isLoading ? (
             <Skeleton width={30} />
-          </div>
-        ) : (
-          <FeedName className={styles.name} symbol={props.symbol} />
-        )}
-        {!props.compact && (
+          ) : (
+            <>
+              <span className={styles.firstPart}>{props.feedName[0]}</span>
+              {props.feedName.slice(1).map((part, i) => (
+                <Fragment key={i}>
+                  <span className={styles.divider}>/</span>
+                  <span className={styles.part}>{part}</span>
+                </Fragment>
+              ))}
+            </>
+          )}
+        </div>
+        {!compact && (
           <div className={styles.description}>
             {props.isLoading ? (
               <Skeleton width={50} />
@@ -57,22 +101,9 @@ export const PriceFeedTag = ({ className, ...props }: Props) => {
   );
 };
 
-type OwnFeedNameProps = { symbol: string };
-type FeedNameProps = Omit<ComponentProps<"div">, keyof OwnFeedNameProps> &
-  OwnFeedNameProps;
-
-const FeedName = ({ symbol, className, ...props }: FeedNameProps) => {
-  const [firstPart, ...parts] = symbol.split("/");
-
-  return (
-    <div className={clsx(styles.priceFeedName, className)} {...props}>
-      <span className={styles.firstPart}>{firstPart}</span>
-      {parts.map((part, i) => (
-        <Fragment key={i}>
-          <span className={styles.divider}>/</span>
-          <span className={styles.part}>{part}</span>
-        </Fragment>
-      ))}
-    </div>
-  );
-};
+class NoSuchFeedError extends Error {
+  constructor(symbol: string) {
+    super(`No feed exists named ${symbol}`);
+    this.name = "NoSuchFeedError";
+  }
+}

+ 33 - 21
apps/insights/src/components/PriceFeeds/coming-soon-list.tsx

@@ -1,32 +1,43 @@
 "use client";
 
-import { Badge } from "@pythnetwork/component-library/Badge";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
 import { Table } from "@pythnetwork/component-library/Table";
-import { type ReactNode, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
 import styles from "./coming-soon-list.module.scss";
+import { usePriceFeeds } from "../../hooks/use-price-feeds";
+import { AssetClassTag } from "../AssetClassTag";
 import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
-  comingSoonFeeds: ComingSoonPriceFeed[];
+  comingSoonSymbols: string[];
 };
 
-type ComingSoonPriceFeed = {
-  id: string;
-  displaySymbol: string;
-  icon: ReactNode;
-  assetClass: string;
-};
-
-export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
+export const ComingSoonList = ({ comingSoonSymbols }: Props) => {
   const [search, setSearch] = useState("");
   const [assetClass, setAssetClass] = useState("");
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const feeds = usePriceFeeds();
+  const comingSoonFeeds = useMemo(
+    () =>
+      comingSoonSymbols.map((symbol) => {
+        const feed = feeds.get(symbol);
+        if (feed) {
+          return {
+            symbol,
+            assetClass: feed.assetClass,
+            displaySymbol: feed.displaySymbol,
+          };
+        } else {
+          throw new NoSuchFeedError(symbol);
+        }
+      }),
+    [feeds, comingSoonSymbols],
+  );
   const assetClasses = useMemo(
     () =>
       [
@@ -59,17 +70,11 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
   );
   const rows = useMemo(
     () =>
-      filteredFeeds.map(({ id, displaySymbol, assetClass, icon }) => ({
-        id,
+      filteredFeeds.map(({ symbol }) => ({
+        id: symbol,
         data: {
-          priceFeedName: (
-            <PriceFeedTag compact symbol={displaySymbol} icon={icon} />
-          ),
-          assetClass: (
-            <Badge variant="neutral" style="outline" size="xs">
-              {assetClass.toUpperCase()}
-            </Badge>
-          ),
+          priceFeedName: <PriceFeedTag compact symbol={symbol} />,
+          assetClass: <AssetClassTag symbol={symbol} />,
         },
       })),
     [filteredFeeds],
@@ -133,3 +138,10 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
     </div>
   );
 };
+
+class NoSuchFeedError extends Error {
+  constructor(symbol: string) {
+    super(`No feed exists named ${symbol}`);
+    this.name = "NoSuchFeedError";
+  }
+}

+ 4 - 13
apps/insights/src/components/PriceFeeds/index.tsx

@@ -115,14 +115,9 @@ export const PriceFeeds = async () => {
                 }
               >
                 <ComingSoonList
-                  comingSoonFeeds={priceFeeds.comingSoon.map((feed) => ({
-                    id: feed.product.price_account,
-                    displaySymbol: feed.product.display_symbol,
-                    assetClass: feed.product.asset_type,
-                    icon: (
-                      <PriceFeedIcon symbol={feed.product.display_symbol} />
-                    ),
-                  }))}
+                  comingSoonSymbols={priceFeeds.comingSoon.map(
+                    ({ symbol }) => symbol,
+                  )}
                 />
               </Drawer>
             </DrawerTrigger>
@@ -178,11 +173,7 @@ const FeaturedFeedsCard = <T extends ElementType>({
           })}
         >
           <div className={styles.feedCardContents}>
-            <PriceFeedTag
-              symbol={feed.product.display_symbol}
-              description={feed.product.description}
-              icon={<PriceFeedIcon symbol={feed.product.display_symbol} />}
-            />
+            <PriceFeedTag symbol={feed.symbol} />
             {showPrices && (
               <div className={styles.prices}>
                 <LivePrice feedKey={feed.product.price_account} />

+ 58 - 60
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -13,10 +13,12 @@ import {
   Table,
 } from "@pythnetwork/component-library/Table";
 import { useQueryState, parseAsString } from "nuqs";
-import { type ReactNode, Suspense, useCallback, useMemo } from "react";
+import { Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
+import { usePriceFeeds } from "../../hooks/use-price-feeds";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
+import { AssetClassTag } from "../AssetClassTag";
 import { FeedKey } from "../FeedKey";
 import {
   SKELETON_WIDTH,
@@ -34,11 +36,7 @@ type Props = {
 };
 
 type PriceFeed = {
-  icon: ReactNode;
   symbol: string;
-  id: string;
-  displaySymbol: string;
-  assetClass: string;
   exponent: number;
   numQuoters: number;
 };
@@ -57,12 +55,32 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
     "assetClass",
     parseAsString.withDefault(""),
   );
+  const feeds = usePriceFeeds();
+  const priceFeedsWithContextInfo = useMemo(
+    () =>
+      priceFeeds.map((feed) => {
+        const contextFeed = feeds.get(feed.symbol);
+        if (contextFeed) {
+          return {
+            ...feed,
+            assetClass: contextFeed.assetClass,
+            displaySymbol: contextFeed.displaySymbol,
+            key: contextFeed.key,
+          };
+        } else {
+          throw new NoSuchFeedError(feed.symbol);
+        }
+      }),
+    [feeds, priceFeeds],
+  );
   const feedsFilteredByAssetClass = useMemo(
     () =>
       assetClass
-        ? priceFeeds.filter((feed) => feed.assetClass === assetClass)
-        : priceFeeds,
-    [assetClass, priceFeeds],
+        ? priceFeedsWithContextInfo.filter(
+            (feed) => feed.assetClass === assetClass,
+          )
+        : priceFeedsWithContextInfo,
+    [assetClass, priceFeedsWithContextInfo],
   );
   const {
     search,
@@ -100,54 +118,27 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
 
   const rows = useMemo(
     () =>
-      paginatedItems.map(
-        ({
-          icon,
-          id,
-          symbol,
-          displaySymbol,
-          exponent,
-          numQuoters,
-          assetClass,
-        }) => ({
-          id,
-          href: `/price-feeds/${encodeURIComponent(symbol)}`,
-          data: {
-            exponent: (
-              <LiveValue
-                field="exponent"
-                feedKey={id}
-                defaultValue={exponent}
-              />
-            ),
-            numPublishers: (
-              <LiveValue
-                field="numQuoters"
-                feedKey={id}
-                defaultValue={numQuoters}
-              />
-            ),
-            price: <LivePrice feedKey={id} />,
-            confidenceInterval: <LiveConfidence feedKey={id} />,
-            priceFeedName: (
-              <PriceFeedTag compact symbol={displaySymbol} icon={icon} />
-            ),
-            assetClass: (
-              <Badge variant="neutral" style="outline" size="xs">
-                {assetClass.toUpperCase()}
-              </Badge>
-            ),
-            priceFeedId: (
-              <FeedKey
-                // className={styles.feedKey ?? ""}
-                size="xs"
-                variant="ghost"
-                feedKey={id}
-              />
-            ),
-          },
-        }),
-      ),
+      paginatedItems.map(({ symbol, exponent, numQuoters, key }) => ({
+        id: symbol,
+        href: `/price-feeds/${encodeURIComponent(symbol)}`,
+        data: {
+          exponent: (
+            <LiveValue field="exponent" feedKey={key} defaultValue={exponent} />
+          ),
+          numPublishers: (
+            <LiveValue
+              field="numQuoters"
+              feedKey={key}
+              defaultValue={numQuoters}
+            />
+          ),
+          price: <LivePrice feedKey={key} />,
+          confidenceInterval: <LiveConfidence feedKey={key} />,
+          priceFeedName: <PriceFeedTag compact symbol={symbol} />,
+          assetClass: <AssetClassTag symbol={symbol} />,
+          priceFeedId: <FeedKey size="xs" variant="ghost" feedKey={key} />,
+        },
+      })),
     [paginatedItems],
   );
 
@@ -163,10 +154,10 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
 
   const assetClasses = useMemo(
     () =>
-      [...new Set(priceFeeds.map((feed) => feed.assetClass))].sort((a, b) =>
-        collator.compare(a, b),
-      ),
-    [priceFeeds, collator],
+      [
+        ...new Set(priceFeedsWithContextInfo.map((feed) => feed.assetClass)),
+      ].sort((a, b) => collator.compare(a, b)),
+    [priceFeedsWithContextInfo, collator],
   );
 
   return (
@@ -362,3 +353,10 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
     />
   </Card>
 );
+
+class NoSuchFeedError extends Error {
+  constructor(symbol: string) {
+    super(`No feed exists named ${symbol}`);
+    this.name = "NoSuchFeedError";
+  }
+}

+ 1 - 8
apps/insights/src/components/Publisher/performance.tsx

@@ -21,7 +21,6 @@ import {
   ExplainAverage,
 } from "../Explanations";
 import { NoResults } from "../NoResults";
-import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherIcon } from "../PublisherIcon";
 import { PublisherTag } from "../PublisherTag";
@@ -212,13 +211,7 @@ const getFeedRows = (
     .map(({ feed, ranking }) => ({
       id: ranking.symbol,
       data: {
-        asset: (
-          <PriceFeedTag
-            compact
-            symbol={feed.product.display_symbol}
-            icon={<PriceFeedIcon symbol={feed.symbol} />}
-          />
-        ),
+        asset: <PriceFeedTag compact symbol={feed.symbol} />,
         assetClass: (
           <Badge variant="neutral" style="outline" size="xs">
             {feed.product.asset_type.toUpperCase()}

+ 1 - 7
apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx

@@ -93,13 +93,7 @@ const PriceFeedDrawerProviderImpl = ({
           status={selectedFeed.status}
           navigateButtonText="Open Feed"
           navigateHref={feedHref}
-          title={
-            <PriceFeedTag
-              symbol={selectedFeed.displaySymbol}
-              description={selectedFeed.description}
-              icon={selectedFeed.icon}
-            />
-          }
+          title={<PriceFeedTag symbol={selectedFeed.symbol} />}
         />
       )}
     </PriceFeedDrawerContext>

+ 50 - 6
apps/insights/src/components/Publisher/price-feeds-card.tsx

@@ -3,14 +3,30 @@
 import { type ComponentProps, useCallback } from "react";
 
 import { useSelectPriceFeed } from "./price-feed-drawer-provider";
+import { usePriceFeeds } from "../../hooks/use-price-feeds";
+import { Cluster, ClusterToName } from "../../services/pyth";
 import { PriceComponentsCard } from "../PriceComponentsCard";
+import { PriceFeedTag } from "../PriceFeedTag";
 
-export const PriceFeedsCard = (
-  props: Omit<
-    ComponentProps<typeof PriceComponentsCard>,
-    "onPriceComponentAction"
-  >,
-) => {
+type Props = Omit<
+  ComponentProps<typeof PriceComponentsCard>,
+  "onPriceComponentAction" | "priceComponents"
+> & {
+  publisherKey: string;
+  priceFeeds: (Pick<
+    ComponentProps<typeof PriceComponentsCard>["priceComponents"][number],
+    "score" | "uptimeScore" | "deviationScore" | "stalledScore" | "status"
+  > & {
+    symbol: string;
+  })[];
+};
+
+export const PriceFeedsCard = ({
+  priceFeeds,
+  publisherKey,
+  ...props
+}: Props) => {
+  const feeds = usePriceFeeds();
   const selectPriceFeed = useSelectPriceFeed();
   const onPriceComponentAction = useCallback(
     ({ symbol }: { symbol: string }) => selectPriceFeed?.(symbol),
@@ -19,7 +35,35 @@ export const PriceFeedsCard = (
   return (
     <PriceComponentsCard
       onPriceComponentAction={onPriceComponentAction}
+      priceComponents={priceFeeds.map((feed) => {
+        const contextFeed = feeds.get(feed.symbol);
+        if (contextFeed) {
+          return {
+            id: `${contextFeed.key}-${ClusterToName[Cluster.Pythnet]}`,
+            feedKey: contextFeed.key,
+            symbol: feed.symbol,
+            score: feed.score,
+            uptimeScore: feed.uptimeScore,
+            deviationScore: feed.deviationScore,
+            stalledScore: feed.stalledScore,
+            cluster: Cluster.Pythnet,
+            status: feed.status,
+            publisherKey,
+            name: <PriceFeedTag compact symbol={feed.symbol} />,
+            nameAsString: contextFeed.displaySymbol,
+          };
+        } else {
+          throw new NoSuchFeedError(feed.symbol);
+        }
+      })}
       {...props}
     />
   );
 };
+
+class NoSuchFeedError extends Error {
+  constructor(symbol: string) {
+    super(`No feed exists named ${symbol}`);
+    this.name = "NoSuchFeedError";
+  }
+}

+ 3 - 15
apps/insights/src/components/Publisher/price-feeds.tsx

@@ -1,7 +1,6 @@
 import { getPriceFeeds } from "./get-price-feeds";
 import { PriceFeedsCard } from "./price-feeds-card";
-import { Cluster, ClusterToName } from "../../services/pyth";
-import { PriceFeedIcon } from "../PriceFeedIcon";
+import { Cluster } from "../../services/pyth";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
@@ -22,25 +21,14 @@ export const PriceFeeds = async ({ params }: Props) => {
       searchPlaceholder="Feed symbol"
       metricsTime={metricsTime}
       nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
-      priceComponents={feeds.map(({ ranking, feed, status }) => ({
-        id: `${feed.product.price_account}-${ClusterToName[Cluster.Pythnet]}`,
-        feedKey: feed.product.price_account,
+      publisherKey={key}
+      priceFeeds={feeds.map(({ ranking, feed, status }) => ({
         symbol: feed.symbol,
         score: ranking?.final_score,
         uptimeScore: ranking?.uptime_score,
         deviationScore: ranking?.deviation_score,
         stalledScore: ranking?.stalled_score,
-        cluster: Cluster.Pythnet,
         status,
-        publisherKey: key,
-        name: (
-          <PriceFeedTag
-            compact
-            symbol={feed.product.display_symbol}
-            icon={<PriceFeedIcon symbol={feed.product.display_symbol} />}
-          />
-        ),
-        nameAsString: feed.product.display_symbol,
       }))}
     />
   );

+ 25 - 13
apps/insights/src/components/Root/index.tsx

@@ -14,8 +14,9 @@ import {
   GOOGLE_ANALYTICS_ID,
   AMPLITUDE_API_KEY,
 } from "../../config/server";
-import { toHex } from "../../hex";
+// import { toHex } from "../../hex";
 import { LivePriceDataProvider } from "../../hooks/use-live-price-data";
+import { PriceFeedsProvider as PriceFeedsProviderImpl } from "../../hooks/use-price-feeds";
 import { getPublishers } from "../../services/clickhouse";
 import { Cluster, getFeeds } from "../../services/pyth";
 import { PriceFeedIcon } from "../PriceFeedIcon";
@@ -26,27 +27,17 @@ type Props = {
 };
 
 export const Root = async ({ children }: Props) => {
-  const [feeds, publishers] = await Promise.all([
-    getFeeds(Cluster.Pythnet),
-    getPublishers(),
-  ]);
+  const publishers = await getPublishers();
 
   return (
     <BaseRoot
       amplitudeApiKey={AMPLITUDE_API_KEY}
       googleAnalyticsId={GOOGLE_ANALYTICS_ID}
       enableAccessibilityReporting={ENABLE_ACCESSIBILITY_REPORTING}
-      providers={[NuqsAdapter, LivePriceDataProvider]}
+      providers={[NuqsAdapter, LivePriceDataProvider, PriceFeedsProvider]}
       className={styles.root}
     >
       <SearchDialogProvider
-        feeds={feeds.map((feed) => ({
-          id: feed.symbol,
-          key: toHex(feed.product.price_account),
-          displaySymbol: feed.product.display_symbol,
-          icon: <PriceFeedIcon symbol={feed.symbol} />,
-          assetClass: feed.product.asset_type,
-        }))}
         publishers={publishers.map((publisher) => {
           const knownPublisher = lookupPublisher(publisher.key);
           return {
@@ -70,3 +61,24 @@ export const Root = async ({ children }: Props) => {
     </BaseRoot>
   );
 };
+
+const PriceFeedsProvider = async ({ children }: { children: ReactNode }) => {
+  const feeds = await getFeeds(Cluster.Pythnet);
+
+  const feedMap = new Map(
+    feeds.map((feed) => [
+      feed.symbol,
+      {
+        displaySymbol: feed.product.display_symbol,
+        icon: <PriceFeedIcon symbol={feed.product.display_symbol} />,
+        description: feed.product.description,
+        key: feed.product.price_account,
+        assetClass: feed.product.asset_type,
+      },
+    ]),
+  );
+
+  return (
+    <PriceFeedsProviderImpl value={feedMap}>{children}</PriceFeedsProviderImpl>
+  );
+};

+ 12 - 19
apps/insights/src/components/Root/search-dialog.tsx

@@ -26,6 +26,8 @@ import {
 import { useCollator, useFilter } from "react-aria";
 
 import styles from "./search-dialog.module.scss";
+import { usePriceFeeds } from "../../hooks/use-price-feeds";
+import { AssetClassTag } from "../AssetClassTag";
 import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherTag } from "../PublisherTag";
@@ -42,13 +44,6 @@ const SearchDialogOpenContext = createContext<
 
 type Props = {
   children: ReactNode;
-  feeds: {
-    id: string;
-    key: string;
-    displaySymbol: string;
-    icon: ReactNode;
-    assetClass: string;
-  }[];
   publishers: ({
     id: string;
     averageScore: number;
@@ -58,16 +53,13 @@ type Props = {
   ))[];
 };
 
-export const SearchDialogProvider = ({
-  children,
-  feeds,
-  publishers,
-}: Props) => {
+export const SearchDialogProvider = ({ children, publishers }: Props) => {
   const searchDialogState = useSearchDialogStateContext();
   const [search, setSearch] = useState("");
   const [type, setType] = useState<ResultType | "">("");
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const feeds = usePriceFeeds();
 
   const close = useCallback(() => {
     searchDialogState.close();
@@ -92,9 +84,13 @@ export const SearchDialogProvider = ({
         ...(type === ResultType.Publisher
           ? []
           : feeds
-              .filter((feed) => filter.contains(feed.displaySymbol, search))
-              .map((feed) => ({
+              .entries()
+              .filter(([, { displaySymbol }]) =>
+                filter.contains(displaySymbol, search),
+              )
+              .map(([symbol, feed]) => ({
                 type: ResultType.PriceFeed as const,
+                id: symbol,
                 ...feed,
               }))),
         ...(type === ResultType.PriceFeed
@@ -234,13 +230,10 @@ export const SearchDialogProvider = ({
                     <>
                       <PriceFeedTag
                         compact
-                        symbol={result.displaySymbol}
-                        icon={result.icon}
+                        symbol={result.id}
                         className={styles.itemTag}
                       />
-                      <Badge variant="neutral" style="outline" size="xs">
-                        {result.assetClass.toUpperCase()}
-                      </Badge>
+                      <AssetClassTag symbol={result.id} />
                     </>
                   ) : (
                     <>

+ 37 - 0
apps/insights/src/hooks/use-price-feeds.tsx

@@ -0,0 +1,37 @@
+"use client";
+
+import { type ReactNode, type ComponentProps, createContext, use } from "react";
+
+const PriceFeedsContext = createContext<undefined | PriceFeeds>(undefined);
+
+export const PriceFeedsProvider = (
+  props: ComponentProps<typeof PriceFeedsContext>,
+) => <PriceFeedsContext {...props} />;
+
+export const usePriceFeeds = () => {
+  const value = use(PriceFeedsContext);
+  if (value) {
+    return value;
+  } else {
+    throw new PriceFeedsNotInitializedError();
+  }
+};
+
+type PriceFeeds = Map<string, PriceFeed>;
+
+export type PriceFeed = {
+  displaySymbol: string;
+  icon: ReactNode;
+  description: string;
+  key: string;
+  assetClass: string;
+};
+
+class PriceFeedsNotInitializedError extends Error {
+  constructor() {
+    super(
+      "This component must be a child of <PriceFeedsContext> to use the `usePriceFeeds` hook",
+    );
+    this.name = "PriceFeedsNotInitializedError";
+  }
+}