From 8d81ddbcc90ac0b1928736bc586e513da0b778eb Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 May 2022 10:51:06 -0400 Subject: [PATCH 1/6] Architecture proof of concept --- .../components/ErrorBoundary.module.css | 6 + .../components/ErrorBoundary.tsx | 23 ++ .../components/Initializer.tsx | 77 ++++++ .../components/Loader.module.css | 3 + .../components/Loader.tsx | 5 + .../components/console/Filters.module.css | 23 ++ .../components/console/Filters.tsx | 57 +++++ .../components/console/Focuser.module.css | 66 ++++++ .../components/console/Focuser.tsx | 179 ++++++++++++++ .../console/MessageRenderer.module.css | 26 +++ .../components/console/MessageRenderer.tsx | 63 +++++ .../console/MessagesList.module.css | 24 ++ .../components/console/MessagesList.tsx | 80 +++++++ .../bvaughn-architecture-demo/next.config.js | 8 + .../package-lock.json | 28 +++ .../bvaughn-architecture-demo/package.json | 14 ++ .../bvaughn-architecture-demo/pages/_app.tsx | 56 +++++ .../pages/global.css | 23 ++ .../pages/index.module.css | 108 +++++++++ .../bvaughn-architecture-demo/pages/index.tsx | 129 ++++++++++ .../src/ReplayClient.ts | 117 ++++++++++ .../bvaughn-architecture-demo/src/contexts.ts | 48 ++++ .../src/hooks/useDebouncedCallback.ts | 23 ++ .../src/hooks/useFilteredMessages.ts | 68 ++++++ .../bvaughn-architecture-demo/src/react.d.ts | 12 + .../src/suspense/MessagesCache.ts | 220 ++++++++++++++++++ .../src/suspense/PointsCache.ts | 64 +++++ .../bvaughn-architecture-demo/src/types.ts | 34 +++ .../src/utils/string.ts | 3 + .../src/utils/suspense.ts | 68 ++++++ .../src/utils/time.ts | 46 ++++ .../tailwind.config.js | 7 + .../bvaughn-architecture-demo/tsconfig.json | 23 ++ src/devtools/client/shared/react.d.ts | 3 + 34 files changed, 1734 insertions(+) create mode 100644 packages/bvaughn-architecture-demo/components/ErrorBoundary.module.css create mode 100644 packages/bvaughn-architecture-demo/components/ErrorBoundary.tsx create mode 100644 packages/bvaughn-architecture-demo/components/Initializer.tsx create mode 100644 packages/bvaughn-architecture-demo/components/Loader.module.css create mode 100644 packages/bvaughn-architecture-demo/components/Loader.tsx create mode 100644 packages/bvaughn-architecture-demo/components/console/Filters.module.css create mode 100644 packages/bvaughn-architecture-demo/components/console/Filters.tsx create mode 100644 packages/bvaughn-architecture-demo/components/console/Focuser.module.css create mode 100644 packages/bvaughn-architecture-demo/components/console/Focuser.tsx create mode 100644 packages/bvaughn-architecture-demo/components/console/MessageRenderer.module.css create mode 100644 packages/bvaughn-architecture-demo/components/console/MessageRenderer.tsx create mode 100644 packages/bvaughn-architecture-demo/components/console/MessagesList.module.css create mode 100644 packages/bvaughn-architecture-demo/components/console/MessagesList.tsx create mode 100644 packages/bvaughn-architecture-demo/next.config.js create mode 100644 packages/bvaughn-architecture-demo/package-lock.json create mode 100644 packages/bvaughn-architecture-demo/package.json create mode 100644 packages/bvaughn-architecture-demo/pages/_app.tsx create mode 100644 packages/bvaughn-architecture-demo/pages/global.css create mode 100644 packages/bvaughn-architecture-demo/pages/index.module.css create mode 100644 packages/bvaughn-architecture-demo/pages/index.tsx create mode 100644 packages/bvaughn-architecture-demo/src/ReplayClient.ts create mode 100644 packages/bvaughn-architecture-demo/src/contexts.ts create mode 100644 packages/bvaughn-architecture-demo/src/hooks/useDebouncedCallback.ts create mode 100644 packages/bvaughn-architecture-demo/src/hooks/useFilteredMessages.ts create mode 100644 packages/bvaughn-architecture-demo/src/react.d.ts create mode 100644 packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts create mode 100644 packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts create mode 100644 packages/bvaughn-architecture-demo/src/types.ts create mode 100644 packages/bvaughn-architecture-demo/src/utils/string.ts create mode 100644 packages/bvaughn-architecture-demo/src/utils/suspense.ts create mode 100644 packages/bvaughn-architecture-demo/src/utils/time.ts create mode 100644 packages/bvaughn-architecture-demo/tailwind.config.js create mode 100644 packages/bvaughn-architecture-demo/tsconfig.json 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..5a52f1b728b --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/ErrorBoundary.module.css @@ -0,0 +1,6 @@ +.Error { + background-color: var(--color-red-light); + color: var(--color-red-dark); + padding: 1rem; + margin: 0; +} diff --git a/packages/bvaughn-architecture-demo/components/ErrorBoundary.tsx b/packages/bvaughn-architecture-demo/components/ErrorBoundary.tsx new file mode 100644 index 00000000000..2bd871a705b --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/ErrorBoundary.tsx @@ -0,0 +1,23 @@ +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: Error): ErrorBoundaryState { + return { error }; + } + + render() { + const { error } = this.state; + + if (error !== null) { + return
{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..c0d2cea365b --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/Initializer.tsx @@ -0,0 +1,77 @@ +// This file is not really part of the architectural demo. +// It's just a bootstrap for things like auth that I didn't want to spend time actually implementing. + +import { client, initSocket } from "protocol/socket"; +import { ThreadFront } from "protocol/thread"; +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 = { + prefs: {}, + }; +} + +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"); + const recordingId = url.searchParams.get("recordingId"); + if (!recordingId) { + throw Error(`Must specify "recordingId" parameter.`); + } + + // Authenticate + if (accessToken) { + await client.Authentication.setAccessToken({ accessToken }); + } + + // Create session + const { sessionId } = await client.Recording.createSession({ recordingId }); + const { endpoint } = await client.Session.getEndpoint({}, sessionId); + + // Pre-load sources for ValueFront usage later. + ThreadFront.setSessionId(sessionId); + // @ts-expect-error `sourceMapURL` doesn't exist? + await ThreadFront.findSources(({ sourceId, url, sourceMapURL }) => { + // Ignore + }); + + 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..38e0d2a3808 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/Loader.module.css @@ -0,0 +1,3 @@ +.Loader { + padding: 0.25rem; +} 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/console/Filters.module.css b/packages/bvaughn-architecture-demo/components/console/Filters.module.css new file mode 100644 index 00000000000..594f36b6913 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/Filters.module.css @@ -0,0 +1,23 @@ +.FilterToggles { + padding: 0.25rem 1ch; + flex: 0 0 auto; + display: flex; + gap: 1ch; + border-right: 1px solid var(--color-gray-2); + border-bottom: 1px solid var(--color-gray-2); +} + +.FilterInput { + flex: 1; + height: 100%; + background-color: var(--color-white); + padding: 0 1ch; + border: none; + outline: none; + border-bottom: 1px solid var(--color-gray-2); +} + +.FilterLabel { + display: flex; + align-items: center; +} diff --git a/packages/bvaughn-architecture-demo/components/console/Filters.tsx b/packages/bvaughn-architecture-demo/components/console/Filters.tsx new file mode 100644 index 00000000000..6826b04e4bc --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/Filters.tsx @@ -0,0 +1,57 @@ +import React, { useContext } from "react"; +import { ConsoleFiltersContext } from "../../src/contexts"; + +import styles from "./Filters.module.css"; + +export default function Filters() { + const { filterByDisplayText, levelFlags, update } = useContext(ConsoleFiltersContext); + + return ( + <> +
+ + + +
+ update(event.currentTarget.value, levelFlags)} + placeholder="Filter output" + /> + + ); +} diff --git a/packages/bvaughn-architecture-demo/components/console/Focuser.module.css b/packages/bvaughn-architecture-demo/components/console/Focuser.module.css new file mode 100644 index 00000000000..cd1c3514e6f --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/Focuser.module.css @@ -0,0 +1,66 @@ +.FocusRegionRowOff, +.FocusRegionRowOn { + padding: 1ch; + display: flex; + flex-direction: row; + flex: 1; + gap: 1ch; + align-items: center; +} +.FocusRegionRowOn { + background-color: var(--color-blue); +} + +.FocusToggleButton { + width: 10ch; + 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/components/console/Focuser.tsx b/packages/bvaughn-architecture-demo/components/console/Focuser.tsx new file mode 100644 index 00000000000..517ff100c68 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/Focuser.tsx @@ -0,0 +1,179 @@ +import React, { MutableRefObject, useContext, useEffect, useRef, useState } from "react"; + +import { FocusContext, SessionContext } from "../../src/contexts"; +import { formatTimestamp } from "../../src/utils/time"; + +import styles from "./Focuser.module.css"; + +export default function Focuser() { + const { duration } = useContext(SessionContext); + const { rangeForDisplay: range, update } = useContext(FocusContext); + + const start = range === null ? 0 : range[0] / duration; + const end = range === null ? 1 : range[1] / duration; + + const toggleFocus = () => { + if (range === null) { + update([start * duration, end * duration], false); + } else { + update(null, false); + } + }; + + const onSliderChange = (newStart: number, newEnd: number) => { + update([newStart * duration, newEnd * duration], true); + }; + + return ( +
+ + +
+ ); +} + +function RangeSlider({ + enabled, + end, + onChange, + start, +}: { + enabled: boolean; + end: number; + onChange: (start: number, end: number) => void; + start: number; +}) { + const ref = useRef(null) as MutableRefObject; + + return ( + <> +
+
+
+
+ +
+ + onChange(newStart, end)} + parentRef={ref} + value={start} + /> + onChange(start, newEnd)} + parentRef={ref} + value={end} + /> +
+
+ {formatTimestamp(start)} – {formatTimestamp(end)} +
+ + ); +} + +function SliderThumb({ + className, + enabled, + maximumValue, + minimumValue, + onChange, + parentRef, + value, +}: { + className: string; + enabled: boolean; + maximumValue: number; + minimumValue: number; + onChange: (value: number) => void; + parentRef: MutableRefObject; + value: number; +}) { + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + const parent = parentRef.current; + + if (!enabled || !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); + }; + + 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); + }; + }); + + const onMouseDown = () => { + if (enabled) { + setIsDragging(true); + } + }; + + return ( +
+ ); +} diff --git a/packages/bvaughn-architecture-demo/components/console/MessageRenderer.module.css b/packages/bvaughn-architecture-demo/components/console/MessageRenderer.module.css new file mode 100644 index 00000000000..77c4939e958 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/MessageRenderer.module.css @@ -0,0 +1,26 @@ +.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: 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/console/MessageRenderer.tsx b/packages/bvaughn-architecture-demo/components/console/MessageRenderer.tsx new file mode 100644 index 00000000000..83e4ef70a44 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/MessageRenderer.tsx @@ -0,0 +1,63 @@ +// This component only exists to demo the architectural changes. +// It's not really meant for review. + +import { Message } from "@replayio/protocol"; +import { Pause, ThreadFront, ValueFront } from "protocol/thread"; +import { memo, useMemo } from "react"; + +import { formatTimestamp } from "../../src/utils/time"; + +import styles from "./MessageRenderer.module.css"; + +// This is a crappy approximation of the console; the UI isn't meant to be the focus of this branch. +// It would be nice to re-implement the whole Console UI though and re-write all of the legacy object inspector code. +function MessageRenderer({ message }: { message: Message }) { + const { point, text } = message; + + // TODO This logic should be moved out of the view. + // It should probably be backed by its own Suspense cache which can just-in-time load ValueFronts, e.g. loadIfNecessary() + const valueFronts = useMemo(() => { + if (message.argumentValues == null) { + return []; + } + + const pause = new Pause(ThreadFront); + pause.instantiate( + message.pauseId, + message.point.point, + message.point.time, + !!message.point.frame, + message.data + ); + + return message.argumentValues.map(value => new ValueFront(pause, value)); + }, [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} + {valueFronts.map((argumentValue: any, index: number) => { + if (argumentValue.isPrimitive()) { + return {argumentValue.primitive()}; + } else { + return Unsupported argument type; + } + })} +
+ ); +} + +export default memo(MessageRenderer); diff --git a/packages/bvaughn-architecture-demo/components/console/MessagesList.module.css b/packages/bvaughn-architecture-demo/components/console/MessagesList.module.css new file mode 100644 index 00000000000..c90b9ea2008 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/MessagesList.module.css @@ -0,0 +1,24 @@ +.Container, +.ContainerPending { + position: relative; + overflow: auto; + height: 100%; +} +.ContainerPending { + opacity: 0.5; +} + +.CountRow, +.NoMessagesRow, +.OverflowRow { + padding: 0.25rem; +} + +.OverflowRow { + background-color: var(--color-red-dark); + color: var(--color-white); +} + +.CountRow { + background-color: var(--color-gray-1); +} diff --git a/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx b/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx new file mode 100644 index 00000000000..32558fd9566 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx @@ -0,0 +1,80 @@ +import { Message } from "@replayio/protocol"; +import { useContext } from "react"; + +import { ConsoleFiltersContext, FocusContext, SessionContext } from "../../src/contexts"; +import useFilteredMessages from "../../src/hooks/useFilteredMessages"; +import { getMessages } from "../../src/suspense/MessagesCache"; +import { getClosestPointForTime } from "../../src/suspense/PointsCache"; + +import MessageRenderer from "./MessageRenderer"; +import styles from "./MessagesList.module.css"; + +// This is a crappy approximation of the console; the UI isn't meant to be the focus of this branch. +// The primary purpose of this component is to showcase: +// 1. The getMessages() Suspense cache for just-in-time loading of console data +// 2. The memoized useFilteredMessages() selector for computing derived (merged, sorted, and filtered) data +// +// Note that the props passed from the parent component would more likely be exposed through Context in a real app. +// We're passing them as props in this case since the parent and child are right beside each other in the tree. +export default function MessagesList() { + const { sessionId } = useContext(SessionContext); + + const { filterByText, levelFlags } = useContext(ConsoleFiltersContext); + const { range, isTransitionPending: isFocusTransitionPending } = useContext(FocusContext); + + let focusMode = null; + if (range !== null) { + const [startTime, endTime] = range; + + const startPoint = getClosestPointForTime(startTime, sessionId); + const endPoint = getClosestPointForTime(endTime, sessionId); + + focusMode = { + begin: { + point: startPoint, + time: startTime, + }, + end: { + point: endPoint, + time: endTime, + }, + }; + } + + const { countAfter, countBefore, didOverflow, messages } = getMessages(sessionId, focusMode); + + // Memoized selector that joins log points and messages and filters by criteria (e.g. type) + // Note that we are intentionally not storing derived values like this in state. + const filteredMessages = useFilteredMessages(messages, { + ...levelFlags, + filterByText, + }); + + // This component only needs to render a pending UI when a focus changes, + // because this might require an async backend request. + // Filter text changes are always processed synchronously by useFilteredMessages(), + // so dimming the UI would just cause a short flicker which we can avoid. + const isTransitionPending = isFocusTransitionPending; + + 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
+ )} +
+ ); +} diff --git a/packages/bvaughn-architecture-demo/next.config.js b/packages/bvaughn-architecture-demo/next.config.js new file mode 100644 index 00000000000..e26851c8b16 --- /dev/null +++ b/packages/bvaughn-architecture-demo/next.config.js @@ -0,0 +1,8 @@ +module.exports = { + reactStrictMode: true, + + // This setting allows the Next app to import code from e.g. "packages/protocol" + experimental: { + externalDir: true, + }, +}; diff --git a/packages/bvaughn-architecture-demo/package-lock.json b/packages/bvaughn-architecture-demo/package-lock.json new file mode 100644 index 00000000000..1c2796bbbba --- /dev/null +++ b/packages/bvaughn-architecture-demo/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "bvaughn-architecture-demo", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "bvaughn-architecture-demo", + "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/package.json b/packages/bvaughn-architecture-demo/package.json new file mode 100644 index 00000000000..4e5110a8efc --- /dev/null +++ b/packages/bvaughn-architecture-demo/package.json @@ -0,0 +1,14 @@ +{ + "name": "bvaughn-architecture-demo", + "version": "0.1.0", + "private": true, + "dependencies": { + "protocol": "file:../protocol" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + } +} 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..1d6c9d3e3aa --- /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: border-box; +} + +:root { + --color-blue: #0a84ff; + --color-gray-1: #d0d5dc; + --color-gray-2: #9ca3af; + --color-gray-3: #234; + --color-red-dark: #ff0000; + --color-red-light: #ffd3d3; + --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..11762631e3e --- /dev/null +++ b/packages/bvaughn-architecture-demo/pages/index.module.css @@ -0,0 +1,108 @@ +.Container { + display: flex; + flex-direction: column; + height: 100vh; +} + +.ContentArea { + flex: 1 1 auto; + overflow: auto; +} + +.Row { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--color-gray-1); +} + +.FilterToggles { + padding: 0.25rem 1ch; + flex: 0 0 auto; + display: flex; + gap: 1ch; + border-right: 1px solid var(--color-gray-2); + border-bottom: 1px solid var(--color-gray-2); +} + +.FilterInput { + flex: 1; + height: 100%; + background-color: var(--color-white); + padding: 0 1ch; + border: none; + outline: none; + border-bottom: 1px solid var(--color-gray-2); +} + +.FilterLabel { + display: flex; + align-items: center; +} + +.FocusRegionRowOff, +.FocusRegionRowOn { + padding: 1ch; + display: flex; + flex-direction: row; + flex: 1; + gap: 1ch; + align-items: center; +} +.FocusRegionRowOn { + background-color: var(--color-blue); +} + +.FocusToggleButton { + width: 10ch; + 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 new file mode 100644 index 00000000000..21307ae55b6 --- /dev/null +++ b/packages/bvaughn-architecture-demo/pages/index.tsx @@ -0,0 +1,129 @@ +import React, { Suspense, useCallback, useMemo, useState, useTransition } from "react"; + +import ErrorBoundary from "../components/ErrorBoundary"; +import ConsoleFilters from "../components/console/Filters"; +import Focuser from "../components/console/Focuser"; +import ConsoleMessages from "../components/console/MessagesList"; +import Loader from "../components/Loader"; +import { + ConsoleFiltersContext, + ConsoleFiltersContextType, + ConsoleLevelFlags, + FocusContext, + FocusContextType, +} from "../src/contexts"; +import useDebouncedCallback from "../src/hooks/useDebouncedCallback"; +import { Range } from "../src/types"; + +import styles from "./index.module.css"; + +// TODO There's a potential hot loop in this code 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 + +const FOCUS_DEBOUNCE_DURATION = 250; + +export default function HomePage() { + const [levelFlags, setLevelFlags] = useState({ + showErrors: true, + showLogs: true, + showWarnings: true, + }); + + // Filter input changes quickly while a user types, but re-filtering can be slow. + // We can use the deferred value hook to allow React to update the visible filter text quickly (at a high priority) + // and then re-filter the list after a small delay (at a lower priority). + const [filterByText, setFilterByText] = useState(""); + const [deferredFilterByText, setDeferredFilterByText] = useState(""); + + // 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. + const [range, setRange] = useState(null); + const [deferredRange, setDeferredRange] = useState(null); + + // 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 [isTransitionPending, startTransition] = useTransition(); + + const debouncedSetDeferredRange = useDebouncedCallback((newRange: Range | null) => { + startTransition(() => { + setDeferredRange(newRange); + }); + }, FOCUS_DEBOUNCE_DURATION); + + const updateFocusRange = useCallback( + (newRange: Range | null, debounce: boolean) => { + setRange(newRange); + + // Focus values may change rapidly (e.g. during a click-and-drag) + // In this case, React's default high/low priority split is helpful, but we can do more. + // Debouncing a little before starting the transition helps avoid sending a lot of unused requests to the backend. + if (debounce) { + debouncedSetDeferredRange(newRange); + } else { + startTransition(() => { + setDeferredRange(newRange); + }); + } + }, + [debouncedSetDeferredRange] + ); + + const focusContext = useMemo( + () => ({ + isTransitionPending, + rangeForDisplay: range, + range: deferredRange, + update: updateFocusRange, + }), + [deferredRange, isTransitionPending, range, updateFocusRange] + ); + + const updateFilters = useCallback((newFilterByText: string, newLevelFlags: ConsoleLevelFlags) => { + setLevelFlags(newLevelFlags); + setFilterByText(newFilterByText); + startTransition(() => { + setDeferredFilterByText(newFilterByText); + }); + }, []); + + const consoleFiltersContext = useMemo( + () => ({ + filterByDisplayText: filterByText, + filterByText: deferredFilterByText, + isTransitionPending, + levelFlags, + update: updateFilters, + }), + [deferredFilterByText, filterByText, isTransitionPending, levelFlags, updateFilters] + ); + + // TODO Once we have a client implementation to interface with Replay backend, + // the app can inject a wrapper one that also reports cache hits and misses to this UI in a debug panel. + + return ( + + +
+
+ +
+
+ + }> + + + +
+
+ +
+
+
+
+ ); +} diff --git a/packages/bvaughn-architecture-demo/src/ReplayClient.ts b/packages/bvaughn-architecture-demo/src/ReplayClient.ts new file mode 100644 index 00000000000..25c81c65a4d --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/ReplayClient.ts @@ -0,0 +1,117 @@ +import { ExecutionPoint, Message, SessionId, TimeStampedPointRange } from "@replayio/protocol"; +import { client } from "protocol/socket"; +import type { ThreadFront } from "protocol/thread"; + +import { isRangeSubset } from "./utils/time"; + +// TODO How should the client handle concurrent requests? +// Should we force serialization? +// Should we cancel in-flight requests and start new ones? + +export default class ReplayClient { + private _focusRange: TimeStampedPointRange | null = null; + private _messageClient: MessageClient; + + // TODO Pass config of some sort? + constructor(threadFront: typeof ThreadFront) { + this._messageClient = new MessageClient(threadFront); + } + + get focusRange(): TimeStampedPointRange | null { + return this._focusRange; + } + + get messages(): Message[] { + return this._messageClient.filteredMessages; + } + + // Narrow focus window for all Replay data. + async setFocus(focusRange: TimeStampedPointRange | null): Promise { + this._focusRange = focusRange; + + // Update all data; fetch from the backend or filter in memory, as needed. + await Promise.all([this._messageClient.setFocus(focusRange)]); + } +} + +class MessageClient { + private _filteredMessages: Message[] = []; + private _lastFetchDidOverflow: boolean = false; + private _lastFetchFocusRange: TimeStampedPointRange | null = null; + private _numFilteredAfterFocusRange: number = 0; + private _numFilteredBeforeFocusRange: number = 0; + private _unfilteredMessages: Message[] = []; + private _sessionEndpoint: ExecutionPoint | null = null; + private _threadFront: typeof ThreadFront; + + constructor(threadFront: typeof ThreadFront) { + this._threadFront = threadFront; + } + + get didOverflow(): boolean { + return this._lastFetchDidOverflow; + } + + get filteredMessages(): Message[] { + return this._filteredMessages; + } + + get numFilteredAfterFocusRange(): number { + return this._numFilteredAfterFocusRange; + } + + get numFilteredBeforeFocusRange(): number { + return this._numFilteredBeforeFocusRange; + } + + // Apply the new focus window to console logs. + // + // In many cases, this will be a synchronous in-memory operation. + // In some cases, this will require re-fetching from the backend. + async setFocus(focusRange: TimeStampedPointRange | null): Promise { + if ( + this._unfilteredMessages === null || + this._lastFetchDidOverflow || + !isRangeSubset(this._lastFetchFocusRange, focusRange) + ) { + const sessionId = this._threadFront.sessionId as SessionId; + + if (!this._sessionEndpoint) { + this._sessionEndpoint = (await client.Session.getEndpoint({}, sessionId)).endpoint.point; + } + + const begin = focusRange ? focusRange.begin.point : "0"; + const end = focusRange ? focusRange.end.point : this._sessionEndpoint!; + const { messages, overflow } = await client.Console.findMessagesInRange( + { range: { begin, end } }, + sessionId + ); + + this._lastFetchDidOverflow = overflow === true; + this._unfilteredMessages = messages; + } + + // Filter in-memory values. + if (focusRange === null) { + this._filteredMessages = this._unfilteredMessages; + } else { + const begin = focusRange.begin.time; + const end = focusRange.end.time; + + this._numFilteredAfterFocusRange = 0; + this._numFilteredBeforeFocusRange = 0; + this._filteredMessages = this._unfilteredMessages.filter(message => { + const time = message.point.time; + if (time < begin) { + this._numFilteredBeforeFocusRange++; + return false; + } else if (time > end) { + this._numFilteredAfterFocusRange++; + return false; + } else { + return true; + } + }); + } + } +} diff --git a/packages/bvaughn-architecture-demo/src/contexts.ts b/packages/bvaughn-architecture-demo/src/contexts.ts new file mode 100644 index 00000000000..f2f5d79d9a9 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/contexts.ts @@ -0,0 +1,48 @@ +import { ExecutionPoint } from "@replayio/protocol"; +import { createContext } from "react"; + +import { Range } from "./types"; + +export type ConsoleLevelFlags = { + showErrors: boolean; + showLogs: boolean; + showWarnings: boolean; +}; + +export type ConsoleFiltersContextType = { + // Filter text to display in the UI. + // This value is updated at React's default, higher priority. + filterByDisplayText: string; + + // Text to filter console messages by. + // This value is updated at a lower, transition priority. + filterByText: string; + + // Filter by text is about to be updated as part of a transition; + // UI that consumes the focus for Suspense purposes may wish want reflect the temporary pending state. + isTransitionPending: boolean; + + // Types of console messages to include (or filter out). + levelFlags: ConsoleLevelFlags; + + update: (filterByText: string, levelFlags: ConsoleLevelFlags) => void; +}; +export const ConsoleFiltersContext = createContext(null as any); + +export type FocusContextType = { + // Focus is about to be updated as part of a transition; + // UI that consumes the focus for Suspense purposes may wish want reflect the temporary pending state. + isTransitionPending: boolean; + range: Range | null; + rangeForDisplay: Range | null; + update: (value: Range | null, debounce: boolean) => void; +}; +export const FocusContext = createContext(null as any); + +export type SessionContextType = { + duration: number; + endPoint: ExecutionPoint; + recordingId: string; + sessionId: string; +}; +export const SessionContext = createContext(null as any); diff --git a/packages/bvaughn-architecture-demo/src/hooks/useDebouncedCallback.ts b/packages/bvaughn-architecture-demo/src/hooks/useDebouncedCallback.ts new file mode 100644 index 00000000000..a6b6dfb9012 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/hooks/useDebouncedCallback.ts @@ -0,0 +1,23 @@ +import { useRef } from "react"; + +export default function useDebouncedCallback void>( + callback: T, + duration: number +): T { + const timeoutIdRef = useRef(null); + + // @ts-ignore I don't know how to make TypeScript happy with the inner function signature. + const debouncedCallback = useRef((...args: any[]) => { + if (timeoutIdRef.current !== null) { + clearTimeout(timeoutIdRef.current); + } + + timeoutIdRef.current = setTimeout(() => { + timeoutIdRef.current = null; + + callback(...args); + }, duration); + }); + + return debouncedCallback.current; +} diff --git a/packages/bvaughn-architecture-demo/src/hooks/useFilteredMessages.ts b/packages/bvaughn-architecture-demo/src/hooks/useFilteredMessages.ts new file mode 100644 index 00000000000..45f3a2a2a9f --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/hooks/useFilteredMessages.ts @@ -0,0 +1,68 @@ +import { Message } from "@replayio/protocol"; +import { useMemo } from "react"; + +// TODO Should this method returned WiredMessage[] or Message[] +export default function useFilteredMessages( + messages: Message[], + options: { + filterByText: string; + showErrors: boolean; + showLogs: boolean; + showWarnings: boolean; + } +): Message[] { + const { filterByText, showErrors, showLogs, showWarnings } = options; + + const filteredMessages = useMemo(() => { + if (showErrors && showLogs && showWarnings && filterByText === "") { + return messages; + } else { + const filterByTextLowercase = filterByText.toLowerCase(); + + return messages.filter(message => { + switch (message.level) { + case "warning": { + if (!showWarnings) { + return false; + } + break; + } + case "error": { + if (!showErrors) { + return false; + } + break; + } + default: { + if (!showLogs) { + return false; + } + break; + } + } + + if (filterByTextLowercase !== "") { + // TODO This is a hacky partial implementation of filter by text. + if (message.text && message.text.toLowerCase().includes(filterByTextLowercase)) { + return true; + } else { + if (message.argumentValues) { + return message.argumentValues.find(argumentValue => { + if ( + argumentValue.value && + `${argumentValue.value}`.toLowerCase().includes(filterByTextLowercase) + ) { + return true; + } + }); + } + } + } + + return true; + }); + } + }, [filterByText, messages, showErrors, showLogs, showWarnings]); + + return filteredMessages; +} diff --git a/packages/bvaughn-architecture-demo/src/react.d.ts b/packages/bvaughn-architecture-demo/src/react.d.ts new file mode 100644 index 00000000000..782bbafae1e --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/react.d.ts @@ -0,0 +1,12 @@ +import "react"; + +declare module "react" { + type Callback = () => void; + + // 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/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts new file mode 100644 index 00000000000..1038e718cc4 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts @@ -0,0 +1,220 @@ +import { Message, SessionId, TimeStampedPointRange } from "@replayio/protocol"; +import { client } from "protocol/socket"; + +import { compareNumericStrings } from "../utils/string"; +import { createWakeable } from "../utils/suspense"; +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 = 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 { + 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) { + inFlightFocusRange = focusRange; + + const wakeable = (inFlightWakeable = createWakeable()); + + fetchMessages(sessionId, focusRange, wakeable); + + 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 (by comparing ranges) 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!, + }; +} + +async function fetchMessages( + sessionId: SessionId, + focusRange: TimeStampedPointRange | null, + wakeable: Wakeable +) { + try { + // 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 unknown 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 == true; + } 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 === true; + } + + // 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 === wakeable) { + 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; + } + + wakeable.resolve(); + } catch (error) { + inFlightFocusRange = null; + inFlightWakeable = null; + + console.error("getMessage() Error for range", printFocusRange(focusRange), error); + + wakeable.reject(error); + } +} + +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/suspense/PointsCache.ts b/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts new file mode 100644 index 00000000000..a84a040fa58 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts @@ -0,0 +1,64 @@ +import { ExecutionPoint, SessionId } from "@replayio/protocol"; +import { client } from "protocol/socket"; +import { unstable_getCacheForType as getCacheForType } from "react"; + +import { Record, STATUS_PENDING, STATUS_REJECTED, STATUS_RESOLVED, Wakeable } from "../types"; +import { createWakeable } from "../utils/suspense"; + +// TODO We could add some way for external code (in the client adapter) to pre-populate this cache with known points. +// For example, when we get Paints they have a corresponding point (and time) which could be pre-loaded here. + +type TimeToRecordMap = Map>; + +function createMap(): TimeToRecordMap { + return new Map(); +} + +function getRecordMap(): TimeToRecordMap { + return getCacheForType(createMap); +} + +export function getClosestPointForTime(time: number, sessionId: SessionId): ExecutionPoint { + const map = getRecordMap(); + let record = map.get(time); + if (record == null) { + const wakeable = createWakeable(); + + record = { + status: STATUS_PENDING, + value: wakeable, + }; + + map.set(time, record); + + fetchPoint(time, sessionId, record, wakeable); + } + + if (record.status === STATUS_RESOLVED) { + return record.value; + } else { + throw record.value; + } +} + +async function fetchPoint( + time: number, + sessionId: SessionId, + record: Record, + wakeable: Wakeable +) { + try { + // TODO Replace these client references with ReplayClient instance. + const timeStampedPoint = await client.Session.getPointNearTime({ time: time }, sessionId); + + record.status = STATUS_RESOLVED; + record.value = timeStampedPoint.point.point; + + wakeable.resolve(); + } catch (error) { + record.status = STATUS_REJECTED; + record.value = error; + + wakeable.reject(error); + } +} diff --git a/packages/bvaughn-architecture-demo/src/types.ts b/packages/bvaughn-architecture-demo/src/types.ts new file mode 100644 index 00000000000..51de180624f --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/types.ts @@ -0,0 +1,34 @@ +export type Range = [number, number]; + +export const STATUS_PENDING = 0; +export const STATUS_RESOLVED = 1; +export const STATUS_REJECTED = 2; + +export type PendingRecord = { + status: 0; + value: Wakeable; +}; + +export type ResolvedRecord = { + status: 1; + value: T; +}; + +export type RejectedRecord = { + status: 2; + value: any; +}; + +export type Record = PendingRecord | ResolvedRecord | RejectedRecord; + +// This type defines the subset of the Promise API that React uses (the .then method to add success/error callbacks). +// You can use a Promise for this, but Promises have a downside (the microtask queue). +// You can also create your own "thennable" if you want to support synchronous resolution/rejection. +export interface Thennable { + then(onFulfill: () => any, onReject: () => any): void | Thennable; +} + +export interface Wakeable extends Thennable { + reject(error: any): void; + resolve(): void; +} 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..0ad1ae5f453 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/utils/suspense.ts @@ -0,0 +1,68 @@ +import { Wakeable } from "../types"; + +// A "thennable" is a subset of the Promise API. +// We could use a Promise as thennable, but Promises have a downside: they use the microtask queue. +// An advantage to creating a custom thennable is synchronous resolution (or rejection). +// +// A "wakeable" is a "thennable" that has convenience resolve/reject methods. +export function createWakeable(): Wakeable { + const resolveCallbacks: Set<() => void> = new Set(); + const rejectCallbacks: Set<(error: Error) => void> = new Set(); + + const wakeable: Wakeable = { + then(resolveCallback: () => void, rejectCallback: (error: Error) => void) { + resolveCallbacks.add(resolveCallback); + rejectCallbacks.add(rejectCallback); + }, + reject(error: Error) { + rejectCallbacks.forEach(rejectCallback => { + let thrownValue = null; + + try { + rejectCallback(error); + } catch (error) { + thrownValue = error; + } + + if (thrownValue !== null) { + throw thrownValue; + } + }); + }, + resolve() { + resolveCallbacks.forEach(resolveCallback => { + let thrownValue = null; + + try { + resolveCallback(); + } catch (error) { + thrownValue = error; + } + + if (thrownValue !== null) { + throw thrownValue; + } + }); + }, + }; + + return wakeable; +} + +// Helper function to read from multiple Suspense caches in parallel. +// This method will re-throw any thrown value, but only after also calling subsequent caches. +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 new file mode 100644 index 00000000000..f9afafc60e7 --- /dev/null +++ b/packages/bvaughn-architecture-demo/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "paths": { + "components/*": ["components/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} 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; } From ad91149a66d00f2b2832d7c2b9829a90946de282 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 May 2022 12:45:48 -0400 Subject: [PATCH 2/6] Inject Replay client instance (via context) so no React components import the protocol directly --- .../components/Initializer.tsx | 31 +--- .../components/console/MessageRenderer.tsx | 1 + .../components/console/MessagesList.tsx | 10 +- .../src/ReplayClient.ts | 162 ++++++++---------- .../bvaughn-architecture-demo/src/contexts.ts | 11 ++ .../src/suspense/MessagesCache.ts | 46 +---- .../src/suspense/PointsCache.ts | 13 +- 7 files changed, 113 insertions(+), 161 deletions(-) diff --git a/packages/bvaughn-architecture-demo/components/Initializer.tsx b/packages/bvaughn-architecture-demo/components/Initializer.tsx index c0d2cea365b..3b43d9841eb 100644 --- a/packages/bvaughn-architecture-demo/components/Initializer.tsx +++ b/packages/bvaughn-architecture-demo/components/Initializer.tsx @@ -1,13 +1,9 @@ // This file is not really part of the architectural demo. // It's just a bootstrap for things like auth that I didn't want to spend time actually implementing. -import { client, initSocket } from "protocol/socket"; -import { ThreadFront } from "protocol/thread"; -import { ReactNode, useEffect, useRef, useState } from "react"; +import { ReactNode, useContext, useEffect, useRef, useState } from "react"; -import { SessionContext } from "../src/contexts"; - -const DISPATCH_URL = "wss://dispatch.replay.io"; +import { ReplayClientContext, SessionContext } from "../src/contexts"; // HACK Hack around the fact that the initSocket() function is side effectful // and writes to an "app" global on the window object. @@ -20,6 +16,7 @@ if (typeof window !== "undefined") { type ContextType = { duration: number; endPoint: string; recordingId: string; sessionId: string }; export default function Initializer({ children }: { children: ReactNode }) { + const client = useContext(ReplayClientContext); const [context, setContext] = useState(null); const didInitializeRef = useRef(false); @@ -28,8 +25,6 @@ export default function Initializer({ children }: { children: ReactNode }) { // 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); @@ -39,21 +34,11 @@ export default function Initializer({ children }: { children: ReactNode }) { throw Error(`Must specify "recordingId" parameter.`); } - // Authenticate - if (accessToken) { - await client.Authentication.setAccessToken({ accessToken }); - } + const sessionId = await client.initialize(recordingId, accessToken); + const endpoint = await client.getSessionEndpoint(sessionId); - // Create session - const { sessionId } = await client.Recording.createSession({ recordingId }); - const { endpoint } = await client.Session.getEndpoint({}, sessionId); - - // Pre-load sources for ValueFront usage later. - ThreadFront.setSessionId(sessionId); - // @ts-expect-error `sourceMapURL` doesn't exist? - await ThreadFront.findSources(({ sourceId, url, sourceMapURL }) => { - // Ignore - }); + // The demo doesn't use these directly, but the client throws if they aren't loaded. + await client.findSources(); setContext({ duration: endpoint.time, @@ -67,7 +52,7 @@ export default function Initializer({ children }: { children: ReactNode }) { } didInitializeRef.current = true; - }, []); + }, [client]); if (context === null) { return null; diff --git a/packages/bvaughn-architecture-demo/components/console/MessageRenderer.tsx b/packages/bvaughn-architecture-demo/components/console/MessageRenderer.tsx index 83e4ef70a44..7bdd2eae0ae 100644 --- a/packages/bvaughn-architecture-demo/components/console/MessageRenderer.tsx +++ b/packages/bvaughn-architecture-demo/components/console/MessageRenderer.tsx @@ -16,6 +16,7 @@ function MessageRenderer({ message }: { message: Message }) { // TODO This logic should be moved out of the view. // It should probably be backed by its own Suspense cache which can just-in-time load ValueFronts, e.g. loadIfNecessary() + // Do messages have some sort of stable ID that we could use for a Suspense cache key? Maybe pauseId? const valueFronts = useMemo(() => { if (message.argumentValues == null) { return []; diff --git a/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx b/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx index 32558fd9566..d3c0ffbb816 100644 --- a/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx +++ b/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx @@ -1,7 +1,7 @@ import { Message } from "@replayio/protocol"; import { useContext } from "react"; -import { ConsoleFiltersContext, FocusContext, SessionContext } from "../../src/contexts"; +import { ConsoleFiltersContext, FocusContext, ReplayClientContext } from "../../src/contexts"; import useFilteredMessages from "../../src/hooks/useFilteredMessages"; import { getMessages } from "../../src/suspense/MessagesCache"; import { getClosestPointForTime } from "../../src/suspense/PointsCache"; @@ -17,7 +17,7 @@ import styles from "./MessagesList.module.css"; // Note that the props passed from the parent component would more likely be exposed through Context in a real app. // We're passing them as props in this case since the parent and child are right beside each other in the tree. export default function MessagesList() { - const { sessionId } = useContext(SessionContext); + const replayClient = useContext(ReplayClientContext); const { filterByText, levelFlags } = useContext(ConsoleFiltersContext); const { range, isTransitionPending: isFocusTransitionPending } = useContext(FocusContext); @@ -26,8 +26,8 @@ export default function MessagesList() { if (range !== null) { const [startTime, endTime] = range; - const startPoint = getClosestPointForTime(startTime, sessionId); - const endPoint = getClosestPointForTime(endTime, sessionId); + const startPoint = getClosestPointForTime(replayClient, startTime); + const endPoint = getClosestPointForTime(replayClient, endTime); focusMode = { begin: { @@ -41,7 +41,7 @@ export default function MessagesList() { }; } - const { countAfter, countBefore, didOverflow, messages } = getMessages(sessionId, focusMode); + const { countAfter, countBefore, didOverflow, messages } = getMessages(replayClient, focusMode); // Memoized selector that joins log points and messages and filters by criteria (e.g. type) // Note that we are intentionally not storing derived values like this in state. diff --git a/packages/bvaughn-architecture-demo/src/ReplayClient.ts b/packages/bvaughn-architecture-demo/src/ReplayClient.ts index 25c81c65a4d..d17bf407d32 100644 --- a/packages/bvaughn-architecture-demo/src/ReplayClient.ts +++ b/packages/bvaughn-architecture-demo/src/ReplayClient.ts @@ -1,117 +1,101 @@ -import { ExecutionPoint, Message, SessionId, TimeStampedPointRange } from "@replayio/protocol"; -import { client } from "protocol/socket"; +import { + ExecutionPoint, + Message, + SessionId, + TimeStampedPoint, + TimeStampedPointRange, +} from "@replayio/protocol"; +import { client, initSocket } from "protocol/socket"; import type { ThreadFront } from "protocol/thread"; -import { isRangeSubset } from "./utils/time"; - // TODO How should the client handle concurrent requests? // Should we force serialization? // Should we cancel in-flight requests and start new ones? export default class ReplayClient { - private _focusRange: TimeStampedPointRange | null = null; - private _messageClient: MessageClient; + private _sessionId: SessionId | null = null; + private _threadFront: typeof ThreadFront; - // TODO Pass config of some sort? - constructor(threadFront: typeof ThreadFront) { - this._messageClient = new MessageClient(threadFront); - } + constructor(dispatchURL: string, threadFront: typeof ThreadFront) { + this._threadFront = threadFront; - get focusRange(): TimeStampedPointRange | null { - return this._focusRange; + if (typeof window !== "undefined") { + initSocket(dispatchURL); + } } - get messages(): Message[] { - return this._messageClient.filteredMessages; + getSessionIdThrows(): SessionId { + const sessionId = this._sessionId; + if (sessionId === null) { + throw Error("Invalid session"); + } + return sessionId; } - // Narrow focus window for all Replay data. - async setFocus(focusRange: TimeStampedPointRange | null): Promise { - this._focusRange = focusRange; + async initialize(recordingId: string, accessToken: string | null): Promise { + if (accessToken != null) { + await client.Authentication.setAccessToken({ accessToken }); + } - // Update all data; fetch from the backend or filter in memory, as needed. - await Promise.all([this._messageClient.setFocus(focusRange)]); - } -} + const { sessionId } = await client.Recording.createSession({ recordingId }); -class MessageClient { - private _filteredMessages: Message[] = []; - private _lastFetchDidOverflow: boolean = false; - private _lastFetchFocusRange: TimeStampedPointRange | null = null; - private _numFilteredAfterFocusRange: number = 0; - private _numFilteredBeforeFocusRange: number = 0; - private _unfilteredMessages: Message[] = []; - private _sessionEndpoint: ExecutionPoint | null = null; - private _threadFront: typeof ThreadFront; + this._sessionId = sessionId; + this._threadFront.setSessionId(sessionId); - constructor(threadFront: typeof ThreadFront) { - this._threadFront = threadFront; + return sessionId; } - get didOverflow(): boolean { - return this._lastFetchDidOverflow; - } + async findMessages(focusRange: TimeStampedPointRange | null): Promise<{ + messages: Message[]; + overflow: boolean; + }> { + const sessionId = this.getSessionIdThrows(); - get filteredMessages(): Message[] { - return this._filteredMessages; - } + if (focusRange !== null) { + const response = await client.Console.findMessagesInRange( + { range: { begin: focusRange.begin.point, end: focusRange.end.point } }, + sessionId + ); - get numFilteredAfterFocusRange(): number { - return this._numFilteredAfterFocusRange; + return { + messages: response.messages, + overflow: response.overflow == true, + }; + } else { + const messages: Message[] = []; + + // 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(); + + return { + messages, + overflow: response.overflow == true, + }; + } } - get numFilteredBeforeFocusRange(): number { - return this._numFilteredBeforeFocusRange; + async findSources() { + await this._threadFront.findSources(() => { + // The demo doesn't use these directly, but the client throws if they aren't loaded. + }); } - // Apply the new focus window to console logs. - // - // In many cases, this will be a synchronous in-memory operation. - // In some cases, this will require re-fetching from the backend. - async setFocus(focusRange: TimeStampedPointRange | null): Promise { - if ( - this._unfilteredMessages === null || - this._lastFetchDidOverflow || - !isRangeSubset(this._lastFetchFocusRange, focusRange) - ) { - const sessionId = this._threadFront.sessionId as SessionId; - - if (!this._sessionEndpoint) { - this._sessionEndpoint = (await client.Session.getEndpoint({}, sessionId)).endpoint.point; - } - - const begin = focusRange ? focusRange.begin.point : "0"; - const end = focusRange ? focusRange.end.point : this._sessionEndpoint!; - const { messages, overflow } = await client.Console.findMessagesInRange( - { range: { begin, end } }, - sessionId - ); + async getPointNearTime(time: number): Promise { + const sessionId = this.getSessionIdThrows(); + const { point } = await client.Session.getPointNearTime({ time: time }, sessionId); - this._lastFetchDidOverflow = overflow === true; - this._unfilteredMessages = messages; - } + return point; + } - // Filter in-memory values. - if (focusRange === null) { - this._filteredMessages = this._unfilteredMessages; - } else { - const begin = focusRange.begin.time; - const end = focusRange.end.time; - - this._numFilteredAfterFocusRange = 0; - this._numFilteredBeforeFocusRange = 0; - this._filteredMessages = this._unfilteredMessages.filter(message => { - const time = message.point.time; - if (time < begin) { - this._numFilteredBeforeFocusRange++; - return false; - } else if (time > end) { - this._numFilteredAfterFocusRange++; - return false; - } else { - return true; - } - }); - } + async getSessionEndpoint(sessionId: SessionId): Promise { + const { endpoint } = await client.Session.getEndpoint({}, sessionId); + + return endpoint; } } diff --git a/packages/bvaughn-architecture-demo/src/contexts.ts b/packages/bvaughn-architecture-demo/src/contexts.ts index f2f5d79d9a9..d151ccbc676 100644 --- a/packages/bvaughn-architecture-demo/src/contexts.ts +++ b/packages/bvaughn-architecture-demo/src/contexts.ts @@ -1,5 +1,7 @@ import { ExecutionPoint } from "@replayio/protocol"; +import { ThreadFront } from "protocol/thread"; import { createContext } from "react"; +import ReplayClient from "./ReplayClient"; import { Range } from "./types"; @@ -46,3 +48,12 @@ export type SessionContextType = { sessionId: string; }; export const SessionContext = createContext(null as any); + +export type ReplayClientContextType = ReplayClient; + +// By default, this context wires the app up to use real Replay backend APIs. +// We can leverage this when writing tests (or UI demos) by injecting a stub client. +const DISPATCH_URL = "wss://dispatch.replay.io"; +export const ReplayClientContext = createContext( + new ReplayClient(DISPATCH_URL, ThreadFront) +); diff --git a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts index 1038e718cc4..b2003aeb9f3 100644 --- a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts +++ b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts @@ -1,10 +1,10 @@ -import { Message, SessionId, TimeStampedPointRange } from "@replayio/protocol"; -import { client } from "protocol/socket"; +import { Message, TimeStampedPointRange } from "@replayio/protocol"; +import ReplayClient from "../ReplayClient"; +import { Wakeable } from "../types"; import { compareNumericStrings } from "../utils/string"; import { createWakeable } from "../utils/suspense"; 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, @@ -39,7 +39,7 @@ type getMessagesResponse = { // // This method is Suspense friend; it is meant to be called from a React component during render. export function getMessages( - sessionId: SessionId, + client: ReplayClient, focusRange: TimeStampedPointRange | null ): getMessagesResponse { if (focusRange !== null && focusRange.begin.point === focusRange.end.point) { @@ -89,7 +89,7 @@ export function getMessages( const wakeable = (inFlightWakeable = createWakeable()); - fetchMessages(sessionId, focusRange, wakeable); + fetchMessages(client, focusRange, wakeable); throw inFlightWakeable; } @@ -137,40 +137,12 @@ export function getMessages( } async function fetchMessages( - sessionId: SessionId, + client: ReplayClient, focusRange: TimeStampedPointRange | null, wakeable: Wakeable ) { try { - // 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 unknown 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 == true; - } 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 === true; - } + const { messages, overflow } = await client.findMessages(focusRange); // Only update cached values if this request hasn't been superceded by a newer one. // @@ -187,7 +159,7 @@ async function fetchMessages( // // TODO Is this only required for Console.findMessages()? // Can we skip it for Console.findMessagesInRange()? - messages = messages.sort((messageA: Message, messageB: Message) => { + const sortedMessages = messages.sort((messageA: Message, messageB: Message) => { const pointA = messageA.point.point; const pointB = messageB.point.point; return compareNumericStrings(pointA, pointB); @@ -195,7 +167,7 @@ async function fetchMessages( lastFetchDidOverflow = overflow; lastFetchedFocusRange = focusRange; - lastFetchedMessages = messages; + lastFetchedMessages = sortedMessages; } wakeable.resolve(); diff --git a/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts b/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts index a84a040fa58..56c26a86b95 100644 --- a/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts +++ b/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts @@ -1,6 +1,6 @@ import { ExecutionPoint, SessionId } from "@replayio/protocol"; -import { client } from "protocol/socket"; import { unstable_getCacheForType as getCacheForType } from "react"; +import ReplayClient from "../ReplayClient"; import { Record, STATUS_PENDING, STATUS_REJECTED, STATUS_RESOLVED, Wakeable } from "../types"; import { createWakeable } from "../utils/suspense"; @@ -18,7 +18,7 @@ function getRecordMap(): TimeToRecordMap { return getCacheForType(createMap); } -export function getClosestPointForTime(time: number, sessionId: SessionId): ExecutionPoint { +export function getClosestPointForTime(client: ReplayClient, time: number): ExecutionPoint { const map = getRecordMap(); let record = map.get(time); if (record == null) { @@ -31,7 +31,7 @@ export function getClosestPointForTime(time: number, sessionId: SessionId): Exec map.set(time, record); - fetchPoint(time, sessionId, record, wakeable); + fetchPoint(client, time, record, wakeable); } if (record.status === STATUS_RESOLVED) { @@ -42,17 +42,16 @@ export function getClosestPointForTime(time: number, sessionId: SessionId): Exec } async function fetchPoint( + client: ReplayClient, time: number, - sessionId: SessionId, record: Record, wakeable: Wakeable ) { try { - // TODO Replace these client references with ReplayClient instance. - const timeStampedPoint = await client.Session.getPointNearTime({ time: time }, sessionId); + const point = await client.getPointNearTime(time); record.status = STATUS_RESOLVED; - record.value = timeStampedPoint.point.point; + record.value = point.point; wakeable.resolve(); } catch (error) { From 8d9e7a7360b498e4cf114d1a16fc66e91318803b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 May 2022 13:36:32 -0400 Subject: [PATCH 3/6] Upgrade RTL to fix createRoot warning --- package-lock.json | 341 +++++++++++++++++++++------------------------- package.json | 2 +- 2 files changed, 154 insertions(+), 189 deletions(-) diff --git a/package-lock.json b/package-lock.json index c162e3efe67..aadba96f00d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,7 +104,7 @@ "@storybook/react": "^6.4.21", "@tailwindcss/forms": "^0.5.0", "@testing-library/jest-dom": "^5.16.3", - "@testing-library/react": "^12.1.4", + "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^14.1.0", "@trunkio/launcher": "^1.0.5", "@types/classnames": "^2.2.11", @@ -21743,21 +21743,30 @@ } }, "node_modules/@testing-library/react": { - "version": "12.1.4", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz", - "integrity": "sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA==", + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.3.0.tgz", + "integrity": "sha512-DB79aA426+deFgGSjnf5grczDPiL4taK3hFaa+M5q7q20Kcve9eQottOG5kZ74KEr55v0tU2CQormSSDK87zYQ==", "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.0.0", - "@types/react-dom": "*" + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" }, "engines": { "node": ">=12" }, "peerDependencies": { - "react": "*", - "react-dom": "*" + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/@types/react-dom": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.5.tgz", + "integrity": "sha512-OWPWTUrY/NIrjsAPkAk1wW9LZeIjSvkXRhclsFO8CZcZGCOg2G0YZy4ft+rOyYxy8B7ui5iZzi9OkDebZ7/QSA==", + "dev": true, + "dependencies": { + "@types/react": "*" } }, "node_modules/@testing-library/user-event": { @@ -22762,6 +22771,7 @@ "version": "5.1.25", "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.25.tgz", "integrity": "sha512-fgwl+0Pa8pdkwXRoVPP9JbqF0Ivo9llnmsm+7TCI330kbPIFd9qv1Lrhr37shf4tnxCOSu+/IgqM7uJXLWZZNQ==", + "dev": true, "dependencies": { "@types/hoist-non-react-statics": "*", "@types/react": "*", @@ -24822,7 +24832,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "devOptional": true + "dev": true }, "node_modules/asynckit": { "version": "0.4.0", @@ -25283,7 +25293,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=", - "devOptional": true + "dev": true }, "node_modules/bail": { "version": "1.0.5", @@ -25866,7 +25876,7 @@ }, "node_modules/bufferutil": { "version": "4.0.6", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -25881,6 +25891,10 @@ "dev": true, "license": "MIT" }, + "node_modules/bvaughn-architecture-demo": { + "resolved": "packages/bvaughn-architecture-demo", + "link": true + }, "node_modules/byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -29751,7 +29765,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", - "devOptional": true + "dev": true }, "node_modules/events": { "version": "3.3.0", @@ -31619,6 +31633,7 @@ "version": "15.8.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", + "dev": true, "engines": { "node": ">= 10.x" } @@ -31835,7 +31850,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.6.3.tgz", "integrity": "sha512-ZolWOi6bzI35ovGROCZROB9nDbwZiJdIsaPdzW/jkICCGNb3qL/33IONY/yQiBa+Je2uA11HfY4Uxse4+/ePYA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=10" }, @@ -34223,7 +34238,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", - "devOptional": true + "dev": true }, "node_modules/iterate-iterator": { "version": "1.0.2", @@ -39499,7 +39514,7 @@ }, "node_modules/node-gyp-build": { "version": "4.3.0", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -44765,7 +44780,7 @@ "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.18.tgz", "integrity": "sha512-tztzcBTNoEbuErsVQpTN2xUNN/efAZXyCyL5m3x4t6SKrEiTL2N8SaKWBFWM4u56pL79ULif3zjyeq+oV+nOaA==", "deprecated": "The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md", - "devOptional": true, + "dev": true, "dependencies": { "backo2": "^1.0.2", "eventemitter3": "^3.1.0", @@ -44781,7 +44796,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -44790,7 +44805,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.3.tgz", "integrity": "sha512-jZArVERrMsKUatIdnLzqvcfydI85dvd/Fp1u/VOpfdDWQ4c9qWXe+VIeAbQ5FrDwciAkr+lzofXLz3Kuf26AOA==", - "devOptional": true, + "dev": true, "dependencies": { "async-limiter": "~1.0.0" } @@ -46442,7 +46457,7 @@ }, "node_modules/utf-8-validate": { "version": "5.0.8", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -47743,6 +47758,12 @@ }, "devDependencies": {} }, + "packages/bvaughn-architecture-demo": { + "version": "0.1.0", + "dependencies": { + "protocol": "file:../protocol" + } + }, "packages/docs": { "version": "0.0.0" }, @@ -47807,8 +47828,7 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/@apollographql/apollo-tools/-/apollo-tools-0.5.3.tgz", "integrity": "sha512-VcsXHfTFoCodDAgJZxN04GdFK1kqOhZQnQY/9Fa147P+I8xfvOSz5d+lKAPB+hwSgBNyd7ncAKGIs4+utbL+yA==", - "dev": true, - "requires": {} + "dev": true }, "@apollographql/graphql-language-service-interface": { "version": "2.0.2", @@ -47834,8 +47854,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@apollographql/graphql-language-service-types/-/graphql-language-service-types-2.0.2.tgz", "integrity": "sha512-vE+Dz8pG+Xa1Z2nMl82LoO66lQ6JqBUjaXqLDvS3eMjvA3N4hf+YUDOWfPdNZ0zjhHhHXzUIIZCkax6bXfFbzQ==", - "dev": true, - "requires": {} + "dev": true }, "@apollographql/graphql-language-service-utils": { "version": "2.0.2", @@ -49341,8 +49360,7 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", - "dev": true, - "requires": {} + "dev": true } } }, @@ -49362,8 +49380,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "dev": true, - "requires": {} + "dev": true } } }, @@ -49406,18 +49423,15 @@ } }, "@graphql-typed-document-node/core": { - "version": "3.1.0", - "requires": {} + "version": "3.1.0" }, "@headlessui/react": { - "version": "1.5.0", - "requires": {} + "version": "1.5.0" }, "@heroicons/react": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-1.0.6.tgz", - "integrity": "sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==", - "requires": {} + "integrity": "sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==" }, "@humanwhocodes/config-array": { "version": "0.9.2", @@ -50567,8 +50581,7 @@ "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz", "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==", - "dev": true, - "requires": {} + "dev": true }, "loader-utils": { "version": "2.0.0", @@ -50703,8 +50716,7 @@ "version": "0.9.0", "resolved": "https://registry.npmjs.org/@n1ru4l/graphql-live-query/-/graphql-live-query-0.9.0.tgz", "integrity": "sha512-BTpWy1e+FxN82RnLz4x1+JcEewVdfmUhV1C6/XYD5AjS7PQp9QFF7K8bCD6gzPTr2l+prvqOyVueQhFJxB1vfg==", - "dev": true, - "requires": {} + "dev": true }, "@next/env": { "version": "12.1.6", @@ -53425,8 +53437,7 @@ "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz", "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==", - "dev": true, - "requires": {} + "dev": true }, "@storybook/builder-webpack4": { "version": "6.4.20", @@ -56429,8 +56440,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "dev": true, - "requires": {} + "dev": true }, "yallist": { "version": "3.1.1", @@ -58658,8 +58668,7 @@ }, "postcss-modules-extract-imports": { "version": "3.0.0", - "dev": true, - "requires": {} + "dev": true }, "postcss-modules-local-by-default": { "version": "4.0.0", @@ -60484,8 +60493,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.6.0.tgz", "integrity": "sha512-AzmM3aH3gk0aX7/rZLYvjdvZooofDu3fFOzGqcSnQ1tOcTWwhM/o+q++E8mAyVVIyUdajrkzWUGftaVSDLn1bw==", - "dev": true, - "requires": {} + "dev": true } } }, @@ -62502,8 +62510,7 @@ }, "postcss-modules-extract-imports": { "version": "3.0.0", - "dev": true, - "requires": {} + "dev": true }, "postcss-modules-local-by-default": { "version": "4.0.0", @@ -63358,36 +63365,28 @@ "integrity": "sha512-4R1vC75yKaCVFARW3bhelf9+dKt4NP4iZY/sIjGK7AAMBVvZ47eG74NvsAIUdUnhOXSWFMjdFWqv+etk5BDW4g==" }, "@svgr/babel-plugin-add-jsx-attribute": { - "version": "6.0.0", - "requires": {} + "version": "6.0.0" }, "@svgr/babel-plugin-remove-jsx-attribute": { - "version": "6.0.0", - "requires": {} + "version": "6.0.0" }, "@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "6.0.0", - "requires": {} + "version": "6.0.0" }, "@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "6.0.0", - "requires": {} + "version": "6.0.0" }, "@svgr/babel-plugin-svg-dynamic-title": { - "version": "6.0.0", - "requires": {} + "version": "6.0.0" }, "@svgr/babel-plugin-svg-em-dimensions": { - "version": "6.0.0", - "requires": {} + "version": "6.0.0" }, "@svgr/babel-plugin-transform-react-native-svg": { - "version": "6.0.0", - "requires": {} + "version": "6.0.0" }, "@svgr/babel-plugin-transform-svg-component": { - "version": "6.2.0", - "requires": {} + "version": "6.2.0" }, "@svgr/babel-preset": { "version": "6.2.0", @@ -63614,22 +63613,32 @@ } }, "@testing-library/react": { - "version": "12.1.4", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz", - "integrity": "sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA==", + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.3.0.tgz", + "integrity": "sha512-DB79aA426+deFgGSjnf5grczDPiL4taK3hFaa+M5q7q20Kcve9eQottOG5kZ74KEr55v0tU2CQormSSDK87zYQ==", "dev": true, "requires": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.0.0", - "@types/react-dom": "*" + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" + }, + "dependencies": { + "@types/react-dom": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.5.tgz", + "integrity": "sha512-OWPWTUrY/NIrjsAPkAk1wW9LZeIjSvkXRhclsFO8CZcZGCOg2G0YZy4ft+rOyYxy8B7ui5iZzi9OkDebZ7/QSA==", + "dev": true, + "requires": { + "@types/react": "*" + } + } } }, "@testing-library/user-event": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.1.0.tgz", "integrity": "sha512-+CGfMXlVM+OwREHDEsfTGsXIMI+rjr3a7YBUSutq7soELht+8kQrM5k46xa/WLfHdtX/wqsDIleL6bi4i+xz0w==", - "dev": true, - "requires": {} + "dev": true }, "@tiptap/core": { "version": "2.0.0-beta.174", @@ -63651,12 +63660,10 @@ } }, "@tiptap/extension-blockquote": { - "version": "2.0.0-beta.26", - "requires": {} + "version": "2.0.0-beta.26" }, "@tiptap/extension-bold": { - "version": "2.0.0-beta.26", - "requires": {} + "version": "2.0.0-beta.26" }, "@tiptap/extension-bubble-menu": { "version": "2.0.0-beta.55", @@ -63667,12 +63674,10 @@ } }, "@tiptap/extension-bullet-list": { - "version": "2.0.0-beta.26", - "requires": {} + "version": "2.0.0-beta.26" }, "@tiptap/extension-code": { - "version": "2.0.0-beta.26", - "requires": {} + "version": "2.0.0-beta.26" }, "@tiptap/extension-code-block": { "version": "2.0.0-beta.37", @@ -63681,8 +63686,7 @@ } }, "@tiptap/extension-document": { - "version": "2.0.0-beta.15", - "requires": {} + "version": "2.0.0-beta.15" }, "@tiptap/extension-dropcursor": { "version": "2.0.0-beta.25", @@ -63707,12 +63711,10 @@ } }, "@tiptap/extension-hard-break": { - "version": "2.0.0-beta.30", - "requires": {} + "version": "2.0.0-beta.30" }, "@tiptap/extension-heading": { - "version": "2.0.0-beta.26", - "requires": {} + "version": "2.0.0-beta.26" }, "@tiptap/extension-history": { "version": "2.0.0-beta.21", @@ -63728,12 +63730,10 @@ } }, "@tiptap/extension-italic": { - "version": "2.0.0-beta.26", - "requires": {} + "version": "2.0.0-beta.26" }, "@tiptap/extension-list-item": { - "version": "2.0.0-beta.20", - "requires": {} + "version": "2.0.0-beta.20" }, "@tiptap/extension-mention": { "version": "2.0.0-beta.95", @@ -63744,12 +63744,10 @@ } }, "@tiptap/extension-ordered-list": { - "version": "2.0.0-beta.27", - "requires": {} + "version": "2.0.0-beta.27" }, "@tiptap/extension-paragraph": { - "version": "2.0.0-beta.23", - "requires": {} + "version": "2.0.0-beta.23" }, "@tiptap/extension-placeholder": { "version": "2.0.0-beta.48", @@ -63760,12 +63758,10 @@ } }, "@tiptap/extension-strike": { - "version": "2.0.0-beta.27", - "requires": {} + "version": "2.0.0-beta.27" }, "@tiptap/extension-text": { - "version": "2.0.0-beta.15", - "requires": {} + "version": "2.0.0-beta.15" }, "@tiptap/react": { "version": "2.0.0-beta.108", @@ -64364,6 +64360,7 @@ "version": "5.1.25", "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.25.tgz", "integrity": "sha512-fgwl+0Pa8pdkwXRoVPP9JbqF0Ivo9llnmsm+7TCI330kbPIFd9qv1Lrhr37shf4tnxCOSu+/IgqM7uJXLWZZNQ==", + "dev": true, "requires": { "@types/hoist-non-react-statics": "*", "@types/react": "*", @@ -64876,8 +64873,7 @@ }, "@webpack-cli/configtest": { "version": "1.1.1", - "dev": true, - "requires": {} + "dev": true }, "@webpack-cli/info": { "version": "1.4.1", @@ -64888,8 +64884,7 @@ }, "@webpack-cli/serve": { "version": "1.6.1", - "dev": true, - "requires": {} + "dev": true }, "@wry/context": { "version": "0.6.1", @@ -64982,13 +64977,11 @@ }, "acorn-import-assertions": { "version": "1.8.0", - "dev": true, - "requires": {} + "dev": true }, "acorn-jsx": { "version": "5.3.2", - "dev": true, - "requires": {} + "dev": true }, "acorn-node": { "version": "1.8.2", @@ -65062,8 +65055,7 @@ }, "ajv-errors": { "version": "1.0.1", - "dev": true, - "requires": {} + "dev": true }, "ajv-formats": { "version": "2.1.1", @@ -65090,8 +65082,7 @@ }, "ajv-keywords": { "version": "3.5.2", - "dev": true, - "requires": {} + "dev": true }, "anser": { "version": "2.1.1", @@ -65190,7 +65181,7 @@ "git-url-parse": "11.5.0", "glob": "7.2.0", "global-agent": "2.2.0", - "graphql": "15.8.0", + "graphql": "14.0.2 - 14.2.0 || ^14.3.1 || ^15.0.0", "graphql-tag": "2.12.4", "listr": "0.14.3", "lodash.identity": "3.0.0", @@ -65480,7 +65471,7 @@ "cosmiconfig": "^5.0.6", "dotenv": "^8.0.0", "glob": "^7.1.3", - "graphql": "15.8.0", + "graphql": "14.0.2 - 14.2.0 || ^14.3.1 || ^15.0.0", "graphql-tag": "^2.10.1", "lodash.debounce": "^4.0.8", "lodash.merge": "^4.6.1", @@ -65671,8 +65662,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.5.0.tgz", "integrity": "sha512-lO5oTjgiC3vlVg2RKr3RiXIIQ5pGXBFxYGGUkKDhTud3jMIhs+gel8L8zsEjKaKxkjHhCQAA/bcEfYiKkGQIvA==", - "dev": true, - "requires": {} + "dev": true }, "apollo-utilities": { "version": "1.3.4", @@ -65887,7 +65877,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "devOptional": true + "dev": true }, "asynckit": { "version": "0.4.0", @@ -66207,7 +66197,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=", - "devOptional": true + "dev": true }, "bail": { "version": "1.0.5", @@ -66599,7 +66589,7 @@ }, "bufferutil": { "version": "4.0.6", - "devOptional": true, + "dev": true, "requires": { "node-gyp-build": "^4.3.0" } @@ -66608,6 +66598,12 @@ "version": "3.0.0", "dev": true }, + "bvaughn-architecture-demo": { + "version": "file:packages/bvaughn-architecture-demo", + "requires": { + "protocol": "file:../protocol" + } + }, "byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -66989,8 +66985,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/circular-dependency-plugin/-/circular-dependency-plugin-5.2.2.tgz", "integrity": "sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ==", - "dev": true, - "requires": {} + "dev": true }, "cjs-module-lexer": { "version": "1.2.2", @@ -67975,8 +67970,7 @@ "dependencies": { "postcss-modules-extract-imports": { "version": "3.0.0", - "dev": true, - "requires": {} + "dev": true }, "postcss-modules-local-by-default": { "version": "4.0.0", @@ -69063,8 +69057,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} + "dev": true }, "eslint-import-resolver-node": { "version": "0.3.6", @@ -69230,8 +69223,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.4.0.tgz", "integrity": "sha512-U3RVIfdzJaeKDQKEJbz5p3NW8/L80PCATJAfuojwbaEL+gBjfGdhUcGde+WGUW46Q5sr/NgxevsIiDtNXrvZaQ==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-sort-keys-fix": { "version": "1.1.2", @@ -69364,7 +69356,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", - "devOptional": true + "dev": true }, "events": { "version": "3.3.0", @@ -70695,7 +70687,8 @@ "graphql": { "version": "15.8.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", - "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==" + "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", + "dev": true }, "graphql-config": { "version": "4.1.0", @@ -70763,8 +70756,7 @@ "version": "0.0.19", "resolved": "https://registry.npmjs.org/graphql-executor/-/graphql-executor-0.0.19.tgz", "integrity": "sha512-AFOcsk/yMtl9jcO/f/0Our7unWxJ5m3FS5HjWfsXRHCyjjaubXpSHiOZO/hSYv6brayIrupDoVAzCuJpBc3elg==", - "dev": true, - "requires": {} + "dev": true }, "graphql-language-service-interface": { "version": "2.10.2", @@ -70839,8 +70831,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/graphql-sse/-/graphql-sse-1.1.0.tgz", "integrity": "sha512-xE8AGPJa5X+g7iFmRQw/8H+7lXIDJvSkW6lou/XSSq17opPQl+dbKOMiqraHMx52VrDgS061ZVx90OSuqS6ykA==", - "dev": true, - "requires": {} + "dev": true }, "graphql-tag": { "version": "2.12.6", @@ -70857,8 +70848,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.6.3.tgz", "integrity": "sha512-ZolWOi6bzI35ovGROCZROB9nDbwZiJdIsaPdzW/jkICCGNb3qL/33IONY/yQiBa+Je2uA11HfY4Uxse4+/ePYA==", - "devOptional": true, - "requires": {} + "dev": true }, "graphqurl": { "version": "1.0.1", @@ -70872,7 +70862,7 @@ "@oclif/plugin-help": "3.2.1", "cli-ux": "^4.7.3", "express": "4.16.3", - "graphql": "15.8.0", + "graphql": "15.4.0", "graphql-language-service-interface": "^2.8.2", "graphql-language-service-utils": "2.5.1", "isomorphic-fetch": "3.0.0", @@ -71363,8 +71353,7 @@ "version": "7.4.2", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.2.tgz", "integrity": "sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA==", - "dev": true, - "requires": {} + "dev": true } } }, @@ -71863,8 +71852,7 @@ }, "icss-utils": { "version": "5.1.0", - "dev": true, - "requires": {} + "dev": true }, "identity-obj-proxy": { "version": "3.0.0", @@ -72434,8 +72422,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "dev": true, - "requires": {} + "dev": true }, "istanbul-lib-coverage": { "version": "3.2.0", @@ -72505,7 +72492,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", - "devOptional": true + "dev": true }, "iterate-iterator": { "version": "1.0.2", @@ -73714,8 +73701,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true, - "requires": {} + "dev": true }, "jest-resolve": { "version": "28.0.2", @@ -74812,8 +74798,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "dev": true, - "requires": {} + "dev": true } } }, @@ -75538,8 +75523,7 @@ "integrity": "sha512-yf+iaZNuB13RjNFHzEWvj76G5nVzpl1u6p0wOnWOyLbOJflJJEIMBO81VEOtDUawFYeus8Xprsdfofb+KRqoCQ==" }, "logrocket-react": { - "version": "5.0.1", - "requires": {} + "version": "5.0.1" }, "loose-envify": { "version": "1.4.0", @@ -75654,8 +75638,7 @@ }, "markdown-to-jsx": { "version": "7.1.3", - "dev": true, - "requires": {} + "dev": true }, "matcher": { "version": "3.0.0", @@ -75932,8 +75915,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/meros/-/meros-1.2.0.tgz", "integrity": "sha512-3QRZIS707pZQnijHdhbttXRWwrHhZJ/gzolneoxKVz9N/xmsvY/7Ls8lpnI9gxbgxjcHsAVEW3mgwiZCo6kkJQ==", - "dev": true, - "requires": {} + "dev": true }, "methods": { "version": "1.1.2", @@ -76364,8 +76346,7 @@ "styled-jsx": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.2.tgz", - "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==", - "requires": {} + "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==" } } }, @@ -76442,7 +76423,7 @@ }, "node-gyp-build": { "version": "4.3.0", - "devOptional": true + "dev": true }, "node-int64": { "version": "0.4.0", @@ -77597,8 +77578,7 @@ }, "prettier-plugin-tailwindcss": { "version": "0.1.7", - "dev": true, - "requires": {} + "dev": true }, "pretty-error": { "version": "4.0.0", @@ -78071,17 +78051,14 @@ } }, "react-circular-progressbar": { - "version": "2.0.4", - "requires": {} + "version": "2.0.4" }, "react-codemirror2": { - "version": "7.2.1", - "requires": {} + "version": "7.2.1" }, "react-colorful": { "version": "5.5.0", - "dev": true, - "requires": {} + "dev": true }, "react-devtools-inline": { "version": "4.24.5", @@ -78130,8 +78107,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz", "integrity": "sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==", - "dev": true, - "requires": {} + "dev": true }, "react-dom": { "version": "0.0.0-experimental-e7d0053e6-20220325", @@ -78204,8 +78180,7 @@ "react-icons": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.3.1.tgz", - "integrity": "sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ==", - "requires": {} + "integrity": "sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ==" }, "react-inspector": { "version": "5.1.1", @@ -78238,8 +78213,7 @@ } }, "react-lazyload": { - "version": "3.2.0", - "requires": {} + "version": "3.2.0" }, "react-lifecycles-compat": { "version": "3.0.4" @@ -78342,8 +78316,7 @@ } }, "react-table": { - "version": "7.7.0", - "requires": {} + "version": "7.7.0" }, "react-textarea-autosize": { "version": "8.3.3", @@ -78375,8 +78348,7 @@ } }, "react-virtualized-auto-sizer": { - "version": "1.0.6", - "requires": {} + "version": "1.0.6" }, "react-window": { "version": "1.8.6", @@ -78386,8 +78358,7 @@ } }, "reactjs-popup": { - "version": "2.0.5", - "requires": {} + "version": "2.0.5" }, "read-pkg": { "version": "5.2.0", @@ -78580,12 +78551,10 @@ "redux-persist": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", - "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", - "requires": {} + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==" }, "redux-thunk": { - "version": "2.4.1", - "requires": {} + "version": "2.4.1" }, "refractor": { "version": "3.6.0", @@ -80081,8 +80050,7 @@ }, "style-loader": { "version": "3.3.1", - "dev": true, - "requires": {} + "dev": true }, "style-to-object": { "version": "0.3.0", @@ -80139,7 +80107,7 @@ "version": "0.9.18", "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.18.tgz", "integrity": "sha512-tztzcBTNoEbuErsVQpTN2xUNN/efAZXyCyL5m3x4t6SKrEiTL2N8SaKWBFWM4u56pL79ULif3zjyeq+oV+nOaA==", - "devOptional": true, + "dev": true, "requires": { "backo2": "^1.0.2", "eventemitter3": "^3.1.0", @@ -80152,13 +80120,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", - "devOptional": true + "dev": true }, "ws": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.3.tgz", "integrity": "sha512-jZArVERrMsKUatIdnLzqvcfydI85dvd/Fp1u/VOpfdDWQ4c9qWXe+VIeAbQ5FrDwciAkr+lzofXLz3Kuf26AOA==", - "devOptional": true, + "dev": true, "requires": { "async-limiter": "~1.0.0" } @@ -81265,8 +81233,7 @@ } }, "use-isomorphic-layout-effect": { - "version": "1.1.1", - "requires": {} + "version": "1.1.1" }, "use-latest": { "version": "1.2.0", @@ -81276,7 +81243,7 @@ }, "utf-8-validate": { "version": "5.0.8", - "devOptional": true, + "dev": true, "requires": { "node-gyp-build": "^4.3.0" } @@ -81772,8 +81739,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz", "integrity": "sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==", - "dev": true, - "requires": {} + "dev": true }, "webpack-hot-middleware": { "version": "2.25.1", @@ -82049,8 +82015,7 @@ } }, "ws": { - "version": "7.5.5", - "requires": {} + "version": "7.5.5" }, "x-default-browser": { "version": "0.4.0", diff --git a/package.json b/package.json index 706d689bcfc..c29fa4f4389 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "@storybook/react": "^6.4.21", "@tailwindcss/forms": "^0.5.0", "@testing-library/jest-dom": "^5.16.3", - "@testing-library/react": "^12.1.4", + "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^14.1.0", "@trunkio/launcher": "^1.0.5", "@types/classnames": "^2.2.11", From 594d524f8c04c897f5c98eff24c7d953fe5295e1 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 May 2022 14:58:58 -0400 Subject: [PATCH 4/6] Added some example tests --- .../components/console/Filters.tsx | 1 + .../components/console/Focuser.test.tsx | 57 ++++++ .../components/console/Focuser.tsx | 12 +- .../components/console/MessagesList.tsx | 6 +- .../pages/index.test.tsx | 37 ++++ .../src/ReplayClient.ts | 17 +- .../bvaughn-architecture-demo/src/contexts.ts | 4 +- .../src/suspense/MessagesCache.ts | 6 +- .../src/suspense/PointsCache.ts | 9 +- .../src/utils/testing.tsx | 131 ++++++++++++ packages/shared/utils/testing.ts | 187 +++++++++++++++++ .../Search/useConsoleSearch.test.tsx | 5 +- src/test/testFixtureUtils.tsx | 189 +----------------- 13 files changed, 455 insertions(+), 206 deletions(-) create mode 100644 packages/bvaughn-architecture-demo/components/console/Focuser.test.tsx create mode 100644 packages/bvaughn-architecture-demo/pages/index.test.tsx create mode 100644 packages/bvaughn-architecture-demo/src/utils/testing.tsx create mode 100644 packages/shared/utils/testing.ts diff --git a/packages/bvaughn-architecture-demo/components/console/Filters.tsx b/packages/bvaughn-architecture-demo/components/console/Filters.tsx index 6826b04e4bc..7528e7db571 100644 --- a/packages/bvaughn-architecture-demo/components/console/Filters.tsx +++ b/packages/bvaughn-architecture-demo/components/console/Filters.tsx @@ -48,6 +48,7 @@ export default function Filters() {
update(event.currentTarget.value, levelFlags)} placeholder="Filter output" diff --git a/packages/bvaughn-architecture-demo/components/console/Focuser.test.tsx b/packages/bvaughn-architecture-demo/components/console/Focuser.test.tsx new file mode 100644 index 00000000000..971881c9540 --- /dev/null +++ b/packages/bvaughn-architecture-demo/components/console/Focuser.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, screen } from "@testing-library/react"; + +import { renderFocused } from "../../src/utils/testing"; + +import Focuser from "./Focuser"; + +describe("Focuser", () => { + it("should render the with no focus region", async () => { + await renderFocused(, { + focusContext: { + range: null, + rangeForDisplay: null, + }, + sessionContext: { + duration: 60_000, + }, + }); + + expect(await screen.queryByText("Focus off")).toBeInTheDocument(); + expect(await screen.queryByText("0:00 – 1:00")).toBeInTheDocument(); + }); + + it("should render the current focus region", async () => { + await renderFocused(, { + focusContext: { + range: [0, 30_000], + rangeForDisplay: [0, 30_000], + }, + sessionContext: { + duration: 60_000, + }, + }); + + expect(await screen.queryByText("Focus on")).toBeInTheDocument(); + expect(await screen.queryByText("0:00 – 0:30")).toBeInTheDocument(); + }); + + it("should allow the focus region to be toggled on and off", async () => { + const { + focusContext: { update }, + } = await renderFocused(, { + focusContext: { + range: null, + rangeForDisplay: null, + }, + sessionContext: { + duration: 60_000, + }, + }); + + expect(update).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText("Focus off")); + + expect(update).toHaveBeenCalledWith([0, 60_000], false); + }); +}); diff --git a/packages/bvaughn-architecture-demo/components/console/Focuser.tsx b/packages/bvaughn-architecture-demo/components/console/Focuser.tsx index 517ff100c68..fd9a4c09a9e 100644 --- a/packages/bvaughn-architecture-demo/components/console/Focuser.tsx +++ b/packages/bvaughn-architecture-demo/components/console/Focuser.tsx @@ -29,17 +29,25 @@ export default function Focuser() { - +
); } function RangeSlider({ + duration, enabled, end, onChange, start, }: { + duration: number; enabled: boolean; end: number; onChange: (start: number, end: number) => void; @@ -94,7 +102,7 @@ function RangeSlider({ />
- {formatTimestamp(start)} – {formatTimestamp(end)} + {formatTimestamp(start * duration)} – {formatTimestamp(end * duration)}
); diff --git a/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx b/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx index d3c0ffbb816..beefc6cd081 100644 --- a/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx +++ b/packages/bvaughn-architecture-demo/components/console/MessagesList.tsx @@ -22,14 +22,14 @@ export default function MessagesList() { const { filterByText, levelFlags } = useContext(ConsoleFiltersContext); const { range, isTransitionPending: isFocusTransitionPending } = useContext(FocusContext); - let focusMode = null; + let focusRange = null; if (range !== null) { const [startTime, endTime] = range; const startPoint = getClosestPointForTime(replayClient, startTime); const endPoint = getClosestPointForTime(replayClient, endTime); - focusMode = { + focusRange = { begin: { point: startPoint, time: startTime, @@ -41,7 +41,7 @@ export default function MessagesList() { }; } - const { countAfter, countBefore, didOverflow, messages } = getMessages(replayClient, focusMode); + const { countAfter, countBefore, didOverflow, messages } = getMessages(replayClient, focusRange); // Memoized selector that joins log points and messages and filters by criteria (e.g. type) // Note that we are intentionally not storing derived values like this in state. diff --git a/packages/bvaughn-architecture-demo/pages/index.test.tsx b/packages/bvaughn-architecture-demo/pages/index.test.tsx new file mode 100644 index 00000000000..ba75b139244 --- /dev/null +++ b/packages/bvaughn-architecture-demo/pages/index.test.tsx @@ -0,0 +1,37 @@ +import { screen } from "@testing-library/react"; +import { createConsoleMessage } from "shared/utils/testing"; + +import { render } from "../src/utils/testing"; + +import HomePage from "./index"; + +describe("MessageList", () => { + it("should render the console app", async () => { + await render(, { + replayClient: { + findMessages: async () => + Promise.resolve({ + messages: [ + createConsoleMessage({ + text: "This is a message", + })[1], + ], + overflow: true, + }), + }, + }); + + // Verify that our message was rendered. + expect(screen.getByText("This is a message")).toBeInTheDocument(); + + // Basic message filters + expect(screen.getByRole("checkbox", { name: "Errors?" })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Logs?" })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Warnings?" })).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Filter output")).toBeInTheDocument(); + + // // Basic focus range UI should be rendered + expect(await screen.queryByText("Focus off")).toBeInTheDocument(); + expect(await screen.queryByText("0:00 – 0:01")).toBeInTheDocument(); + }); +}); diff --git a/packages/bvaughn-architecture-demo/src/ReplayClient.ts b/packages/bvaughn-architecture-demo/src/ReplayClient.ts index d17bf407d32..0ee6cb2c559 100644 --- a/packages/bvaughn-architecture-demo/src/ReplayClient.ts +++ b/packages/bvaughn-architecture-demo/src/ReplayClient.ts @@ -12,7 +12,18 @@ import type { ThreadFront } from "protocol/thread"; // Should we force serialization? // Should we cancel in-flight requests and start new ones? -export default class ReplayClient { +export interface ReplayClientInterface { + initialize(recordingId: string, accessToken: string | null): Promise; + findMessages(focusRange: TimeStampedPointRange | null): Promise<{ + messages: Message[]; + overflow: boolean; + }>; + findSources(): Promise; + getPointNearTime(time: number): Promise; + getSessionEndpoint(sessionId: SessionId): Promise; +} + +export class ReplayClient implements ReplayClient { private _sessionId: SessionId | null = null; private _threadFront: typeof ThreadFront; @@ -24,7 +35,7 @@ export default class ReplayClient { } } - getSessionIdThrows(): SessionId { + private getSessionIdThrows(): SessionId { const sessionId = this._sessionId; if (sessionId === null) { throw Error("Invalid session"); @@ -80,7 +91,7 @@ export default class ReplayClient { } } - async findSources() { + async findSources(): Promise { await this._threadFront.findSources(() => { // The demo doesn't use these directly, but the client throws if they aren't loaded. }); diff --git a/packages/bvaughn-architecture-demo/src/contexts.ts b/packages/bvaughn-architecture-demo/src/contexts.ts index d151ccbc676..35e27aa8bb6 100644 --- a/packages/bvaughn-architecture-demo/src/contexts.ts +++ b/packages/bvaughn-architecture-demo/src/contexts.ts @@ -1,7 +1,7 @@ import { ExecutionPoint } from "@replayio/protocol"; import { ThreadFront } from "protocol/thread"; import { createContext } from "react"; -import ReplayClient from "./ReplayClient"; +import { ReplayClient, ReplayClientInterface } from "./ReplayClient"; import { Range } from "./types"; @@ -49,7 +49,7 @@ export type SessionContextType = { }; export const SessionContext = createContext(null as any); -export type ReplayClientContextType = ReplayClient; +export type ReplayClientContextType = ReplayClientInterface; // By default, this context wires the app up to use real Replay backend APIs. // We can leverage this when writing tests (or UI demos) by injecting a stub client. diff --git a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts index b2003aeb9f3..5c054b54c24 100644 --- a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts +++ b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts @@ -1,6 +1,6 @@ import { Message, TimeStampedPointRange } from "@replayio/protocol"; -import ReplayClient from "../ReplayClient"; +import { ReplayClientInterface } from "../ReplayClient"; import { Wakeable } from "../types"; import { compareNumericStrings } from "../utils/string"; import { createWakeable } from "../utils/suspense"; @@ -39,7 +39,7 @@ type getMessagesResponse = { // // This method is Suspense friend; it is meant to be called from a React component during render. export function getMessages( - client: ReplayClient, + client: ReplayClientInterface, focusRange: TimeStampedPointRange | null ): getMessagesResponse { if (focusRange !== null && focusRange.begin.point === focusRange.end.point) { @@ -137,7 +137,7 @@ export function getMessages( } async function fetchMessages( - client: ReplayClient, + client: ReplayClientInterface, focusRange: TimeStampedPointRange | null, wakeable: Wakeable ) { diff --git a/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts b/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts index 56c26a86b95..d91c0f288ea 100644 --- a/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts +++ b/packages/bvaughn-architecture-demo/src/suspense/PointsCache.ts @@ -1,6 +1,6 @@ import { ExecutionPoint, SessionId } from "@replayio/protocol"; import { unstable_getCacheForType as getCacheForType } from "react"; -import ReplayClient from "../ReplayClient"; +import { ReplayClientInterface } from "../ReplayClient"; import { Record, STATUS_PENDING, STATUS_REJECTED, STATUS_RESOLVED, Wakeable } from "../types"; import { createWakeable } from "../utils/suspense"; @@ -18,7 +18,10 @@ function getRecordMap(): TimeToRecordMap { return getCacheForType(createMap); } -export function getClosestPointForTime(client: ReplayClient, time: number): ExecutionPoint { +export function getClosestPointForTime( + client: ReplayClientInterface, + time: number +): ExecutionPoint { const map = getRecordMap(); let record = map.get(time); if (record == null) { @@ -42,7 +45,7 @@ export function getClosestPointForTime(client: ReplayClient, time: number): Exec } async function fetchPoint( - client: ReplayClient, + client: ReplayClientInterface, time: number, record: Record, wakeable: Wakeable diff --git a/packages/bvaughn-architecture-demo/src/utils/testing.tsx b/packages/bvaughn-architecture-demo/src/utils/testing.tsx new file mode 100644 index 00000000000..96e977777f1 --- /dev/null +++ b/packages/bvaughn-architecture-demo/src/utils/testing.tsx @@ -0,0 +1,131 @@ +import { act, render as rtlRender, RenderResult } from "@testing-library/react"; +import { ReactNode } from "react"; + +import { + ConsoleFiltersContext, + ConsoleFiltersContextType, + FocusContext, + FocusContextType, + ReplayClientContext, + SessionContext, + SessionContextType, +} from "../contexts"; +import { ReplayClient, ReplayClientInterface } from "../ReplayClient"; + +// This particular method is written to enable testing the entire client. +// The only context values it stubs out are the ReplayClient (ReplayClientContext). +export async function render( + children: ReactNode, + options?: { + replayClient?: Partial; + sessionContext?: Partial; + } +): Promise<{ + renderResult: RenderResult; + replayClient: ReplayClientInterface; + sessionContext: SessionContextType; +}> { + const replayClient: ReplayClientInterface = { + ...MockReplayClient, + ...options?.replayClient, + }; + + const sessionContext: SessionContextType = { + duration: 1000, + endPoint: "1000", + recordingId: "fakeRecordingId", + sessionId: "fakeSessionId", + ...options?.sessionContext, + }; + + let renderResult: RenderResult = null as any; + await act(async () => { + const result = rtlRender( + + {children} + + ); + + renderResult = result; + }); + + return { + renderResult, + replayClient, + sessionContext, + }; +} + +// We could render the entire app and let it inject the "real" contexts, +// or we can render our own contexts to control the values being shown (and what we're testing). +// The only thing we NEED to override in all cases is the ReplayClient (ReplayClientContext). +// +// This particular method is written to enable focused component testing. +// In other words, it stubs out all of the contexts any component in the app may need. +export async function renderFocused( + children: ReactNode, + options?: { + consoleFiltersContext?: Partial; + focusContext?: Partial; + replayClient?: Partial; + sessionContext?: Partial; + } +): Promise<{ + consoleFiltersContext: ConsoleFiltersContextType; + focusContext: FocusContextType; + renderResult: RenderResult; + replayClient: ReplayClientInterface; + sessionContext: SessionContextType; +}> { + const consoleFiltersContext: ConsoleFiltersContextType = { + filterByDisplayText: "", + filterByText: "", + isTransitionPending: false, + levelFlags: { + showErrors: true, + showLogs: true, + showWarnings: true, + }, + update: jest.fn(), + ...options?.consoleFiltersContext, + }; + + const focusContext: FocusContextType = { + isTransitionPending: false, + range: null, + rangeForDisplay: null, + update: jest.fn(), + ...options?.focusContext, + }; + + const renderResponse = await render( + + + {children} + + , + { + replayClient: options?.replayClient, + sessionContext: options?.sessionContext, + } + ); + + return { + ...renderResponse, + consoleFiltersContext, + focusContext, + }; +} + +// This mock client is mostly useless by itself, +// but its methods can be overridden individually (or observed/inspected) by test code. +const MockReplayClient = { + initialize: jest.fn().mockImplementation(async () => {}), + findMessages: jest.fn().mockImplementation(async () => ({ messages: [], overflow: false })), + findSources: jest.fn().mockImplementation(async () => {}), + getPointNearTime: jest.fn().mockImplementation(async () => ({ point: "0", time: 0 })), + getSessionEndpoint: jest.fn().mockImplementation(async () => ({ + point: "1000", + time: 1000, + })), +}; diff --git a/packages/shared/utils/testing.ts b/packages/shared/utils/testing.ts new file mode 100644 index 00000000000..403c9e8beae --- /dev/null +++ b/packages/shared/utils/testing.ts @@ -0,0 +1,187 @@ +import type { + CallStack, + loadedRegions, + Location, + MappedLocation, + Message, + MessageLevel, + MessageSource, + newSource, + ObjectId, + PauseData, + PauseId, + PointDescription, + SourceId, + SourceKind, + TimeStampedPoint, + TimeStampedPointRange, + Value, +} from "@replayio/protocol"; + +export const DEFAULT_SOURCE_ID = "fake-source-id"; +export const DEFAULT_SOURCE_URL = "fake-source-url"; + +let uidCounter = 0; + +export type MessageTuple = ["Console.newMessage", Message]; + +export function createConsoleMessage({ + argumentValues = [], + column = 0, + data = {}, + level = "info", + line = 1, + pauseId = `${uidCounter++}`, + point = createPointDescription({}), + source = "ConsoleAPI", + sourceId, + stack = [], + text = "", + url, +}: { + argumentValues?: Value[]; + column?: number; + data?: PauseData; + level?: MessageLevel; + line?: number; + pauseId?: PauseId; + point?: PointDescription; + source?: MessageSource; + sourceId?: SourceId; + stack?: CallStack; + text?: string; + url?: string; +} = {}): MessageTuple { + const value: Message = { + argumentValues, + column, + data, + level, + line, + pauseId, + point, + source, + sourceId, + stack, + text, + url, + }; + + return ["Console.newMessage", value]; +} + +export type LoadedRegionsTuple = ["Session.loadedRegions", loadedRegions]; + +export function createLoadedRegions({ + beginPoint, + beginTime = 0, + endPoint, + endTime, + isIndexed = true, + isLoaded = true, +}: { + beginPoint?: string; + beginTime?: number; + endPoint?: string; + endTime: number; + isIndexed?: boolean; + isLoaded?: boolean; +}): LoadedRegionsTuple { + const timeRange: TimeStampedPointRange = { + begin: createTimeStampedPoint({ point: beginPoint, time: beginTime }), + end: createTimeStampedPoint({ point: endPoint, time: endTime }), + }; + + const value: loadedRegions = { + indexed: isIndexed ? [timeRange] : [], + loaded: isLoaded ? [timeRange] : [], + loading: [timeRange], + }; + + return ["Session.loadedRegions", value]; +} + +export function createLocation({ + column = 0, + line = 0, + sourceId = DEFAULT_SOURCE_ID, +}: { + column?: number; + line?: number; + sourceId?: SourceId; +}): Location { + return { column, line, sourceId }; +} + +export function createPointDescription({ + frame = [createLocation({})], + point, + time = 0, +}: { + frame?: MappedLocation; + point?: string; + time?: number; +}): PointDescription { + return { frame, point: point || `${time}`, time }; +} + +export type SourceTuple = ["Debugger.newSource", newSource]; + +export function createSource({ + generatedSourceIds, + kind, + sourceId = DEFAULT_SOURCE_ID, + url = DEFAULT_SOURCE_URL, +}: { + generatedSourceIds?: SourceId[]; + kind: SourceKind; + sourceId?: SourceId; + url?: string; +}): SourceTuple { + const value: newSource = { + generatedSourceIds, + kind, + sourceId, + url, + }; + + return ["Debugger.newSource", value]; +} + +export function createTimeStampedPoint({ + point, + time = 0, +}: { + point?: string; + time?: number; +}): TimeStampedPoint { + return { point: point || `${time}`, time }; +} + +export function createValue({ + bigint, + object, + symbol, + unavailable, + uninitialized, + unserializableNumber, + value, +}: { + bigint?: string; + object?: ObjectId; + symbol?: string; + unavailable?: boolean; + uninitialized?: boolean; + unserializableNumber?: string; + value?: any; +}): Value { + return { + bigint, + object, + symbol, + unavailable, + uninitialized, + unserializableNumber, + value, + }; +} diff --git a/src/devtools/client/webconsole/components/Search/useConsoleSearch.test.tsx b/src/devtools/client/webconsole/components/Search/useConsoleSearch.test.tsx index 9f39a1e8728..e0ebb1a914f 100644 --- a/src/devtools/client/webconsole/components/Search/useConsoleSearch.test.tsx +++ b/src/devtools/client/webconsole/components/Search/useConsoleSearch.test.tsx @@ -2,13 +2,12 @@ import { ThreadFront, ValueFront } from "protocol/thread"; import React from "react"; import { act } from "react-dom/test-utils"; import { - DEFAULT_SESSION_ID, createConsoleMessage, createLoadedRegions, createSource, createValue, - sendValuesToMockEnvironment, -} from "test/testFixtureUtils"; +} from "shared/utils/testing"; +import { DEFAULT_SESSION_ID, sendValuesToMockEnvironment } from "test/testFixtureUtils"; import { createTestStore, filterCommonTestWarnings, diff --git a/src/test/testFixtureUtils.tsx b/src/test/testFixtureUtils.tsx index ab7fa148289..e45b2b82df7 100644 --- a/src/test/testFixtureUtils.tsx +++ b/src/test/testFixtureUtils.tsx @@ -1,202 +1,17 @@ import { readFileSync } from "fs"; import { gql } from "@apollo/client"; -import type { - CallStack, - loadedRegions, - Location, - MappedLocation, - Message, - MessageLevel, - MessageSource, - newSource, - ObjectId, - PauseData, - PauseId, - PointDescription, - SourceId, - SourceKind, - TimeStampedPoint, - TimeStampedPointRange, - Value, -} from "@replayio/protocol"; import { ThreadFront } from "protocol/thread"; +import { LoadedRegionsTuple, MessageTuple, SourceTuple } from "shared/utils/testing"; import { UIStore } from "ui/actions"; +import { getMockEnvironmentForTesting } from "ui/utils/environment"; import { convertRecording } from "ui/hooks/recordings"; import { Recording } from "ui/types"; import { createTestStore } from "./testUtils"; -import { getMockEnvironmentForTesting } from "ui/utils/environment"; -export const DEFAULT_SOURCE_ID = "fake-source-id"; -export const DEFAULT_SOURCE_URL = "fake-source-url"; export const DEFAULT_SESSION_ID = "fake-session-id"; -let uidCounter = 0; - -type MessageTuple = ["Console.newMessage", Message]; - -export function createConsoleMessage({ - argumentValues = [], - column = 0, - data = {}, - level = "info", - line = 1, - pauseId = `${uidCounter++}`, - point = createPointDescription({}), - source = "ConsoleAPI", - sourceId, - stack = [], - text = "", - url, -}: { - argumentValues?: Value[]; - column?: number; - data?: PauseData; - level?: MessageLevel; - line?: number; - pauseId?: PauseId; - point?: PointDescription; - source?: MessageSource; - sourceId?: SourceId; - stack?: CallStack; - text?: string; - url?: string; -}): MessageTuple { - const value: Message = { - argumentValues, - column, - data, - level, - line, - pauseId, - point, - source, - sourceId, - stack, - text, - url, - }; - - return ["Console.newMessage", value]; -} - -type LoadedRegionsTuple = ["Session.loadedRegions", loadedRegions]; - -export function createLoadedRegions({ - beginPoint, - beginTime = 0, - endPoint, - endTime, - isIndexed = true, - isLoaded = true, -}: { - beginPoint?: string; - beginTime?: number; - endPoint?: string; - endTime: number; - isIndexed?: boolean; - isLoaded?: boolean; -}): LoadedRegionsTuple { - const timeRange: TimeStampedPointRange = { - begin: createTimeStampedPoint({ point: beginPoint, time: beginTime }), - end: createTimeStampedPoint({ point: endPoint, time: endTime }), - }; - - const value: loadedRegions = { - indexed: isIndexed ? [timeRange] : [], - loaded: isLoaded ? [timeRange] : [], - loading: [timeRange], - }; - - return ["Session.loadedRegions", value]; -} - -export function createLocation({ - column = 0, - line = 0, - sourceId = DEFAULT_SOURCE_ID, -}: { - column?: number; - line?: number; - sourceId?: SourceId; -}): Location { - return { column, line, sourceId }; -} - -export function createPointDescription({ - frame = [createLocation({})], - point, - time = 0, -}: { - frame?: MappedLocation; - point?: string; - time?: number; -}): PointDescription { - return { frame, point: point || `${time}`, time }; -} - -type SourceTuple = ["Debugger.newSource", newSource]; - -export function createSource({ - generatedSourceIds, - kind, - sourceId = DEFAULT_SOURCE_ID, - url = DEFAULT_SOURCE_URL, -}: { - generatedSourceIds?: SourceId[]; - kind: SourceKind; - sourceId?: SourceId; - url?: string; -}): SourceTuple { - const value: newSource = { - generatedSourceIds, - kind, - sourceId, - url, - }; - - return ["Debugger.newSource", value]; -} - -export function createTimeStampedPoint({ - point, - time = 0, -}: { - point?: string; - time?: number; -}): TimeStampedPoint { - return { point: point || `${time}`, time }; -} - -export function createValue({ - bigint, - object, - symbol, - unavailable, - uninitialized, - unserializableNumber, - value, -}: { - bigint?: string; - object?: ObjectId; - symbol?: string; - unavailable?: boolean; - uninitialized?: boolean; - unserializableNumber?: string; - value?: any; -}): Value { - return { - bigint, - object, - symbol, - unavailable, - uninitialized, - unserializableNumber, - value, - }; -} - export async function loadFixtureData( testName: string ): Promise<{ graphqlMocks: any[]; recording: Recording; sessionId: string; store: UIStore }> { From 560b1ec8182cbc0e9035bb8a093648a765c3f188 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 May 2022 15:46:39 -0400 Subject: [PATCH 5/6] Removed an oudated TODO comment --- .../bvaughn-architecture-demo/src/suspense/MessagesCache.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts index 5c054b54c24..75fffe63c66 100644 --- a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts +++ b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts @@ -10,11 +10,6 @@ import { formatTimestamp, isRangeEqual, isRangeSubset } from "../utils/time"; // 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; From 0e2ee8091a38aaf1b616648a7ecad633e94d4320 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 May 2022 16:20:29 -0400 Subject: [PATCH 6/6] Moved sorting into ReplayClient and optimized slightly --- .../src/ReplayClient.ts | 36 ++++++++++++++++--- .../src/suspense/MessagesCache.ts | 13 +------ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/bvaughn-architecture-demo/src/ReplayClient.ts b/packages/bvaughn-architecture-demo/src/ReplayClient.ts index 0ee6cb2c559..1ac91f0830f 100644 --- a/packages/bvaughn-architecture-demo/src/ReplayClient.ts +++ b/packages/bvaughn-architecture-demo/src/ReplayClient.ts @@ -7,6 +7,7 @@ import { } from "@replayio/protocol"; import { client, initSocket } from "protocol/socket"; import type { ThreadFront } from "protocol/thread"; +import { compareNumericStrings } from "protocol/utils"; // TODO How should the client handle concurrent requests? // Should we force serialization? @@ -68,16 +69,43 @@ export class ReplayClient implements ReplayClient { sessionId ); + // Messages aren't guaranteed to arrive sorted, but unsorted messages aren't that useful to work with. + // So sort them before returning. + const sortedMessages = response.messages.sort((messageA: Message, messageB: Message) => { + const pointA = messageA.point.point; + const pointB = messageB.point.point; + return compareNumericStrings(pointA, pointB); + }); + return { - messages: response.messages, + messages: sortedMessages, overflow: response.overflow == true, }; } else { - const messages: Message[] = []; + const sortedMessages: Message[] = []; // TOOD This won't work if there are every overlapping requests. + // Do we need to implement some kind of locking mechanism to ensure only one read is going at a time? client.Console.addNewMessageListener(({ message }) => { - messages.push(message); + const newMessagePoint = message.point.point; + + // Messages may arrive out of order so let's sort them as we get them. + let lowIndex = 0; + let highIndex = sortedMessages.length; + while (lowIndex < highIndex) { + let middleIndex = (lowIndex + highIndex) >>> 1; + const message = sortedMessages[middleIndex]; + + if (compareNumericStrings(message.point.point, newMessagePoint)) { + lowIndex = middleIndex + 1; + } else { + highIndex = middleIndex; + } + } + + const insertAtIndex = lowIndex; + + sortedMessages.splice(insertAtIndex, 0, message); }); const response = await client.Console.findMessages({}, sessionId); @@ -85,7 +113,7 @@ export class ReplayClient implements ReplayClient { client.Console.removeNewMessageListener(); return { - messages, + messages: sortedMessages, overflow: response.overflow == true, }; } diff --git a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts index 75fffe63c66..0c8f83a97fb 100644 --- a/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts +++ b/packages/bvaughn-architecture-demo/src/suspense/MessagesCache.ts @@ -149,20 +149,9 @@ async function fetchMessages( if (inFlightWakeable === wakeable) { 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()? - const sortedMessages = 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 = sortedMessages; + lastFetchedMessages = messages; } wakeable.resolve();