diff --git a/packages/markerikson-stack-client-prototype/react.d.ts b/packages/markerikson-stack-client-prototype/react.d.ts new file mode 100644 index 00000000000..3c70a19a4c4 --- /dev/null +++ b/packages/markerikson-stack-client-prototype/react.d.ts @@ -0,0 +1,17 @@ +import "react"; + +declare module "react" { + type TransitionCallback = () => void; + + // The following hooks are only available in the experimental react release. + export function startTransition(TransitionCallback): void; + export function useDeferredValue(value: T): T; + export function useTransition(): [ + isPending: boolean, + startTransition: (TransitionCallback) => void + ]; + + // Unstable Suspense cache API + export function unstable_getCacheForType(resourceType: () => T): T; + export function unstable_useCacheRefresh(): () => void; +} diff --git a/packages/markerikson-stack-client-prototype/src/app/api.ts b/packages/markerikson-stack-client-prototype/src/app/api.ts index 0467a1c5775..8820ae374be 100644 --- a/packages/markerikson-stack-client-prototype/src/app/api.ts +++ b/packages/markerikson-stack-client-prototype/src/app/api.ts @@ -13,22 +13,31 @@ import { Dictionary } from "lodash"; import groupBy from "lodash/groupBy"; // eslint-disable-next-line no-restricted-imports import { client } from "protocol/socket"; +import type { ReplayClientInterface } from "shared/client/types"; -interface SourceGroups { +let sessionId: SessionId; +let replayClient: ReplayClientInterface; + +export const setReplayClient = (_sessionId: SessionId, _replayClient: ReplayClientInterface) => { + sessionId = _sessionId; + replayClient = _replayClient; +}; + +export interface SourceGroups { src: newSource[]; node_modules: newSource[]; other: newSource[]; } -const reIsJsSourceFile = /(js|ts)x?$/; +const reIsJsSourceFile = /(js|ts)x?(\?[\w\d]+)*$/; const CACHE_DATA_PERMANENTLY = 10 * 365 * 24 * 60 * 60; export const api = createApi({ baseQuery: fakeBaseQuery(), endpoints: build => ({ - getSources: build.query({ - queryFn: async (sessionId: SessionId) => { + getSources: build.query({ + queryFn: async () => { const sources: newSource[] = []; // Fetch the sources @@ -60,8 +69,8 @@ export const api = createApi({ }, keepUnusedDataFor: CACHE_DATA_PERMANENTLY, }), - getSourceText: build.query({ - queryFn: async ({ sessionId, sourceId }) => { + getSourceText: build.query({ + queryFn: async sourceId => { const demoSourceText = await client.Debugger.getSourceContents( { sourceId, @@ -72,11 +81,8 @@ export const api = createApi({ }, keepUnusedDataFor: CACHE_DATA_PERMANENTLY, }), - getSourceHitCounts: build.query< - Dictionary, - { sessionId: SessionId; sourceId: string } - >({ - queryFn: async ({ sessionId, sourceId }) => { + getSourceHitCounts: build.query, string>({ + queryFn: async sourceId => { const { lineLocations } = await client.Debugger.getPossibleBreakpoints( { sourceId, @@ -98,32 +104,30 @@ export const api = createApi({ }, keepUnusedDataFor: CACHE_DATA_PERMANENTLY, }), - getLineHitPoints: build.query( - { - queryFn: async ({ location, sessionId }) => { - const data = await new Promise(async resolve => { - const { analysisId } = await client.Analysis.createAnalysis( - { - mapper: "", - effectful: false, - }, - sessionId - ); - - client.Analysis.addLocation({ analysisId, location }, sessionId); - client.Analysis.findAnalysisPoints({ analysisId }, sessionId); - client.Analysis.addAnalysisPointsListener(({ points }) => { - resolve(points); - client.Analysis.releaseAnalysis({ analysisId }, sessionId); - }); + getLineHitPoints: build.query({ + queryFn: async location => { + const data = await new Promise(async resolve => { + const { analysisId } = await client.Analysis.createAnalysis( + { + mapper: "", + effectful: false, + }, + sessionId + ); + + client.Analysis.addLocation({ analysisId, location }, sessionId); + client.Analysis.findAnalysisPoints({ analysisId }, sessionId); + client.Analysis.addAnalysisPointsListener(({ points }) => { + resolve(points); + client.Analysis.releaseAnalysis({ analysisId }, sessionId); }); + }); - return { data }; - }, - } - ), - getPause: build.query({ - queryFn: async ({ point, sessionId }) => { + return { data }; + }, + }), + getPause: build.query({ + queryFn: async point => { const pause = await client.Session.createPause({ point: point.point }, sessionId); return { data: pause }; }, diff --git a/packages/markerikson-stack-client-prototype/src/app/hooks.ts b/packages/markerikson-stack-client-prototype/src/app/hooks.ts index b6b61d34e28..f2f7f18a78c 100644 --- a/packages/markerikson-stack-client-prototype/src/app/hooks.ts +++ b/packages/markerikson-stack-client-prototype/src/app/hooks.ts @@ -1,9 +1,9 @@ // eslint-disable-next-line no-restricted-imports -import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; +import { TypedUseSelectorHook, useDispatch, useSelector, useStore } from "react-redux"; -import type { AppDispatch, AppState } from "./store"; +import type { AppDispatch, AppState, AppStore } from "./store"; // Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch = () => useDispatch(); - +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppStore: () => AppStore = useStore; export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/packages/markerikson-stack-client-prototype/src/app/store.ts b/packages/markerikson-stack-client-prototype/src/app/store.ts index 86cfd359d10..e5131d2b4a1 100644 --- a/packages/markerikson-stack-client-prototype/src/app/store.ts +++ b/packages/markerikson-stack-client-prototype/src/app/store.ts @@ -15,8 +15,8 @@ export function makeStore() { const store = makeStore(); +export type AppStore = typeof store; export type AppState = ReturnType; - export type AppDispatch = typeof store.dispatch; export type AppThunk = ThunkAction< diff --git a/packages/markerikson-stack-client-prototype/src/components/Initializer.tsx b/packages/markerikson-stack-client-prototype/src/components/Initializer.tsx index 4503646712c..caa2d727a15 100644 --- a/packages/markerikson-stack-client-prototype/src/components/Initializer.tsx +++ b/packages/markerikson-stack-client-prototype/src/components/Initializer.tsx @@ -6,6 +6,7 @@ import { ReplayClientContext } from "shared/client/ReplayClientContext"; import asyncInitializeClient from "../client/asyncInitializeClient"; import { SessionContext, SessionContextType } from "../contexts/SessionContext"; +import { setReplayClient } from "../app/api"; // HACK Hack around the fact that the initSocket() function is side effectful // and writes to an "app" global on the window object. @@ -25,6 +26,7 @@ export default function Initializer({ children }: { children: ReactNode }) { // We only need to initialize them once. if (!didInitializeRef.current) { asyncInitializeClient(replayClient).then(sessionData => { + setReplayClient(sessionData.sessionId, replayClient) setContext(sessionData); }); } diff --git a/packages/markerikson-stack-client-prototype/src/components/Loader.module.css b/packages/markerikson-stack-client-prototype/src/components/Loader.module.css new file mode 100644 index 00000000000..a2aaa9cf9ac --- /dev/null +++ b/packages/markerikson-stack-client-prototype/src/components/Loader.module.css @@ -0,0 +1,4 @@ +.Loader { + padding: 0.25rem; + background-color: yellow; +} \ No newline at end of file diff --git a/packages/markerikson-stack-client-prototype/src/components/Loader.tsx b/packages/markerikson-stack-client-prototype/src/components/Loader.tsx new file mode 100644 index 00000000000..f4e6c55511f --- /dev/null +++ b/packages/markerikson-stack-client-prototype/src/components/Loader.tsx @@ -0,0 +1,5 @@ +import styles from "./Loader.module.css"; + +export default function Loader() { + return
Loading… ⏱️
; +} \ No newline at end of file diff --git a/packages/markerikson-stack-client-prototype/src/features/sources/SourceContent.tsx b/packages/markerikson-stack-client-prototype/src/features/sources/SourceContent.tsx index 15de335b094..77cd82aa341 100644 --- a/packages/markerikson-stack-client-prototype/src/features/sources/SourceContent.tsx +++ b/packages/markerikson-stack-client-prototype/src/features/sources/SourceContent.tsx @@ -25,14 +25,11 @@ export const SourceContent = () => { const selectedSourceId = useAppSelector(state => state.sources.selectedSourceId); const selectedPoint = useAppSelector(state => state.sources.selectedPoint); - const replayClient = useContext(ReplayClientContext); - const sessionId = replayClient.getSessionId()!; - const { currentData: sourceText } = useGetSourceTextQuery( - selectedSourceId ? { sessionId, sourceId: selectedSourceId } : skipToken + selectedSourceId ? selectedSourceId : skipToken ); const { currentData: sourceHits } = useGetSourceHitCountsQuery( - selectedSourceId ? { sessionId, sourceId: selectedSourceId } : skipToken + selectedSourceId ? selectedSourceId : skipToken ); let closestHitPoint: HitCount | null = null; @@ -48,17 +45,10 @@ export const SourceContent = () => { const location = closestHitPoint?.location; const { currentData: locationHitPoints } = useGetLineHitPointsQuery( - location ? { location, sessionId } : skipToken + location ? location : skipToken ); - const { currentData: pause } = useGetPauseQuery( - selectedPoint - ? { - point: selectedPoint, - sessionId, - } - : skipToken - ); + const { currentData: pause } = useGetPauseQuery(selectedPoint ? selectedPoint : skipToken); const domHandler = useMemo(() => { return EditorView.domEventHandlers({ diff --git a/packages/markerikson-stack-client-prototype/src/features/sources/SourcesList.tsx b/packages/markerikson-stack-client-prototype/src/features/sources/SourcesList.tsx index 0bb4226b83d..fb58981d9d8 100644 --- a/packages/markerikson-stack-client-prototype/src/features/sources/SourcesList.tsx +++ b/packages/markerikson-stack-client-prototype/src/features/sources/SourcesList.tsx @@ -1,18 +1,14 @@ -import { useContext } from "react"; -import { ReplayClientContext } from "shared/client/ReplayClientContext"; - -import { useGetSourcesQuery } from "../../app/api"; -import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { useAppDispatch, useAppSelector, useAppStore } from "../../app/hooks"; import { sourceEntrySelected } from "./sourcesSlice"; +import { getSourceGroups } from "./sourcesCache"; export const SourcesList = () => { const dispatch = useAppDispatch(); const selectedSourceId = useAppSelector(state => state.sources.selectedSourceId); + const store = useAppStore(); - const replayClient = useContext(ReplayClientContext); - const sessionId = replayClient.getSessionId()!; - const { data } = useGetSourcesQuery(sessionId); + const data = getSourceGroups(store); return (
    @@ -26,7 +22,7 @@ export const SourcesList = () => { const onLineClicked = () => dispatch(sourceEntrySelected(entry.sourceId)); return (
  • - {entryText} + {entryText} ({entry.kind})
  • ); })} diff --git a/packages/markerikson-stack-client-prototype/src/features/sources/sourcesCache.ts b/packages/markerikson-stack-client-prototype/src/features/sources/sourcesCache.ts new file mode 100644 index 00000000000..dfd99a78dce --- /dev/null +++ b/packages/markerikson-stack-client-prototype/src/features/sources/sourcesCache.ts @@ -0,0 +1,65 @@ +import { unstable_getCacheForType as getCacheForType } from "react"; +import type { AppStore } from "../../app/store"; + +import { api, SourceGroups } from "../../app/api"; + +/* + * This entire file is _mostly_ WIP PROOF OF CONCEPT! + + I've been staring at Brian's `CommentsCache` and `MessagesCache` + trying to understand the data flow, and reverse-engineer how to + create a valid Suspense cache entry. + + There appear to be two key pieces to know about here: + + - The `unstable_createCacheForType` API, which seems to accept a factory function + that holds the wrapper for a piece of data (such as a `new Map()`, or an object + with some field that has the actual value). Note: per Brian, that's not needed + for this cache implementation, since we don't need to invalidate the data later. + - React's undocumented but semi-known "throw a promise" mechanism, where we throw + a consistent promise reference if the data isn't yet available. + (Note: the promise throwing behavior may still be subject to change? per Dan, + https://github.com/facebook/react/issues/22964#issuecomment-1113649154 ) + + Brian's example uses synchronous "wakeables" - a plain JS object + with a `.then` method, plus `.resolve/reject` methods, and is doing + manual "pending/resolved" tracking. This is apparently to avoid the + overhead of promises and microtasks. + + RTKQ recently added a `getRunningOperationPromise` method, which returns + a consistent promise reference _if_ there's a request in flight for that + particular endpoint+args combination. So, we can use that as the thrown promise. + */ + +// We know there's only one cache entry, since it's a "list of things" query. +// Create a selector to read that cache entry from the Redux store. +const selectSourceGroupsEntry = api.endpoints.getSources.select(); + +// Use module-scoped variables for the cache, since this is a one-shot retrieval +let hasPromiseListenerBeenAttached = false; +let sourceGroups: SourceGroups | null = null; + +export function getSourceGroups(store: AppStore): SourceGroups { + if (sourceGroups !== null) { + return sourceGroups; + } + + let sourcesEntry: ReturnType | undefined = + selectSourceGroupsEntry(store.getState()); + + if (!sourcesEntry || sourcesEntry.isUninitialized) { + store.dispatch(api.endpoints.getSources.initiate()); + sourcesEntry = selectSourceGroupsEntry(store.getState()); + } + + const promise = api.util.getRunningOperationPromise("getSources", undefined)!; + + if (!hasPromiseListenerBeenAttached) { + hasPromiseListenerBeenAttached = true; + promise.then(result => { + sourceGroups = result.data as SourceGroups; + }); + } + + throw promise; +} diff --git a/packages/markerikson-stack-client-prototype/src/pages/index.tsx b/packages/markerikson-stack-client-prototype/src/pages/index.tsx index 6e899667f60..5a51f7fe1da 100644 --- a/packages/markerikson-stack-client-prototype/src/pages/index.tsx +++ b/packages/markerikson-stack-client-prototype/src/pages/index.tsx @@ -1,9 +1,10 @@ +import React, { Suspense, useContext } from "react"; import type { NextPage } from "next"; import styles from "../styles/Home.module.css"; import { SessionContext } from "../contexts/SessionContext"; -import { useContext } from "react"; +import Loader from "../components/Loader"; import { SourcesList } from "../features/sources/SourcesList"; import { SourceContent } from "../features/sources/SourceContent"; @@ -23,7 +24,9 @@ const IndexPage: NextPage = () => {

    Sources Entries

    - + }> + +

    Source Contents