From 499d61cbe7948fab0943253fb74b21fae2e9cf3a Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Wed, 27 May 2026 20:39:25 +0700 Subject: [PATCH 1/2] fix(portal): resolve destination-types loading race that blanks the page useDestinationTypes returned `{}` while the SWR fetch was pending, which is truthy. Callers' `destination_types && destinations` gates therefore passed before destination-types had loaded, and the list view crashed in render on `destination_types[destination.type].icon` when /destinations resolved before /destination-types. ErrorBoundary caught the crash and blanked the route, requiring several reloads (SWR cache warm-up) for the page to render. Return `undefined` while loading so the existing truthy gates actually mean "everything loaded". Update the two call sites that indexed the map (`useDestinationType`, `CreateDestination`'s context) to handle the new nullable return. --- internal/portal/src/destination-types.tsx | 11 +- .../CreateDestination/CreateDestination.tsx | 24 +++- .../DestinationsList/DestinationList.tsx | 129 +++++++++--------- 3 files changed, 91 insertions(+), 73 deletions(-) diff --git a/internal/portal/src/destination-types.tsx b/internal/portal/src/destination-types.tsx index 5a96adcf8..932ea5826 100644 --- a/internal/portal/src/destination-types.tsx +++ b/internal/portal/src/destination-types.tsx @@ -3,10 +3,9 @@ import useSWR from "swr"; import type { DestinationTypeReference } from "./typings/Destination"; import { ApiContext } from "./app"; -export function useDestinationTypes(): Record< - string, - DestinationTypeReference -> { +export function useDestinationTypes(): + | Record + | undefined { const apiClient = useContext(ApiContext); const { data } = useSWR( "destination-types", @@ -14,7 +13,7 @@ export function useDestinationTypes(): Record< { revalidateIfStale: false }, ); if (!data) { - return {}; + return undefined; } return data.reduce( (acc, type) => { @@ -30,7 +29,7 @@ export function useDestinationType( ): DestinationTypeReference | undefined { const destination_types = useDestinationTypes(); - if (!type) { + if (!type || !destination_types) { return undefined; } diff --git a/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx b/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx index 268f6aa58..a047a67a8 100644 --- a/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx +++ b/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx @@ -10,7 +10,14 @@ import { useNavigate, useSearchParams, } from "react-router-dom"; -import { useState, useMemo, useCallback, useEffect, createContext, useContext } from "react"; +import { + useState, + useMemo, + useCallback, + useEffect, + createContext, + useContext, +} from "react"; import type { DestinationTypeReference } from "../../typings/Destination"; import { useDestinationTypes } from "../../destination-types"; import CONFIGS from "../../config"; @@ -73,8 +80,9 @@ export default function CreateDestination() { const location = useLocation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const destinationTypes = useDestinationTypes(); - const hasDestinationTypes = Object.keys(destinationTypes).length > 0; + const loadedDestinationTypes = useDestinationTypes(); + const hasDestinationTypes = loadedDestinationTypes !== undefined; + const destinationTypes = loadedDestinationTypes ?? {}; // Hydrate stepValues from URL search params on mount (supports page refresh) const [stepValues, setStepValues] = useState>(() => { @@ -145,7 +153,15 @@ export default function CreateDestination() { steps, buildSearchParams, }), - [stepValues, setStepValues, destinationTypes, hasDestinationTypes, nextPath, steps, buildSearchParams], + [ + stepValues, + setStepValues, + destinationTypes, + hasDestinationTypes, + nextPath, + steps, + buildSearchParams, + ], ); return ( diff --git a/internal/portal/src/scenes/DestinationsList/DestinationList.tsx b/internal/portal/src/scenes/DestinationsList/DestinationList.tsx index 1e048560b..77e1dcfa3 100644 --- a/internal/portal/src/scenes/DestinationsList/DestinationList.tsx +++ b/internal/portal/src/scenes/DestinationsList/DestinationList.tsx @@ -26,14 +26,15 @@ const DestinationList: React.FC = () => { () => destinations?.map((d) => d.id) ?? [], [destinations], ); - const { data: batchedMetrics, isLoading: metricsLoading } = - useBatchedMetrics({ + const { data: batchedMetrics, isLoading: metricsLoading } = useBatchedMetrics( + { measures: ["successful_count", "failed_count"], destinationIds, timeframe: "24h", granularity: "4h", filters: {}, - }); + }, + ); const [searchTerm, setSearchTerm] = useState(""); const [selectedStatus, setSelectedStatus] = useState>( @@ -83,67 +84,69 @@ const DestinationList: React.FC = () => { }) : []; - const table_rows = - filtered_destinations?.map((destination) => ({ - id: destination.id, - entries: [ - <> -
- - {destination_types[destination.type].label} - - , - {destination.target}, - CONFIGS.TOPICS ? ( - - {(destination.topics.length > 0 && destination.topics[0] === "*" - ? CONFIGS.TOPICS.split(",") - : destination.topics - ) - .slice(0, 9) - .map((topic) => ( - - ))} - {(destination.topics[0] === "*" - ? CONFIGS.TOPICS.split(",").length - : destination.topics.length) > 9 && ( - - +{" "} - {(destination.topics[0] === "*" - ? CONFIGS.TOPICS.split(",").length - : destination.topics.length) - 9}{" "} - more - - )} -
- } - > - - {destination.topics.length > 0 && destination.topics[0] === "*" - ? "All" - : destination.topics.length} + const table_rows = destination_types + ? filtered_destinations?.map((destination) => ({ + id: destination.id, + entries: [ + <> +
+ + {destination_types[destination.type].label} - - ) : null, - destination.disabled_at ? ( - - ) : ( - - ), - , - ].filter((entry) => entry !== null), - link: `/destinations/${destination.id}`, - })) || []; + , + {destination.target}, + CONFIGS.TOPICS ? ( + + {(destination.topics.length > 0 && + destination.topics[0] === "*" + ? CONFIGS.TOPICS.split(",") + : destination.topics + ) + .slice(0, 9) + .map((topic) => ( + + ))} + {(destination.topics[0] === "*" + ? CONFIGS.TOPICS.split(",").length + : destination.topics.length) > 9 && ( + + +{" "} + {(destination.topics[0] === "*" + ? CONFIGS.TOPICS.split(",").length + : destination.topics.length) - 9}{" "} + more + + )} +
+ } + > + + {destination.topics.length > 0 && destination.topics[0] === "*" + ? "All" + : destination.topics.length} + + + ) : null, + destination.disabled_at ? ( + + ) : ( + + ), + , + ].filter((entry) => entry !== null), + link: `/destinations/${destination.id}`, + })) || [] + : []; const logo = getLogo(); From 26d066a19498d7d7f1f7f3da61411d27ae0e1039 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Wed, 27 May 2026 20:48:43 +0700 Subject: [PATCH 2/2] chore(portal): apply prettier to drifted files --- .../src/common/JSONViewer/JSONViewer.tsx | 34 +++++++++++++------ .../src/common/MetricsChart/useMetrics.ts | 5 +-- .../portal/src/common/Sparkline/Sparkline.tsx | 6 +++- .../CreateDestination/steps/ConfigStep.tsx | 12 ++----- .../scenes/Destination/DestinationMetrics.tsx | 8 +++-- 5 files changed, 37 insertions(+), 28 deletions(-) diff --git a/internal/portal/src/common/JSONViewer/JSONViewer.tsx b/internal/portal/src/common/JSONViewer/JSONViewer.tsx index 64df551e5..7f52743b7 100644 --- a/internal/portal/src/common/JSONViewer/JSONViewer.tsx +++ b/internal/portal/src/common/JSONViewer/JSONViewer.tsx @@ -27,7 +27,9 @@ const JSONViewer = ({ data, label }: JSONViewerProps) => { return paths; }, [data]); - const [expanded, setExpanded] = useState>(() => new Set(allPaths)); + const [expanded, setExpanded] = useState>( + () => new Set(allPaths), + ); const allExpanded = expanded.size >= allPaths.size; @@ -52,14 +54,22 @@ const JSONViewer = ({ data, label }: JSONViewerProps) => {
{label &&

{label}

}
-
- +
); @@ -157,7 +167,8 @@ const CollapsibleNode = ({ if (count === 0) { return ( - {openBracket}{closeBracket} + {openBracket} + {closeBracket} ); } @@ -168,10 +179,10 @@ const CollapsibleNode = ({ if (!isExpanded) { return ( ); @@ -180,9 +191,10 @@ const CollapsibleNode = ({ return (
{entries.map((entry, i) => ( diff --git a/internal/portal/src/common/MetricsChart/useMetrics.ts b/internal/portal/src/common/MetricsChart/useMetrics.ts index c0c444a75..3e88ca0e2 100644 --- a/internal/portal/src/common/MetricsChart/useMetrics.ts +++ b/internal/portal/src/common/MetricsChart/useMetrics.ts @@ -186,10 +186,7 @@ export function useBatchedMetrics({ } } - params.set( - "granularity", - granularityOverride ?? getGranularity(timeframe), - ); + params.set("granularity", granularityOverride ?? getGranularity(timeframe)); return `metrics/attempts?${params.toString()}`; }, [idsKey, measuresKey, filtersKey, granularityOverride, timeframe]); diff --git a/internal/portal/src/common/Sparkline/Sparkline.tsx b/internal/portal/src/common/Sparkline/Sparkline.tsx index 35dcb1475..8df7b488b 100644 --- a/internal/portal/src/common/Sparkline/Sparkline.tsx +++ b/internal/portal/src/common/Sparkline/Sparkline.tsx @@ -30,7 +30,11 @@ const Sparkline: React.FC = ({ data }) => { const successPct = 100 - failPct; return ( -
+
{d.failed > 0 && (
- sidebar.toggle( - "filter-syntax", - , - ) + sidebar.toggle("filter-syntax", ) } className="filter-section__guide-button" > diff --git a/internal/portal/src/scenes/Destination/DestinationMetrics.tsx b/internal/portal/src/scenes/Destination/DestinationMetrics.tsx index 62a9896bf..9aaa700e9 100644 --- a/internal/portal/src/scenes/Destination/DestinationMetrics.tsx +++ b/internal/portal/src/scenes/Destination/DestinationMetrics.tsx @@ -165,7 +165,9 @@ const DestinationMetrics: React.FC = ({
{/* Row 2 — Error Breakdown (3 columns) */} -
+
= ({ />
)} -
+