diff --git a/packages/bvaughn-architecture-demo/components/ErrorBoundary.module.css b/packages/bvaughn-architecture-demo/components/ErrorBoundary.module.css new file mode 100644 index 00000000000..0159c605bc0 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/ErrorBoundary.module.css @@ -0,0 +1,5 @@ +.Error { + background-color: var(--color-red-light); + color: var(--color-red-dark); + padding: 1rem; +} diff --git a/packages/bvaughn-architecture-demo/components/ErrorBoundary.tsx b/packages/bvaughn-architecture-demo/components/ErrorBoundary.tsx new file mode 100644 index 00000000000..ee9deba80ad --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/ErrorBoundary.tsx @@ -0,0 +1,28 @@ +import React, { Component, PropsWithChildren } from "react"; + +import styles from "./ErrorBoundary.module.css"; + +type ErrorBoundaryState = { error: Error | null }; + +export default class ErrorBoundary extends Component, ErrorBoundaryState> { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error): ErrorBoundaryState { + return { error }; + } + + render() { + const { error } = this.state; + + if (error !== null) { + return ( +
+
{error.message}
+
{error.stack}
+
+ ); + } + + return this.props.children; + } +} diff --git a/packages/bvaughn-architecture-demo/components/Initializer.tsx b/packages/bvaughn-architecture-demo/components/Initializer.tsx new file mode 100644 index 00000000000..89336894232 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/Initializer.tsx @@ -0,0 +1,66 @@ +import { number, string } from "prop-types"; +import { client, initSocket } from "protocol/socket"; +import { ReactNode, useEffect, useRef, useState } from "react"; + +import { SessionContext } from "../src/contexts"; + +const DISPATCH_URL = "wss://dispatch.replay.io"; + +// HACK Hack around the fact that the initSocket() function is side effectful +// and writes to an "app" global on the window object. +if (typeof window !== "undefined") { + (window as any).app = {}; +} + +type ContextType = { duration: number; endPoint: string; recordingId: string; sessionId: string }; + +export default function Initializer({ children }: { children: ReactNode }) { + const [context, setContext] = useState(null); + const didInitializeRef = useRef(false); + + useEffect(() => { + // The WebSocket and session/authentication are global. + // We only need to initialize them once. + if (!didInitializeRef.current) { + const asyncInitialize = async () => { + initSocket(DISPATCH_URL); + + // Read some of the hard-coded values from query params. + // (This is just a prototype; no sense building a full authentication flow.) + const url = new URL(window.location.href); + const accessToken = url.searchParams.get("accessToken"); + if (!accessToken) { + throw Error(`Must specify "accessToken" parameter.`); + } + const recordingId = url.searchParams.get("recordingId"); + if (!recordingId) { + throw Error(`Must specify "recordingId" parameter.`); + } + + // Authenticate + await client.Authentication.setAccessToken({ accessToken }); + + // Create session + const { sessionId } = await client.Recording.createSession({ recordingId }); + const { endpoint } = await client.Session.getEndpoint({}, sessionId); + + setContext({ + duration: endpoint.time, + endPoint: endpoint.point, + recordingId, + sessionId, + }); + }; + + asyncInitialize(); + } + + didInitializeRef.current = true; + }, []); + + if (context === null) { + return null; + } + + return {children}; +} diff --git a/packages/bvaughn-architecture-demo/components/Loader.module.css b/packages/bvaughn-architecture-demo/components/Loader.module.css new file mode 100644 index 00000000000..f74bd029747 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/Loader.module.css @@ -0,0 +1,3 @@ +.Loader { + padding: 1rem; +} diff --git a/packages/bvaughn-architecture-demo/components/Loader.tsx b/packages/bvaughn-architecture-demo/components/Loader.tsx new file mode 100644 index 00000000000..5b7b2dbd35f --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/Loader.tsx @@ -0,0 +1,5 @@ +import styles from "./Loader.module.css"; + +export default function Loader() { + return
Loading...
; +} diff --git a/packages/bvaughn-architecture-demo/components/Messages.module.css b/packages/bvaughn-architecture-demo/components/Messages.module.css new file mode 100644 index 00000000000..5698b3cf9e3 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/Messages.module.css @@ -0,0 +1,52 @@ +.Container, +.ContainerPending { + position: relative; + overflow: auto; + height: 100%; +} +.ContainerPending { + opacity: 0.5; +} + +.CountRow, +.MessageRow, +.NoMessagesRow, +.OverflowRow { + padding: 0.25rem; +} + +.OverflowRow { + background-color: var(--color-red-dark); + color: var(--color-white); +} + +.CountRow { + background-color: var(--color-gray-1); +} + +.MessageRow, +.MessageRowError, +.MessageRowWarning { + display: flex; + flex-direction: row; + gap: 1ch; + padding: 0.25rem; + font-family: monospace; + font-size: 10px; + border-bottom: 1px solid var(--color-gray-1); +} +.MessageRow { + background-color: var(--color-white); +} +.MessageRowError { + background-color: var(--color-red-light); + color: 1px solid var(--color-red-dark); +} +.MessageRowWarning { + background-color: var(--color-yellow-light); + color: var(--color-yellow-dark); +} + +.Time { + color: var(--color-blue); +} diff --git a/packages/bvaughn-architecture-demo/components/Messages.tsx b/packages/bvaughn-architecture-demo/components/Messages.tsx new file mode 100644 index 00000000000..3aee6f7fbd6 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/Messages.tsx @@ -0,0 +1,101 @@ +import { memo, useContext } from "react"; +import { Message } from "@replayio/protocol"; + +import { SessionContext } from "../src/contexts"; +import { useFilteredMessages } from "../src/hooks"; +import { getMessages } from "../src/MessagesCache"; +import { getClosestPointForTime } from "../src/PointsCache"; +import { formatTimestamp } from "../src/utils/time"; + +import styles from "./Messages.module.css"; + +export default function Messages({ + focusStartTime, + focusEndTime, + isPending, + showErrors, + showLogs, + showWarnings, +}: { + focusStartTime: number | null; + focusEndTime: number | null; + isPending: boolean; + showErrors: boolean; + showLogs: boolean; + showWarnings: boolean; +}) { + const { sessionId } = useContext(SessionContext); + + let focusMode = null; + if (focusStartTime !== null && focusEndTime !== null) { + const startPoint = getClosestPointForTime(focusStartTime, sessionId); + const endPoint = getClosestPointForTime(focusEndTime, sessionId); + + focusMode = { + begin: { + point: startPoint, + time: focusStartTime, + }, + end: { + point: endPoint, + time: focusEndTime, + }, + }; + } + + const { countAfter, countBefore, didOverflow, messages } = getMessages(sessionId, focusMode); + + // TODO Derived memoized selector that joins log points and messages and filters by criteria (e.g. type) + const filteredMessages = useFilteredMessages(messages, { showErrors, showLogs, showWarnings }); + + return ( +
+ {didOverflow && ( +
There were too many messages to fetch them all
+ )} + {countBefore > 0 && ( +
+ {countBefore} messages filtered before the focus range +
+ )} + {filteredMessages.length === 0 && ( +
No messages found.
+ )} + {filteredMessages.map((message: Message, index: number) => ( + + ))} + {countAfter > 0 && ( +
{countAfter} messages filtered after the focus range
+ )} +
+ ); +} + +// This is a really crappy approximation of the console renderer. +// It isn't meant to be the focus of this branch. +const Row = memo(function Row({ message }: { message: Message }) { + const { argumentValues, point, text } = message; + + let className = styles.MessageRow; + switch (message.level) { + case "warning": { + className = styles.MessageRowWarning; + break; + } + case "error": { + className = styles.MessageRowError; + break; + } + } + + return ( +
+
{formatTimestamp(point.time)}
+ {text} + {argumentValues != null && + argumentValues.map((argumentValue: any, index: number) => ( + {argumentValue.value || argumentValue.object} + ))} +
+ ); +}); diff --git a/packages/bvaughn-architecture-demo/package-lock.json b/packages/bvaughn-architecture-demo/package-lock.json index a386809b861..1c2796bbbba 100644 --- a/packages/bvaughn-architecture-demo/package-lock.json +++ b/packages/bvaughn-architecture-demo/package-lock.json @@ -6,7 +6,23 @@ "packages": { "": { "name": "bvaughn-architecture-demo", - "version": "0.1.0" + "version": "0.1.0", + "dependencies": { + "protocol": "file:../protocol" + } + }, + "../protocol": { + "version": "0.0.0", + "devDependencies": {} + }, + "node_modules/protocol": { + "resolved": "../protocol", + "link": true + } + }, + "dependencies": { + "protocol": { + "version": "file:../protocol" } } } diff --git a/packages/bvaughn-architecture-demo/pages/_app.tsx b/packages/bvaughn-architecture-demo/pages/_app.tsx new file mode 100644 index 00000000000..bf890a83881 --- /dev/null +++ b/packages/bvaughn-architecture-demo/pages/_app.tsx @@ -0,0 +1,56 @@ +import Head from "next/head"; +import type { AppContext, AppProps } from "next/app"; +import NextApp from "next/app"; +import React from "react"; + +import ErrorBoundary from "../components/ErrorBoundary"; +import Initializer from "../components/Initializer"; +import Loader from "../components/Loader"; + +import "./global.css"; + +interface AuthProps { + apiKey?: string; +} + +function Routing({ Component, pageProps }: AppProps) { + return ( + <> + + + + Replay + + + + }> + + + + + + ); +} + +const App = ({ apiKey, ...props }: AppProps & AuthProps) => { + return ; +}; + +App.getInitialProps = (appContext: AppContext) => { + const props = NextApp.getInitialProps(appContext); + const authHeader = appContext.ctx.req?.headers.authorization; + const authProps: AuthProps = { apiKey: undefined }; + + if (authHeader) { + const [scheme, token] = authHeader.split(" ", 2); + if (!token || !/^Bearer$/i.test(scheme)) { + console.error("Format is Authorization: Bearer [token]"); + } else { + authProps.apiKey = token; + } + } + + return { ...props, ...authProps }; +}; + +export default App; diff --git a/packages/bvaughn-architecture-demo/pages/global.css b/packages/bvaughn-architecture-demo/pages/global.css new file mode 100644 index 00000000000..5c9676afd95 --- /dev/null +++ b/packages/bvaughn-architecture-demo/pages/global.css @@ -0,0 +1,23 @@ +html, +body { + padding: 0; + margin: 0; + font-family: sans-serif; + font-size: 14px; +} + +* { + box-sizing: content-box; +} + +:root { + --color-blue: #0a84ff; + --color-gray-1: #d0d5dc; + --color-gray-2: #9ca3af; + --color-gray-3: #234; + --color-red-dark: #ea2330; + --color-red-light: #ffb3d2; + --color-yellow-dark: #6c5914; + --color-yellow-light: #fffac8; + --color-white: #fff; +} diff --git a/packages/bvaughn-architecture-demo/pages/index.module.css b/packages/bvaughn-architecture-demo/pages/index.module.css new file mode 100644 index 00000000000..55dd1c595cd --- /dev/null +++ b/packages/bvaughn-architecture-demo/pages/index.module.css @@ -0,0 +1,87 @@ +.Container { + display: flex; + flex-direction: column; + height: 100vh; +} + +.ContentArea { + flex: 1 1 auto; + overflow: auto; +} + +.Row { + display: flex; + flex-direction: row; + gap: 1ch; + align-items: center; +} + +.FocusRegionRowOff, +.FocusRegionRowOn { + padding: 1ch; + display: flex; + flex-direction: row; + flex: 1; + gap: 1ch; + align-items: center; +} +.FocusRegionRowOff { + background-color: var(--color-gray-1); +} +.FocusRegionRowOn { + background-color: var(--color-blue); +} + +.FocusToggleButton { + width: 8ch; + cursor: pointer; + border-radius: 0.25rem; + border: none; + padding: 0.25rem; +} + +.RangeSlider { + flex: 1; + position: relative; + display: flex; + flex-direction: row; + align-items: center; +} + +.RangeTrackFocused { + background-color: rgba(0, 0, 0, 0.25); + height: 4px; +} +.RangeTrackUnfocused { + height: 2px; + background-color: rgba(0, 0, 0, 0.1); +} + +.FocusedRange { + background-color: red; +} + +.RangeStartThumb, +.RangeEndThumb { + position: absolute; + cursor: grab; + width: 1px; + height: 20px; + width: 6px; +} +.RangeStartThumb { + background-color: var(--color-gray-3); + border-radius: 4px 0px 0px 4px; +} +.RangeEndThumb { + background-color: var(--color-gray-3); + border-radius: 0px 4px 4px 0px; +} + +.FocusTimeStamps { + white-space: nowrap; + font-size: 12px; + background-color: rgba(255, 255, 255, 0.5); + border-radius: 4px; + padding: 2px 4px; +} diff --git a/packages/bvaughn-architecture-demo/pages/index.tsx b/packages/bvaughn-architecture-demo/pages/index.tsx index a7a802e0085..d2276e5a340 100644 --- a/packages/bvaughn-architecture-demo/pages/index.tsx +++ b/packages/bvaughn-architecture-demo/pages/index.tsx @@ -1,16 +1,265 @@ -import { ThreadFront } from "protocol/thread"; +import { + MutableRefObject, + Suspense, + useContext, + useEffect, + useRef, + useState, + useTransition, +} from "react"; +import ErrorBoundary from "../components/ErrorBoundary"; -import ReplayClient from "../src/ReplayClient"; +import Loader from "../components/Loader"; +import Messages from "../components/Messages"; +import { SessionContext } from "../src/contexts"; +import { formatTimestamp } from "../src/utils/time"; -const client = new ReplayClient(ThreadFront); +import styles from "./index.module.css"; -// TODO Suspense cache read for protocol messages -// TODO Derived memoized selector that joins log points and messages -// TODO Simple UI inputs for setting and clearing focus region (which invalidates Suspense cache) -// TODO Simple UI list for merged and sorted list +type Range = [number, number]; -function HomePage() { - return
Welcome to Next.js!
; +// TODO There's a hot loop when an error happens (e.g. Linker too old to support Console.findMessagesInRange) +// where React keeps quickly retrying after an error is thrown, rather than rendering an error boundary. +// Filed https://github.com/facebook/react/issues/24634 + +export default function HomePage() { + const { duration } = useContext(SessionContext); + + const [applyFocusRange, setApplyFocusRange] = useState(false); + + const [range, setRange] = useState([0, 1]); + const [deferredRange, setDeferredRange] = useState([0, 1]); + + // Using a deferred values enables the focus UI to update quickly, + // and the slower operation of Suspending to load points to be deferred. + // + // It also allows us to update the UI slightly, before we suspend to fetch new data, + // to indicate that what's currently being showed is stale. + const [isPending, startTransition] = useTransition(); + + const [start, end] = range; + const [deferredStart, deferredEnd] = deferredRange; + + const onFoucsRangeChange = (newStart: number, newEnd: number) => { + setRange([newStart, newEnd]); + + // Changing the focus range may cause us to suspend (while fetching new info from the backend). + // Wrapping it in a transition enables us to show the older set of messages (in a pending state) while new data loads. + // This is less jarring than the alternative of unmounting all messages and rendering a fallback loader. + startTransition(() => { + setDeferredRange([newStart, newEnd]); + }); + }; + + const toggleApplyFocusRange = () => { + // Changing the focus range may cause us to suspend (while fetching new info from the backend). + // Wrapping it in a transition enables us to show the older set of messages (in a pending state) while new data loads. + // This is less jarring than the alternative of unmounting all messages and rendering a fallback loader. + startTransition(() => setApplyFocusRange(!applyFocusRange)); + }; + + // TODO UI for adding logpoints and some in-memory store for them. + const [showErrors, setShowErrors] = useState(true); + const [showLogs, setShowLogs] = useState(true); + const [showWarnings, setShowWarnings] = useState(true); + + // TODO Once we inject a client implementation, inject a wrapper one + // that also reports cache hits and misses to this UI in a debug panel somewhere. + + return ( +
+
+ + + +
+ + +
+
+
+ + }> + + + +
+
+ ); } -export default HomePage; +function RangeSlider({ + active, + duration, + end, + onChange, + start, +}: { + active: boolean; + duration: number; + end: number; + onChange: (start: number, end: number) => void; + start: number; +}) { + const ref = useRef(null); + + const [pendingStart, setPendingStart] = useState(start); + const [pendingEnd, setPendingEnd] = useState(end); + + return ( + <> +
+
+
+
+ +
+ + setPendingStart(value)} + onSave={() => onChange(pendingStart, end)} + parentRef={ref} + value={pendingStart} + /> + setPendingEnd(value)} + onSave={() => onChange(start, pendingEnd)} + parentRef={ref} + value={pendingEnd} + /> +
+
+ {formatTimestamp(pendingStart * duration)} – {formatTimestamp(pendingEnd * duration)} +
+ + ); +} + +function SliderThumb({ + className, + maximumValue, + minimumValue, + onChange, + onSave, + parentRef, + value, +}: { + className: string; + maximumValue: number; + minimumValue: number; + onChange: (value: number) => void; + onSave: () => void; + parentRef: MutableRefObject; + value: number; +}) { + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + const parent = parentRef.current; + + if (!isDragging || parent === null) { + return; + } + + // Move events happen very frequently and rarely reflect final intent. + // We don't want to flood the backend with requests that won't even be used, + // so we wait until the drag action has completed before applying the new range. + const processDragUpdate = (event: MouseEvent) => { + const { movementX, pageX } = event; + if (movementX !== 0) { + const bounds = parent.getBoundingClientRect(); + const relativeX = pageX - bounds.left; + + const clampedValue = Math.max( + minimumValue, + Math.min(maximumValue, relativeX / bounds.width) + ); + + onChange(clampedValue); + } + }; + + const stopDrag = () => { + setIsDragging(false); + onSave(); + }; + + window.addEventListener("mouseleave", stopDrag); + window.addEventListener("mousemove", processDragUpdate); + window.addEventListener("mouseup", stopDrag); + + return () => { + window.removeEventListener("mouseleave", stopDrag); + window.removeEventListener("mousemove", processDragUpdate); + window.removeEventListener("mouseup", stopDrag); + }; + }); + + return ( +
setIsDragging(true)} + style={{ left: `${value * 100}%`, cursor: isDragging ? "grabbing" : "grab" }} + /> + ); +} diff --git a/packages/bvaughn-architecture-demo/src/MessagesCache.ts b/packages/bvaughn-architecture-demo/src/MessagesCache.ts new file mode 100644 index 00000000000..dbf20d4f2a1 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/MessagesCache.ts @@ -0,0 +1,228 @@ +import { Message, SessionId, TimeStampedPointRange } from "@replayio/protocol"; +import { client } from "protocol/socket"; + +import { compareNumericStrings } from "./utils/string"; +import { formatTimestamp, isRangeEqual, isRangeSubset } from "./utils/time"; + +import { Wakeable } from "./types"; + +// TODO Should I use React's Suspense cache APIs here? +// It's tempting to think that I don't need to, because the recording session data is global, +// but could this cause problems if React wants to render a high-priority update while a lower one is suspended? + +// TODO With a large recording (e.g. recordingId=244fac49-fc9c-4558-8819-25693e04292b) +// toggling focus off and back on again causes two sequential, visible render+suspend+resolve chunks. +// We should bail out on the first one once the second one comes in. +// React doesn't even try to render the second one while the first one is suspending though. + +let inFlightWakeable: Wakeable | null = null; +let inFlightFocusRange: TimeStampedPointRange | null = null; + +let lastFetchDidOverflow: boolean = false; +let lastFetchedFocusRange: TimeStampedPointRange | null = null; +let lastFetchedMessages: Message[] | null = null; + +let lastFilteredFocusRange: TimeStampedPointRange | null = null; +let lastFilteredMessages: Message[] = null; +let lastFilteredCountAfter: number = 0; +let lastFilteredCountBefore: number = 0; + +type getMessagesResponse = { + countAfter: number; + countBefore: number; + didOverflow: boolean; + messages: Message[]; +}; + +// Synchronously returns an array of filtered Messages, +// or throws a Wakeable to be resolved once messages have been fetched. +// +// This method is Suspense friend; it is meant to be called from a React component during render. +export function getMessages( + sessionId: SessionId, + focusRange: TimeStampedPointRange | null +): getMessagesResponse { + console.log("getMessages() %s", printFocusRange(focusRange)); + if (focusRange !== null && focusRange.begin.point === focusRange.end.point) { + // Edge case scenario handling. + // The backend throws if both points in a range are the same. + // Arguably it should handle this more gracefully by just returning an empty array but... + return { + countAfter: -1, + countBefore: -1, + didOverflow: false, + messages: [], + }; + } + + if (inFlightWakeable !== null) { + // If we're already fetching this data, rethrow the same Wakeable (for Suspense reasons). + // We check equality here rather than subset because it's possible a larger range might overflow. + if (isRangeEqual(inFlightFocusRange, focusRange)) { + throw inFlightWakeable; + } + } + + // We only need to refetch data if one of the following conditions is true. + let shouldFetch = false; + if (lastFetchedMessages === null) { + // We have not yet fetched it at all. + shouldFetch = true; + } else if (lastFetchDidOverflow && !isRangeEqual(lastFetchedFocusRange, focusRange)) { + // The most recent time we fetched it "overflowed" (too many messages to send them all), + // and we're trying to fetch a different region. + // + // There are two things to note about this case. + // 1. When devtools is first opened, there is no focused region. + // This is equivalent to focusing on the entire timeline, so we often won't need to refetch messages when focusing for the first time. + // 2. We shouldn't compare the new focus region to the most recent focus region, + // but rather to the most recent focus region that we fetched messages for (the entire timeline in many cases). + // If we don't need to refetch after zooming in, then we won't need to refetch after zooming back out either, + // (unless our fetches have overflowed at some point). + shouldFetch = true; + } else if (!isRangeSubset(lastFetchedFocusRange, focusRange)) { + // The new focus region is outside of the most recent region we fetched messages for. + shouldFetch = true; + } + + if (shouldFetch) { + console.log( + "%cSuspending for %s", + "color: red; font-weight: bold;", + printFocusRange(focusRange) + ); + inFlightFocusRange = focusRange; + const promise = (inFlightWakeable = new Promise(async (resolve, reject) => { + try { + console.debug( + "getMessage() Suspending to find messages in range", + printFocusRange(focusRange) + ); + + // TODO Replace these client references with ReplayClient instance. + // Maybe that instance should have some of the "soft focus" and filtering logic baked into it. + // This could simplify the React-specific APIs and how we mock/stub for testing. + + let messages: Message[] = null as Message[]; + let overflow: boolean = false; + + if (focusRange !== null) { + const response = await client.Console.findMessagesInRange( + { range: { begin: focusRange.begin.point, end: focusRange.end.point } }, + sessionId + ); + + messages = response.messages; + overflow = response.overflow; + } else { + messages = []; + + // TOOD This won't work if there are every overlapping requests. + client.Console.addNewMessageListener(({ message }) => { + messages.push(message); + }); + + const response = await client.Console.findMessages({}, sessionId); + + client.Console.removeNewMessageListener(); + + overflow = response.overflow; + } + console.log( + "%cResovling for %s (%s)", + "color: green; font-weight: bold;", + printFocusRange(focusRange), + inFlightWakeable === promise + ); + + // Only update cached values if this request hasn't been superceded by a newer one. + // + // TODO In the future we could merge new messages over time (assuming no overflow) + // to avoid re-fetching previously fetched ranges if a user scrubs around with the focus UI. + // We'd have to be careful though to only merge data from overlapping points, + // so that we didn't omit messages that happened between points. + // I'm still a little unclear on the exact relationship between time and point. + if (inFlightWakeable === promise) { + inFlightWakeable = null; + + // Messages aren't guaranteed to arrive sorted. + // Presorting this unfiltered list now saves us from having to sort filtered lists later. + // + // TODO Is this only required for Console.findMessages()? + // Can we skip it for Console.findMessagesInRange()? + messages = messages.sort((messageA: Message, messageB: Message) => { + const pointA = messageA.point.point; + const pointB = messageB.point.point; + return compareNumericStrings(pointA, pointB); + }); + + lastFetchDidOverflow = overflow; + lastFetchedFocusRange = focusRange; + lastFetchedMessages = messages; + } + + resolve(); + } catch (error) { + inFlightFocusRange = null; + inFlightWakeable = null; + + console.error("getMessage() Error through for range", printFocusRange(focusRange), error); + + reject(error); + } + })); + + throw inFlightWakeable; + } + + // At this point, messages have been fetched but we may still need to filter them. + // For performance reasons (both in this function and on things that consume the filtered list) + // it's best if we memoize this operation to avoid recreating the filtered array. + if (lastFilteredMessages === null || !isRangeEqual(lastFilteredFocusRange, focusRange)) { + if (focusRange === null) { + lastFilteredFocusRange = null; + lastFilteredCountAfter = 0; + lastFilteredCountBefore = 0; + lastFilteredMessages = lastFetchedMessages; + } else { + const begin = focusRange.begin.time; + const end = focusRange.end.time; + + lastFilteredFocusRange = focusRange; + lastFilteredCountAfter = 0; + lastFilteredCountBefore = 0; + lastFilteredMessages = lastFetchedMessages.filter(message => { + const time = message.point.time; + if (time < begin) { + lastFilteredCountBefore++; + return false; + } else if (time > end) { + lastFilteredCountAfter++; + return false; + } else { + return true; + } + }); + } + } + + // Note that the only time when it's safe for us to specify the number of trimmed messages + // is when we are trimming from the complete set of messages (aka no focus region). + // Otherwise even if we do trim some messages locally, the number isn't meaningful. + return { + countAfter: lastFetchedFocusRange === null ? lastFilteredCountAfter : -1, + countBefore: lastFetchedFocusRange === null ? lastFilteredCountBefore : -1, + didOverflow: lastFetchDidOverflow, + messages: lastFilteredMessages, + }; +} + +function printFocusRange(focusRange: TimeStampedPointRange | null): string { + if (focusRange === null) { + return "null"; + } else { + return `${formatTimestamp(focusRange.begin.time)} - ${formatTimestamp(focusRange.end.time)} (${ + focusRange.begin.point + }:${focusRange.end.point})`; + } +} diff --git a/packages/bvaughn-architecture-demo/src/PointsCache.ts b/packages/bvaughn-architecture-demo/src/PointsCache.ts new file mode 100644 index 00000000000..ca163bea46f --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/PointsCache.ts @@ -0,0 +1,78 @@ +import { ExecutionPoint, SessionId } from "@replayio/protocol"; +import { client } from "protocol/socket"; + +import { Wakeable } from "./types"; + +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +type PendingRecord = { + status: 0; + value: Wakeable; +}; + +type ResolvedRecord = { + status: 1; + value: ExecutionPoint; +}; + +type RejectedRecord = { + status: 2; + value: Error | string; +}; + +type Record = PendingRecord | ResolvedRecord | RejectedRecord; + +// TODO We could add some way for external code (in the client adapter) to pre-populate this cache with known points. +const timeToRecordMap: Map = new Map(); + +export function getClosestPointForTime(time: number, sessionId: SessionId): ExecutionPoint { + let record = timeToRecordMap.get(time); + if (record == null) { + const callbacks: Set = new Set(); + const wakeable: Wakeable = { + then(callback) { + callbacks.add(callback); + }, + }; + + const wake = () => { + // This assumes they won't throw. + callbacks.forEach(callback => callback()); + callbacks.clear(); + }; + + record = { + status: Pending, + value: wakeable, + }; + + timeToRecordMap.set(time, record); + + console.debug(`getClosestPointForTime(${time}) Suspending to find point point ...`); + + fetchPoint(time, sessionId, record, wake); + } + + if (record.status === Resolved) { + return record.value; + } else { + throw record.value; + } +} + +async function fetchPoint(time: number, sessionId: SessionId, record: Record, wake: Function) { + try { + // TODO Replace these client references with ReplayClient instance. + const timeStampedPoint = await client.Session.getPointNearTime({ time: time }, sessionId); + + record.status = Resolved; + record.value = timeStampedPoint.point.point; + } catch (error) { + record.status = Rejected; + record.value = new Error(`Could not load points for time ${time}`); + } finally { + wake(); + } +} diff --git a/packages/bvaughn-architecture-demo/src/ReplayClient.ts b/packages/bvaughn-architecture-demo/src/ReplayClient.ts index 43bd54f519a..25c81c65a4d 100644 --- a/packages/bvaughn-architecture-demo/src/ReplayClient.ts +++ b/packages/bvaughn-architecture-demo/src/ReplayClient.ts @@ -1,7 +1,8 @@ import { ExecutionPoint, Message, SessionId, TimeStampedPointRange } from "@replayio/protocol"; import { client } from "protocol/socket"; import type { ThreadFront } from "protocol/thread"; -import { isRangeSubset } from "./utils"; + +import { isRangeSubset } from "./utils/time"; // TODO How should the client handle concurrent requests? // Should we force serialization? @@ -69,7 +70,7 @@ class MessageClient { // In some cases, this will require re-fetching from the backend. async setFocus(focusRange: TimeStampedPointRange | null): Promise { if ( - this._lastFetchFocusRange === null || + this._unfilteredMessages === null || this._lastFetchDidOverflow || !isRangeSubset(this._lastFetchFocusRange, focusRange) ) { diff --git a/packages/bvaughn-architecture-demo/src/contexts.ts b/packages/bvaughn-architecture-demo/src/contexts.ts new file mode 100644 index 00000000000..9242adf1ab8 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/contexts.ts @@ -0,0 +1,9 @@ +import { ExecutionPoint } from "@replayio/protocol"; +import { createContext } from "react"; + +export const SessionContext = createContext<{ + duration: number; + endPoint: ExecutionPoint; + recordingId: string; + sessionId: string; +}>(null); diff --git a/packages/bvaughn-architecture-demo/src/hooks.ts b/packages/bvaughn-architecture-demo/src/hooks.ts new file mode 100644 index 00000000000..80852d7579e --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/hooks.ts @@ -0,0 +1,39 @@ +import { Message } from "@replayio/protocol"; +import { useMemo } from "react"; + +// TODO Return type +export function useFilteredMessages( + messages: Message[], + options: { + showErrors: boolean; + showLogs: boolean; + showWarnings: boolean; + } +): Message[] { + const { showErrors, showLogs, showWarnings } = options; + + const filteredMessages = useMemo(() => { + if (showErrors && showLogs && showWarnings) { + return messages; + } else { + return messages.filter(message => { + switch (message.level) { + case "warning": { + return showWarnings; + break; + } + case "error": { + return showErrors; + break; + } + default: { + return showLogs; + break; + } + } + }); + } + }, [messages, showErrors, showLogs, showWarnings]); + + return filteredMessages; +} diff --git a/packages/bvaughn-architecture-demo/src/types.ts b/packages/bvaughn-architecture-demo/src/types.ts new file mode 100644 index 00000000000..6efffc3d534 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/types.ts @@ -0,0 +1,5 @@ +// The subset of a Promise that React APIs rely on. +// You can use the Promise API or one that resolves synchronously (and skips the microtask queue) if you prefer. +export interface Wakeable { + then(onFulfill: () => any, onReject: () => any): void | Wakeable; +} diff --git a/packages/bvaughn-architecture-demo/src/utils.ts b/packages/bvaughn-architecture-demo/src/utils.ts deleted file mode 100644 index 21fcd10a87c..00000000000 --- a/packages/bvaughn-architecture-demo/src/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TimeStampedPointRange } from "@replayio/protocol"; - -export function isRangeSubset( - prevRange: TimeStampedPointRange | null, - nextRange: TimeStampedPointRange | null -): boolean { - if (prevRange === null) { - // There was no previous range constraint. - // No matter what the new range is, it will be a subset. - return true; - } else if (nextRange === null) { - // There is no new range constraint. - // No matter what the previous range was, the new one is not a subset. - return false; - } else { - return nextRange.begin.time >= prevRange.begin.time && nextRange.end.time <= prevRange.end.time; - } -} diff --git a/packages/bvaughn-architecture-demo/src/utils/string.ts b/packages/bvaughn-architecture-demo/src/utils/string.ts new file mode 100644 index 00000000000..b3d2f97d9a7 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/utils/string.ts @@ -0,0 +1,3 @@ +export function compareNumericStrings(a: string, b: string): number { + return a.length < b.length ? -1 : a.length > b.length ? 1 : a < b ? -1 : a > b ? 1 : 0; +} diff --git a/packages/bvaughn-architecture-demo/src/utils/suspense.ts b/packages/bvaughn-architecture-demo/src/utils/suspense.ts new file mode 100644 index 00000000000..b7462538c1a --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/utils/suspense.ts @@ -0,0 +1,15 @@ +export function suspendInParallel(callbacks: Function[]): void { + let thrownValue = null; + + callbacks.forEach(callback => { + try { + callback(); + } catch (error) { + thrownValue = error; + } + }); + + if (thrownValue !== null) { + throw thrownValue; + } +} diff --git a/packages/bvaughn-architecture-demo/src/utils/time.ts b/packages/bvaughn-architecture-demo/src/utils/time.ts new file mode 100644 index 00000000000..e7b77a7d529 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/utils/time.ts @@ -0,0 +1,46 @@ +import { TimeStampedPointRange } from "@replayio/protocol"; +import { padStart } from "lodash"; +import prettyMilliseconds from "pretty-ms"; + +export function formatDuration(ms: number) { + return prettyMilliseconds(ms, { millisecondsDecimalDigits: 1 }); +} + +export function formatTimestamp(ms: number) { + const seconds = Math.round(ms / 1000.0); + return `${Math.floor(seconds / 60)}:${padStart(String(seconds % 60), 2, "0")}`; +} + +export function isRangeEqual( + prevRange: TimeStampedPointRange | null, + nextRange: TimeStampedPointRange | null +): boolean { + if (prevRange === null && nextRange === null) { + return true; + } else if (prevRange !== null && nextRange !== null) { + return ( + nextRange.begin.time === prevRange.begin.time && nextRange.end.time === prevRange.end.time + ); + } else { + return false; + } +} + +export function isRangeSubset( + prevRange: TimeStampedPointRange | null, + nextRange: TimeStampedPointRange | null +): boolean { + if (prevRange === null && nextRange === null) { + return true; + } else if (prevRange === null) { + // There was no previous range constraint. + // No matter what the new range is, it will be a subset. + return true; + } else if (nextRange === null) { + // There is no new range constraint. + // No matter what the previous range was, the new one is not a subset. + return false; + } else { + return nextRange.begin.time >= prevRange.begin.time && nextRange.end.time <= prevRange.end.time; + } +} diff --git a/packages/bvaughn-architecture-demo/tailwind.config.js b/packages/bvaughn-architecture-demo/tailwind.config.js new file mode 100644 index 00000000000..4403510c4a9 --- /dev/null +++ b/packages/bvaughn-architecture-demo/tailwind.config.js @@ -0,0 +1,7 @@ +module.exports = { + content: ["./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/packages/bvaughn-architecture-demo/tsconfig.json b/packages/bvaughn-architecture-demo/tsconfig.json index 7bb1f4ab968..f9afafc60e7 100644 --- a/packages/bvaughn-architecture-demo/tsconfig.json +++ b/packages/bvaughn-architecture-demo/tsconfig.json @@ -15,7 +15,7 @@ "isolatedModules": true, "jsx": "preserve", "paths": { - "ui/*": ["../../src/ui/*"] + "components/*": ["components/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], diff --git a/packages/protocol/socket.ts b/packages/protocol/socket.ts index 9d4cd810905..e891bd56bc7 100644 --- a/packages/protocol/socket.ts +++ b/packages/protocol/socket.ts @@ -180,23 +180,34 @@ export function sendMessage( gPendingMessages.push(msg); } - return new Promise(resolve => gMessageWaiters.set(id, { method, resolve })).then( - response => { - if (response.error) { - gSessionCallbacks?.onResponseError(response); + return new Promise(async (resolve, reject) => { + let response: CommandResponse | null = null; + const resolveWrapper = (value: CommandResponse) => { + response = value; + }; + + gMessageWaiters.set(id, { method, resolve: resolveWrapper }); + + while (response === null) { + await new Promise(r => setTimeout(r, 1)); + } - const { code, data, message } = response.error; - console.warn("Message failed", method, { code, id, message }, data); + const { error, result } = response as CommandResponse; + if (error) { + gSessionCallbacks?.onResponseError(response as CommandResponse); - const err = new Error(message) as any; - err.name = "CommandError"; - err.code = code; - throw err; - } + const { code, data, message } = error; + console.warn("Message failed", method, { code, id, message }, data); - return response.result as any; + const err = new Error(message) as any; + err.name = "CommandError"; + err.code = code; + + reject(err); } - ); + + resolve(result); + }); } const doSend = makeInfallible(message => { diff --git a/src/devtools/client/shared/react.d.ts b/src/devtools/client/shared/react.d.ts index 1bfe808d82e..782bbafae1e 100644 --- a/src/devtools/client/shared/react.d.ts +++ b/src/devtools/client/shared/react.d.ts @@ -6,4 +6,7 @@ declare module "react" { // The following hooks are only available in the experimental react release. export function useDeferredValue(value: T): T; export function useTransition(): [isPending: boolean, startTransition: (Callback) => void]; + + // Unstable Suspense cache API + export function unstable_getCacheForType(resourceType: () => T): T; } diff --git a/src/ui/utils/timeline.ts b/src/ui/utils/timeline.ts index 6ecddafe046..2f68d7bb9bd 100644 --- a/src/ui/utils/timeline.ts +++ b/src/ui/utils/timeline.ts @@ -273,7 +273,9 @@ export function isFocusRegionSubset( prevFocusRegion: FocusRegion | null, nextFocusRegion: FocusRegion | null ): boolean { - if (prevFocusRegion === null) { + if (prevFocusRegion === null && nextFocusRegion === null) { + return true; + } else if (prevFocusRegion === null) { // Previously the entire timeline was selected. // No matter what the new focus region is, it will be a subset. return true; diff --git a/src/ui/utils/tokenManager.tsx b/src/ui/utils/tokenManager.tsx index be4a35078d9..7774aa5ba2f 100644 --- a/src/ui/utils/tokenManager.tsx +++ b/src/ui/utils/tokenManager.tsx @@ -205,4 +205,9 @@ class TokenManager { } } -export default new TokenManager(); +const tokenManager = new TokenManager(); +export default tokenManager; + +if (typeof window !== "undefined") { + (window as any).tokenManager = tokenManager; +}