diff --git a/packages/core/src/components/EndTimeContext.tsx b/packages/core/src/components/EndTimeContext.tsx index 3f1cbd92..b4c7514a 100644 --- a/packages/core/src/components/EndTimeContext.tsx +++ b/packages/core/src/components/EndTimeContext.tsx @@ -1,3 +1,68 @@ -import { createContext } from "react"; +import { + ReactNode, + createContext, + useContext, + useEffect, + useState, + useTransition, +} from "react"; -export const EndTimeContext = createContext(Infinity); +const EndTimeContext = createContext<{ + endTime: number; + visibleEndTime: number; + changeEndTime: (value: number) => void; + isPending: boolean; +}>({ + endTime: Infinity, + visibleEndTime: Infinity, + changeEndTime: () => {}, + isPending: false, +}); + +export function EndTimeProvider({ + maxEndTime, + children, +}: { + maxEndTime: number; + children: ReactNode; +}) { + const [endTime, setEndTime] = useState(Infinity); + const [visibleEndTime, setVisibleEndTime] = useState(endTime); + const [isPending, startTransition] = useTransition(); + + const changeEndTime = (value: number) => { + setVisibleEndTime(value); + startTransition(() => { + setEndTime(value); + }); + }; + + useEffect(() => { + if (endTime !== maxEndTime) { + changeEndTime(maxEndTime); + } + }, [maxEndTime]); + + return ( + +
{children}
+
+ ); +} + +export function useEndTime() { + const context = useContext(EndTimeContext); + + if (context === undefined) { + throw new Error("useEndTime must be used within a EndTimeContext.Provider"); + } + + return context; +} diff --git a/packages/core/src/components/FlightResponse.stories.tsx b/packages/core/src/components/FlightResponse.stories.tsx deleted file mode 100644 index 20f7b3cf..00000000 --- a/packages/core/src/components/FlightResponse.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { FlightResponse } from "./FlightResponse"; -import { - createFlightResponse, - processBinaryChunk, -} from "@rsc-parser/react-client"; -import { alvarDevExampleData } from "../example-data/alvar-dev"; -import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; -import { nextjsOrgExampleData } from "../example-data/nextjs-org"; -import { isRscChunkEvent } from "../events"; - -const meta: Meta = { - component: FlightResponse, -}; - -export default meta; -type Story = StoryObj; - -export const alvarDev: Story = { - name: "alvar.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of alvarDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const ghFredKissDev: Story = { - name: "gh.fredkiss.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of ghFredkissDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const nextjsOrg: Story = { - name: "nextjs.org", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of nextjsOrgExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; diff --git a/packages/core/src/components/FlightResponse.tsx b/packages/core/src/components/FlightResponse.tsx deleted file mode 100644 index be91fd14..00000000 --- a/packages/core/src/components/FlightResponse.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import { TabList, Tab, TabPanel, TabProvider } from "@ariakit/react"; - -import { GenericErrorBoundaryFallback } from "./GenericErrorBoundaryFallback"; -import type { FlightResponse } from "@rsc-parser/react-client"; -import { FlightResponseTabSplit } from "./FlightResponseTabSplit"; -import { FlightResponseTabNetwork } from "./FlightResponseTabNetwork"; -import { FlightResponseTabRaw } from "./FlightResponseTabRaw"; - -export function FlightResponse({ - flightResponse, -}: { - flightResponse: FlightResponse; -}) { - return ( - -
- -
- {flightResponse._chunks.length === 0 ? ( - No data for current time frame - ) : ( - - Data from {flightResponse._chunks.length} fetch chunk - {flightResponse._chunks.length === 1 ? "" : "s"} - - )} - - - - Split - - - Raw - - - Network (Beta) - - -
-
- - - - - - - - - - - - - - - - - -
-
-
-
- ); -} diff --git a/packages/core/src/components/FlightResponseSelector.stories.tsx b/packages/core/src/components/FlightResponseSelector.stories.tsx deleted file mode 100644 index 6f5405f0..00000000 --- a/packages/core/src/components/FlightResponseSelector.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { - FlightResponseSelector, - useFlightResponseSelector, -} from "./FlightResponseSelector"; -import { alvarDevExampleData } from "../example-data/alvar-dev"; -import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; -import { nextjsOrgExampleData } from "../example-data/nextjs-org"; - -const meta: Meta = { - component: FlightResponseSelector, -}; - -export default meta; -type Story = StoryObj; - -export const alvarDev: Story = { - name: "alvar.dev", - render: () => { - const pathTabs = useFlightResponseSelector(alvarDevExampleData, { - follow: false, - }); - - return ( - -

Tab content goes

-
- ); - }, -}; - -export const ghFredKissDev: Story = { - name: "gh.fredkiss.dev", - render: () => { - const pathTabs = useFlightResponseSelector(ghFredkissDevExampleData, { - follow: false, - }); - - return ( - -

Tab content goes

-
- ); - }, -}; - -export const nextjsOrg: Story = { - name: "nextjs.org", - render: () => { - const pathTabs = useFlightResponseSelector(nextjsOrgExampleData, { - follow: false, - }); - - return ( - -

Tab content goes

-
- ); - }, -}; diff --git a/packages/core/src/components/FlightResponseSelector.tsx b/packages/core/src/components/FlightResponseSelector.tsx deleted file mode 100644 index c7f523c9..00000000 --- a/packages/core/src/components/FlightResponseSelector.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { - ReactNode, - useEffect, - useMemo, - useState, - useTransition, -} from "react"; -import { TabList, Tab, TabPanel, useTabStore } from "@ariakit/react"; -import { RscEvent, isRscRequestEvent, isRscResponseEvent } from "../events"; -import { getColorForFetch } from "../color"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; - -export function useFlightResponseSelector( - events: RscEvent[], - { - follow, - }: { - follow: boolean; - }, -) { - const tabs = useMemo(() => { - const tabs: string[] = []; - - const sorted = events.sort((a, b) => a.data.timestamp - b.data.timestamp); - - for (const event of sorted) { - if (tabs.find((tab) => tab === String(event.data.requestId))) { - continue; - } - tabs.push(String(event.data.requestId)); - } - - return tabs; - }, [events]); - - const [isPending, startTransition] = useTransition(); - const [selectedTab, setSelectedTab] = useState( - null, - ); - const [currentTab, setCurrentTab] = useState(null); - - const selectTab = (nextTab: string | null | undefined) => { - if (nextTab !== selectedTab) { - setSelectedTab(nextTab); - startTransition(() => { - setCurrentTab(nextTab); - }); - } - }; - - useEffect(() => { - if (follow) { - const lastTab = tabs.at(-1); - if (lastTab !== selectedTab) { - selectTab(String(lastTab)); - } - } - }, [tabs]); - - const tabStore = useTabStore({ - selectedId: selectedTab, - setSelectedId: selectTab, - }); - - return { - tabs, - events, - isPending, - currentTab, - tabStore, - }; -} - -export function FlightResponseSelector({ - isPending, - tabStore, - tabs, - events, - currentTab, - children, -}: ReturnType & { children: ReactNode }) { - return ( - - - - {tabs.map((tab) => { - const eventsForTab = events.filter( - (event) => event.data.requestId === tab, - ); - const [requestEvent] = eventsForTab.filter(isRscRequestEvent); - const [responseEvent] = eventsForTab.filter(isRscResponseEvent); - - if (!requestEvent) { - return null; - } - - const { method, url } = requestEvent.data; - const { status } = responseEvent?.data ?? {}; - - return ( - -
-
event.data.requestId === tab) - ?.data.requestId ?? "0", - ), - }} - >
-
- - {method} ({status ?? "..."}) - - - {new URL(url).pathname} - - - {new URL(url).search} - -
-
-
- ); - })} -
-
- - - - - - {children} - - -
- ); -} diff --git a/packages/core/src/components/FlightResponseTabNetwork.stories.tsx b/packages/core/src/components/FlightResponseTabNetwork.stories.tsx deleted file mode 100644 index 3c2695c0..00000000 --- a/packages/core/src/components/FlightResponseTabNetwork.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { FlightResponseTabNetwork } from "./FlightResponseTabNetwork"; -import { alvarDevExampleData } from "../example-data/alvar-dev"; -import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; -import { nextjsOrgExampleData } from "../example-data/nextjs-org"; -import { - createFlightResponse, - processBinaryChunk, -} from "@rsc-parser/react-client"; -import { isRscChunkEvent } from "../events"; - -const meta: Meta = { - component: FlightResponseTabNetwork, -}; - -export default meta; -type Story = StoryObj; - -export const alvarDev: Story = { - name: "alvar.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of alvarDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const ghFredKissDev: Story = { - name: "gh.fredkiss.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of ghFredkissDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const nextjsOrg: Story = { - name: "nextjs.org", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of nextjsOrgExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; diff --git a/packages/core/src/components/FlightResponseTabRaw.stories.tsx b/packages/core/src/components/FlightResponseTabRaw.stories.tsx deleted file mode 100644 index bf5c5ebd..00000000 --- a/packages/core/src/components/FlightResponseTabRaw.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { FlightResponseTabRaw } from "./FlightResponseTabRaw"; -import { alvarDevExampleData } from "../example-data/alvar-dev"; -import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; -import { nextjsOrgExampleData } from "../example-data/nextjs-org"; -import { - createFlightResponse, - processBinaryChunk, -} from "@rsc-parser/react-client"; -import { isRscChunkEvent } from "../events"; - -const meta: Meta = { - component: FlightResponseTabRaw, -}; - -export default meta; -type Story = StoryObj; - -export const alvarDev: Story = { - name: "alvar.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of alvarDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const ghFredkissDev: Story = { - name: "gh.fredkiss.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of ghFredkissDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const nextjsOrg: Story = { - name: "nextjs.org", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of nextjsOrgExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; diff --git a/packages/core/src/components/FlightResponseTabRaw.tsx b/packages/core/src/components/FlightResponseTabRaw.tsx deleted file mode 100644 index 872d15ac..00000000 --- a/packages/core/src/components/FlightResponseTabRaw.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { useContext } from "react"; -import { FlightResponse } from "@rsc-parser/react-client"; -import { FlightResponseChunkRaw } from "./FlightResponseChunkRaw"; -import { EndTimeContext } from "./EndTimeContext"; - -export function FlightResponseTabRaw({ - flightResponse, -}: { - flightResponse: FlightResponse; -}) { - const endTime = useContext(EndTimeContext); - - const timeFilteredChunks = flightResponse._chunks.filter( - (chunk) => chunk.timestamp <= endTime, - ); - - return ( -
    - {timeFilteredChunks.map((chunk) => ( -
  • - -
  • - ))} -
- ); -} diff --git a/packages/core/src/components/FlightResponseTabSplit.stories.tsx b/packages/core/src/components/FlightResponseTabSplit.stories.tsx deleted file mode 100644 index 48ff8c1f..00000000 --- a/packages/core/src/components/FlightResponseTabSplit.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { FlightResponseTabSplit } from "./FlightResponseTabSplit"; -import { alvarDevExampleData } from "../example-data/alvar-dev"; -import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; -import { nextjsOrgExampleData } from "../example-data/nextjs-org"; -import { - createFlightResponse, - processBinaryChunk, -} from "@rsc-parser/react-client"; -import { isRscChunkEvent } from "../events"; - -const meta: Meta = { - component: FlightResponseTabSplit, -}; - -export default meta; -type Story = StoryObj; - -export const alvarDev: Story = { - name: "alvar.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of alvarDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const ghFredKissDev: Story = { - name: "gh.fredkiss.dev", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of ghFredkissDevExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; - -export const nextjsOrg: Story = { - name: "nextjs.org", - render: () => { - const flightResponse = createFlightResponse(); - for (const event of nextjsOrgExampleData.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk( - flightResponse, - Uint8Array.from(event.data.chunkValue), - ); - } - return ; - }, -}; diff --git a/packages/core/src/components/RequestDetail.tsx b/packages/core/src/components/RequestDetail.tsx new file mode 100644 index 00000000..9a3cff4e --- /dev/null +++ b/packages/core/src/components/RequestDetail.tsx @@ -0,0 +1,78 @@ +import { TabList, Tab, TabPanel, TabProvider } from "@ariakit/react"; +import { RscEvent, isRscRequestEvent, isRscResponseEvent } from "../events"; +import { GenericErrorBoundaryFallback } from "./GenericErrorBoundaryFallback"; +import { ErrorBoundary } from "react-error-boundary"; +import { RequestDetailTabRawPayload } from "./RequestDetailTabRawPayload"; +import { RequestDetailTabParsedPayload } from "./RequestDetailTabParsedPayload"; +import { RequestDetailTabNetwork } from "./RequestDetailTabNetwork"; +import { RequestDetailTabHeaders } from "./RequestDetailTabHeaders"; +import { useTabStoreWithTransitions } from "./useTabStoreWithTransitions"; + +export function RequestDetail({ events }: { events: RscEvent[] }) { + const { currentTab, isPending, tabStore } = useTabStoreWithTransitions({ + defaultSelectedId: "parsedPayload", + }); + + return ( + +
+ + + isRscRequestEvent(event) || isRscResponseEvent(event), + ) + } + > + Headers + + + Parsed payload + + + Raw payload + + + Network (Beta) + + + + + + {currentTab === "headers" ? ( + + ) : null} + {currentTab === "parsedPayload" ? ( + + ) : null} + {currentTab === "rawPayload" ? ( + + ) : null} + {currentTab === "network" ? ( + + ) : null} + + +
+
+ ); +} diff --git a/packages/core/src/components/RequestDetailTabEmptyState.tsx b/packages/core/src/components/RequestDetailTabEmptyState.tsx new file mode 100644 index 00000000..b8002ebb --- /dev/null +++ b/packages/core/src/components/RequestDetailTabEmptyState.tsx @@ -0,0 +1,3 @@ +export function RequestDetailTabEmptyState() { + return "No data found for the current time frame."; +} diff --git a/packages/core/src/components/RequestDetailTabHeaders.stories.tsx b/packages/core/src/components/RequestDetailTabHeaders.stories.tsx new file mode 100644 index 00000000..ff1f82b8 --- /dev/null +++ b/packages/core/src/components/RequestDetailTabHeaders.stories.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { RequestDetailTabHeaders } from "./RequestDetailTabHeaders"; +import { alvarDevExampleData } from "../example-data/alvar-dev"; +import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; +import { nextjsOrgExampleData } from "../example-data/nextjs-org"; + +const meta: Meta = { + component: RequestDetailTabHeaders, +}; + +export default meta; +type Story = StoryObj; + +export const alvarDev: Story = { + name: "alvar.dev", + render: () => { + return ; + }, +}; + +export const ghFredKissDev: Story = { + name: "gh.fredkiss.dev", + render: () => { + return ; + }, +}; + +export const nextjsOrg: Story = { + name: "nextjs.org", + render: () => { + return ; + }, +}; diff --git a/packages/core/src/components/RequestDetailTabHeaders.tsx b/packages/core/src/components/RequestDetailTabHeaders.tsx new file mode 100644 index 00000000..fee3786b --- /dev/null +++ b/packages/core/src/components/RequestDetailTabHeaders.tsx @@ -0,0 +1,44 @@ +import { RscEvent, isRscRequestEvent, isRscResponseEvent } from "../events"; + +export function RequestDetailTabHeaders({ events }: { events: RscEvent[] }) { + const requestEvent = events.filter(isRscRequestEvent)[0]; + const responseEvent = events.filter(isRscResponseEvent)[0]; + + return ( +
+
+

Request headers

+ {requestEvent ? ( + + ) : ( + "No response headers" + )} +
+
+

Response headers

+ {responseEvent ? ( + + ) : ( + "No request headers" + )} +
+
+ ); +} + +function HeadersTable({ headers }: { headers: Record }) { + return ( + + + {Object.entries(headers).map(([key, value]) => ( + + + + + ))} + +
{key} + {value} +
+ ); +} diff --git a/packages/core/src/components/RequestDetailTabNetwork.stories.tsx b/packages/core/src/components/RequestDetailTabNetwork.stories.tsx new file mode 100644 index 00000000..f193702a --- /dev/null +++ b/packages/core/src/components/RequestDetailTabNetwork.stories.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { RequestDetailTabNetwork } from "./RequestDetailTabNetwork"; +import { alvarDevExampleData } from "../example-data/alvar-dev"; +import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; +import { nextjsOrgExampleData } from "../example-data/nextjs-org"; + +const meta: Meta = { + component: RequestDetailTabNetwork, +}; + +export default meta; +type Story = StoryObj; + +export const alvarDev: Story = { + name: "alvar.dev", + render: () => { + return ; + }, +}; + +export const ghFredKissDev: Story = { + name: "gh.fredkiss.dev", + render: () => { + return ; + }, +}; + +export const nextjsOrg: Story = { + name: "nextjs.org", + render: () => { + return ; + }, +}; diff --git a/packages/core/src/components/FlightResponseTabNetwork.tsx b/packages/core/src/components/RequestDetailTabNetwork.tsx similarity index 83% rename from packages/core/src/components/FlightResponseTabNetwork.tsx rename to packages/core/src/components/RequestDetailTabNetwork.tsx index 98007047..716d3df2 100644 --- a/packages/core/src/components/FlightResponseTabNetwork.tsx +++ b/packages/core/src/components/RequestDetailTabNetwork.tsx @@ -1,7 +1,15 @@ -import { Chunk, FlightResponse, isReference } from "@rsc-parser/react-client"; -import React, { memo, useContext, useEffect, useRef, useState } from "react"; +import { + Chunk, + createFlightResponse, + isReference, + processBinaryChunk, +} from "@rsc-parser/react-client"; +import React, { memo, useEffect, useRef, useState } from "react"; import * as d3 from "d3"; -import { EndTimeContext } from "./EndTimeContext"; +import { useEndTime } from "./EndTimeContext"; +import { RscEvent, isRscChunkEvent } from "../events"; +import { eventsFilterByMaxTimestamp } from "../eventArrayHelpers"; +import { RequestDetailTabEmptyState } from "./RequestDetailTabEmptyState"; interface Node extends d3.SimulationNodeDatum { chunk: Chunk; @@ -85,12 +93,21 @@ function getLinks(chunks: Chunk[], id: string) { ); } -export function FlightResponseTabNetwork({ - flightResponse, -}: { - flightResponse: FlightResponse; -}) { - const endTime = useContext(EndTimeContext); +export function RequestDetailTabNetwork({ events }: { events: RscEvent[] }) { + const { endTime } = useEndTime(); + + if ( + eventsFilterByMaxTimestamp(events, endTime).filter(isRscChunkEvent) + .length === 0 + ) { + return ; + } + + const flightResponse = createFlightResponse(); + for (const event of events.filter(isRscChunkEvent)) { + flightResponse._currentTimestamp = event.data.timestamp; + processBinaryChunk(flightResponse, Uint8Array.from(event.data.chunkValue)); + } const nodes: Node[] = flightResponse._chunks.map((chunk) => { return { @@ -109,8 +126,11 @@ export function FlightResponseTabNetwork({ return; } const resizeObserver = new ResizeObserver(() => { - setSvgWidth(svgRef.current!.clientWidth); - setSvgHeight(svgRef.current!.clientHeight); + if (!svgRef.current) { + return; + } + setSvgWidth(svgRef.current.clientWidth); + setSvgHeight(svgRef.current.clientHeight); simulation.current = undefined; }); resizeObserver.observe(svgRef.current); diff --git a/packages/core/src/components/RequestDetailTabParsedPayload.stories.tsx b/packages/core/src/components/RequestDetailTabParsedPayload.stories.tsx new file mode 100644 index 00000000..cf5f4fd1 --- /dev/null +++ b/packages/core/src/components/RequestDetailTabParsedPayload.stories.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { RequestDetailTabParsedPayload } from "./RequestDetailTabParsedPayload"; +import { alvarDevExampleData } from "../example-data/alvar-dev"; +import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; +import { nextjsOrgExampleData } from "../example-data/nextjs-org"; + +const meta: Meta = { + component: RequestDetailTabParsedPayload, +}; + +export default meta; +type Story = StoryObj; + +export const alvarDev: Story = { + name: "alvar.dev", + render: () => { + return ; + }, +}; + +export const ghFredKissDev: Story = { + name: "gh.fredkiss.dev", + render: () => { + return ; + }, +}; + +export const nextjsOrg: Story = { + name: "nextjs.org", + render: () => { + return ; + }, +}; diff --git a/packages/core/src/components/FlightResponseTabSplit.tsx b/packages/core/src/components/RequestDetailTabParsedPayload.tsx similarity index 92% rename from packages/core/src/components/FlightResponseTabSplit.tsx rename to packages/core/src/components/RequestDetailTabParsedPayload.tsx index 9368fc1d..9e64b9db 100644 --- a/packages/core/src/components/FlightResponseTabSplit.tsx +++ b/packages/core/src/components/RequestDetailTabParsedPayload.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState, useTransition } from "react"; +import React, { useState, useTransition } from "react"; import { Disclosure, DisclosureContent, @@ -10,21 +10,43 @@ import { } from "@ariakit/react"; import { ErrorBoundary } from "react-error-boundary"; import { GenericErrorBoundaryFallback } from "./GenericErrorBoundaryFallback"; -import { FlightResponse, Chunk } from "@rsc-parser/react-client"; +import { + Chunk, + createFlightResponse, + processBinaryChunk, +} from "@rsc-parser/react-client"; import { FlightResponseChunkModule } from "./FlightResponseChunkModule"; import { FlightResponseChunkHint } from "./FlightResponseChunkHint"; import { FlightResponseChunkModel } from "./FlightResponseChunkModel"; import { DownArrowIcon, RightArrowIcon } from "./FlightResponseIcons"; -import { EndTimeContext } from "./EndTimeContext"; import { FlightResponseChunkDebugInfo } from "./FlightResponseChunkDebugInfo"; import { FlightResponseChunkText } from "./FlightResponseChunkText"; import { FlightResponseChunkUnknown } from "./FlightResponseChunkUnknown"; +import { useEndTime } from "./EndTimeContext"; +import { RscEvent, isRscChunkEvent } from "../events"; +import { RequestDetailTabEmptyState } from "./RequestDetailTabEmptyState"; +import { eventsFilterByMaxTimestamp } from "../eventArrayHelpers"; -export function FlightResponseTabSplit({ - flightResponse, +export function RequestDetailTabParsedPayload({ + events, }: { - flightResponse: FlightResponse; + events: RscEvent[]; }) { + const { endTime } = useEndTime(); + + if ( + eventsFilterByMaxTimestamp(events, endTime).filter(isRscChunkEvent) + .length === 0 + ) { + return ; + } + + const flightResponse = createFlightResponse(); + for (const event of events.filter(isRscChunkEvent)) { + flightResponse._currentTimestamp = event.data.timestamp; + processBinaryChunk(flightResponse, Uint8Array.from(event.data.chunkValue)); + } + const [isPending, startTransition] = useTransition(); const [selectedTab, setSelectedTab] = useState( null, @@ -67,8 +89,6 @@ export function FlightResponseTabSplit({ setSelectedId: selectTab, }); - const endTime = useContext(EndTimeContext); - const timeFilteredChunks = flightResponse._chunks.filter( (chunk) => chunk.timestamp <= endTime, ); diff --git a/packages/core/src/components/RequestDetailTabRawPayload.stories.tsx b/packages/core/src/components/RequestDetailTabRawPayload.stories.tsx new file mode 100644 index 00000000..c38a1b86 --- /dev/null +++ b/packages/core/src/components/RequestDetailTabRawPayload.stories.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { RequestDetailTabRawPayload } from "./RequestDetailTabRawPayload"; +import { alvarDevExampleData } from "../example-data/alvar-dev"; +import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; +import { nextjsOrgExampleData } from "../example-data/nextjs-org"; + +const meta: Meta = { + component: RequestDetailTabRawPayload, +}; + +export default meta; +type Story = StoryObj; + +export const alvarDev: Story = { + name: "alvar.dev", + render: () => { + return ; + }, +}; + +export const ghFredkissDev: Story = { + name: "gh.fredkiss.dev", + render: () => { + return ; + }, +}; + +export const nextjsOrg: Story = { + name: "nextjs.org", + render: () => { + return ; + }, +}; diff --git a/packages/core/src/components/RequestDetailTabRawPayload.tsx b/packages/core/src/components/RequestDetailTabRawPayload.tsx new file mode 100644 index 00000000..a265efea --- /dev/null +++ b/packages/core/src/components/RequestDetailTabRawPayload.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { FlightResponseChunkRaw } from "./FlightResponseChunkRaw"; +import { useEndTime } from "./EndTimeContext"; +import { RscEvent, isRscChunkEvent } from "../events"; +import { + createFlightResponse, + processBinaryChunk, +} from "@rsc-parser/react-client"; +import { eventsFilterByMaxTimestamp } from "../eventArrayHelpers"; +import { RequestDetailTabEmptyState } from "./RequestDetailTabEmptyState"; + +export function RequestDetailTabRawPayload({ events }: { events: RscEvent[] }) { + const { endTime } = useEndTime(); + + if ( + eventsFilterByMaxTimestamp(events, endTime).filter(isRscChunkEvent) + .length === 0 + ) { + return ; + } + + const flightResponse = createFlightResponse(); + for (const event of events.filter(isRscChunkEvent)) { + flightResponse._currentTimestamp = event.data.timestamp; + processBinaryChunk(flightResponse, Uint8Array.from(event.data.chunkValue)); + } + + const timeFilteredChunks = flightResponse._chunks.filter( + (chunk) => chunk.timestamp <= endTime, + ); + + return ( +
    + {timeFilteredChunks.map((chunk) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/packages/core/src/components/TimeScrubber.stories.tsx b/packages/core/src/components/TimeScrubber.stories.tsx index 74201bdf..886de0b3 100644 --- a/packages/core/src/components/TimeScrubber.stories.tsx +++ b/packages/core/src/components/TimeScrubber.stories.tsx @@ -1,10 +1,11 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react"; -import { TimeScrubber, useTimeScrubber } from "./TimeScrubber"; +import { TimeScrubber } from "./TimeScrubber"; import { alvarDevExampleData } from "../example-data/alvar-dev"; import { ghFredkissDevExampleData } from "../example-data/gh-fredkiss-dev"; import { nextjsOrgExampleData } from "../example-data/nextjs-org"; +import { EndTimeProvider } from "./EndTimeContext"; const meta: Meta = { component: TimeScrubber, @@ -16,32 +17,47 @@ type Story = StoryObj; export const alvarDev: Story = { name: "alvar.dev", render: () => { - const timeScrubber = useTimeScrubber(alvarDevExampleData, { - follow: false, - }); - - return ; + return ( + + + ; + + ); }, }; export const ghFredKissDev: Story = { name: "gh.fredkiss.dev", render: () => { - const timeScrubber = useTimeScrubber(ghFredkissDevExampleData, { - follow: false, - }); - - return ; + return ( + + + ; + + ); }, }; export const nextjsOrg: Story = { name: "nextjs.org", render: () => { - const timeScrubber = useTimeScrubber(nextjsOrgExampleData, { - follow: false, - }); - - return ; + return ( + + + ; + + ); }, }; diff --git a/packages/core/src/components/TimeScrubber.tsx b/packages/core/src/components/TimeScrubber.tsx index 494af8d9..e36946da 100644 --- a/packages/core/src/components/TimeScrubber.tsx +++ b/packages/core/src/components/TimeScrubber.tsx @@ -1,43 +1,8 @@ -import React, { useEffect, useMemo, useState, useTransition } from "react"; +import React, { useMemo } from "react"; import { RscEvent } from "../events"; import { getColorForFetch } from "../color"; - -export function useTimeScrubber( - events: RscEvent[], - { follow }: { follow: boolean }, -) { - const { minStartTime, maxEndTime } = useTimeRange(events); - const [endTime, setEndTime] = useState(maxEndTime); - - const [visibleEndTime, setVisibleEndTime] = useState(endTime); - const [isPending, startTransition] = useTransition(); - - const changeEndTime = (value: number) => { - setVisibleEndTime(value); - startTransition(() => { - setEndTime(value); - }); - }; - - useEffect(() => { - if (follow) { - if (endTime !== maxEndTime) { - changeEndTime(maxEndTime); - } - } - }, [events]); - - return { - events, - endTime, - visibleEndTime, - changeEndTime, - isPending, - startTransition, - minStartTime, - maxEndTime, - }; -} +import { eventsFilterByMaxTimestamp } from "../eventArrayHelpers"; +import { useEndTime } from "./EndTimeContext"; function useTracks(events: RscEvent[]) { return useMemo(() => { @@ -84,14 +49,16 @@ function useTracks(events: RscEvent[]) { export function TimeScrubber({ events, - endTime, - visibleEndTime, - changeEndTime, - isPending, minStartTime, maxEndTime, -}: ReturnType) { - const filteredEvents = useFilterEventsByEndTime(events, endTime); +}: { + events: RscEvent[]; + minStartTime: number; + maxEndTime: number; +}) { + const { endTime, visibleEndTime, changeEndTime, isPending } = useEndTime(); + + const filteredEvents = eventsFilterByMaxTimestamp(events, endTime); const tracks = useTracks(events); const eventHeight = 12; @@ -229,29 +196,3 @@ export function TimeScrubber({ ); } - -function useTimeRange(events: RscEvent[]) { - return useMemo(() => { - let minStartTime = Number.MAX_SAFE_INTEGER; - let maxEndTime = 0; - - for (const event of events) { - minStartTime = Math.min(minStartTime, event.data.timestamp); - maxEndTime = Math.max(maxEndTime, event.data.timestamp); - } - - const timeRange = maxEndTime - minStartTime; - - return { - minStartTime, - maxEndTime, - timeRange, - }; - }, [events]); -} - -export function useFilterEventsByEndTime(events: RscEvent[], endTime: number) { - return useMemo(() => { - return events.filter((event) => event.data.timestamp <= endTime); - }, [events, endTime]); -} diff --git a/packages/core/src/components/ViewerPayload.tsx b/packages/core/src/components/ViewerPayload.tsx index 79fa7b5c..317e25eb 100644 --- a/packages/core/src/components/ViewerPayload.tsx +++ b/packages/core/src/components/ViewerPayload.tsx @@ -2,13 +2,9 @@ import React, { ChangeEvent, useEffect, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { GenericErrorBoundaryFallback } from "./GenericErrorBoundaryFallback"; -import { - createFlightResponse, - processBinaryChunk, -} from "@rsc-parser/react-client"; import { RscChunkEvent } from "../events"; -import { FlightResponse } from "./FlightResponse"; -import { EndTimeContext } from "./EndTimeContext"; +import { EndTimeProvider } from "./EndTimeContext"; +import { RequestDetail } from "./RequestDetail"; export function ViewerPayload({ defaultPayload }: { defaultPayload: string }) { const [payload, setPayload] = useState(defaultPayload); @@ -63,14 +59,9 @@ export function Viewer({ payload }: { payload: string }) { } satisfies RscChunkEvent, ]; - const flightResponse = createFlightResponse(); - for (const event of events) { - processBinaryChunk(flightResponse, Uint8Array.from(event.data.chunkValue)); - } - return ( - - - + + + ); } diff --git a/packages/core/src/components/ViewerStreams.tsx b/packages/core/src/components/ViewerStreams.tsx index bf8dad46..49f32e28 100644 --- a/packages/core/src/components/ViewerStreams.tsx +++ b/packages/core/src/components/ViewerStreams.tsx @@ -1,122 +1,120 @@ import React from "react"; -import { TabList, Tab, TabPanel, TabProvider } from "@ariakit/react"; +import { TabList, Tab, TabPanel } from "@ariakit/react"; +import { RscEvent, isRscRequestEvent, isRscResponseEvent } from "../events"; +import { TimeScrubber } from "./TimeScrubber"; +import { EndTimeProvider, useEndTime } from "./EndTimeContext"; import { - createFlightResponse, - processBinaryChunk, -} from "@rsc-parser/react-client"; -import { - RscEvent, - isRscChunkEvent, - isRscRequestEvent, - isRscResponseEvent, -} from "../events"; -import { FlightResponse } from "./FlightResponse"; -import { - FlightResponseSelector, - useFlightResponseSelector, -} from "./FlightResponseSelector"; -import { TimeScrubber, useTimeScrubber } from "./TimeScrubber"; -import { useFilterEventsByEndTime } from "./TimeScrubber"; -import { EndTimeContext } from "./EndTimeContext"; + eventsFilterByMaxTimestamp, + eventsFilterByRequestId, + eventsGetMinMaxTimestamps, + eventsSortByTimestamp, + eventsUniqueRequestIds, +} from "../eventArrayHelpers"; +import { useTabStoreWithTransitions } from "./useTabStoreWithTransitions"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { getColorForFetch } from "../color"; +import { RequestDetail } from "./RequestDetail"; export function ViewerStreams({ events }: { events: RscEvent[] }) { - const timeScrubber = useTimeScrubber(events, { - follow: true, - }); - - const timeFilteredEvents = useFilterEventsByEndTime( - events, - timeScrubber.endTime, - ); + const { minStartTime, maxEndTime } = eventsGetMinMaxTimestamps(events); - const pathTabs = useFlightResponseSelector(timeFilteredEvents, { - follow: false, - }); + return ( + +
+ - const eventsForCurrentTab = timeFilteredEvents.filter( - (event) => event.data.requestId == pathTabs.currentTab, + +
+
); +} - const flightResponse = createFlightResponse(); - for (const event of eventsForCurrentTab.filter(isRscChunkEvent)) { - flightResponse._currentTimestamp = event.data.timestamp; - processBinaryChunk(flightResponse, Uint8Array.from(event.data.chunkValue)); - } +function Requests({ events }: { events: RscEvent[] }) { + const { endTime } = useEndTime(); + const { currentTab, isPending, tabStore } = + useTabStoreWithTransitions(undefined); - const requestEvent = eventsForCurrentTab.filter(isRscRequestEvent)[0]; - const responseEvent = eventsForCurrentTab.filter(isRscResponseEvent)[0]; + const sortedEvents = eventsSortByTimestamp(events); + const timeFilteredEvents = eventsFilterByMaxTimestamp(sortedEvents, endTime); + const tabs = eventsUniqueRequestIds(events); return ( -
- + + + + {tabs.map((tab) => { + return ( + + + + ); + })} + + + + - - - {!pathTabs.currentTab ? ( + + + {!currentTab ? ( Please select a url ) : ( - - - - Flight response - - - Headers - - - - - - - - -
-

Request headers

- {requestEvent ? ( - - ) : ( - "No response headers" - )} -
-
-

Response headers

- {responseEvent ? ( - - ) : ( - "No request headers" - )} -
-
-
+ )} -
-
-
+ + + ); } -function HeadersTable({ headers }: { headers: Record }) { +function RequestTab({ events }: { events: RscEvent[] }) { + const [requestEvent] = events.filter(isRscRequestEvent); + const [responseEvent] = events.filter(isRscResponseEvent); + + if (!requestEvent) { + return null; + } + + const { method, url } = requestEvent.data; + const { status } = responseEvent?.data ?? {}; + return ( - - - {Object.entries(headers).map(([key, value]) => ( - - - - - ))} - -
{key} - {value} -
+
+
+
+ + {method} ({status ?? "..."}) + + + {new URL(url).pathname} + + + {new URL(url).search} + +
+
); } diff --git a/packages/core/src/components/useTabStoreWithTransitions.ts b/packages/core/src/components/useTabStoreWithTransitions.ts new file mode 100644 index 00000000..dff22bd3 --- /dev/null +++ b/packages/core/src/components/useTabStoreWithTransitions.ts @@ -0,0 +1,35 @@ +import { useTabStore } from "@ariakit/react"; +import { useState, useTransition } from "react"; + +export function useTabStoreWithTransitions( + useTabStoreArgs: Parameters[0], +) { + const [isPending, startTransition] = useTransition(); + const [selectedTab, setSelectedTab] = useState( + useTabStoreArgs?.defaultSelectedId ?? null, + ); + const [currentTab, setCurrentTab] = useState( + useTabStoreArgs?.defaultSelectedId ?? null, + ); + + const selectTab = (nextTab: string | null | undefined) => { + if (nextTab !== selectedTab) { + setSelectedTab(nextTab); + startTransition(() => { + setCurrentTab(nextTab); + }); + } + }; + + const tabStore = useTabStore({ + selectedId: selectedTab, + setSelectedId: selectTab, + ...useTabStoreArgs, + }); + + return { + isPending, + currentTab, + tabStore, + }; +} diff --git a/packages/core/src/eventArrayHelpers.ts b/packages/core/src/eventArrayHelpers.ts new file mode 100644 index 00000000..cd26d1ec --- /dev/null +++ b/packages/core/src/eventArrayHelpers.ts @@ -0,0 +1,45 @@ +import { RscEvent } from "./events"; + +export function eventsSortByTimestamp(events: RscEvent[]) { + return events.sort((a, b) => a.data.timestamp - b.data.timestamp); +} + +export function eventsFilterByMaxTimestamp( + events: RscEvent[], + endTime: number, +) { + return events.filter((event) => event.data.timestamp <= endTime); +} + +export function eventsGetMinMaxTimestamps(events: RscEvent[]) { + let minStartTime = Number.MAX_SAFE_INTEGER; + let maxEndTime = 0; + + for (const event of events) { + minStartTime = Math.min(minStartTime, event.data.timestamp); + maxEndTime = Math.max(maxEndTime, event.data.timestamp); + } + + return { + minStartTime, + maxEndTime, + }; +} + +export function eventsUniqueRequestIds(events: RscEvent[]) { + const requestIds: string[] = []; + + for (const event of events) { + if (requestIds.includes(event.data.requestId)) { + continue; + } + + requestIds.push(event.data.requestId); + } + + return requestIds; +} + +export function eventsFilterByRequestId(events: RscEvent[], requestId: string) { + return events.filter((event) => event.data.requestId === requestId); +}