Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proof of concept: create an RTKQ-based React Suspense cache for the sources list #7205

Merged
merged 5 commits into from
Jul 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/markerikson-stack-client-prototype/react.d.ts
Original file line number Diff line number Diff line change
@@ -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<T>(resourceType: () => T): T;
export function unstable_useCacheRefresh(): () => void;
}
74 changes: 39 additions & 35 deletions packages/markerikson-stack-client-prototype/src/app/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SourceGroups, SessionId>({
queryFn: async (sessionId: SessionId) => {
getSources: build.query<SourceGroups, void>({
queryFn: async () => {
const sources: newSource[] = [];

// Fetch the sources
Expand Down Expand Up @@ -60,8 +69,8 @@ export const api = createApi({
},
keepUnusedDataFor: CACHE_DATA_PERMANENTLY,
}),
getSourceText: build.query<string, { sessionId: SessionId; sourceId: string }>({
queryFn: async ({ sessionId, sourceId }) => {
getSourceText: build.query<string, string>({
queryFn: async sourceId => {
const demoSourceText = await client.Debugger.getSourceContents(
{
sourceId,
Expand All @@ -72,11 +81,8 @@ export const api = createApi({
},
keepUnusedDataFor: CACHE_DATA_PERMANENTLY,
}),
getSourceHitCounts: build.query<
Dictionary<HitCount[]>,
{ sessionId: SessionId; sourceId: string }
>({
queryFn: async ({ sessionId, sourceId }) => {
getSourceHitCounts: build.query<Dictionary<HitCount[]>, string>({
queryFn: async sourceId => {
const { lineLocations } = await client.Debugger.getPossibleBreakpoints(
{
sourceId,
Expand All @@ -98,32 +104,30 @@ export const api = createApi({
},
keepUnusedDataFor: CACHE_DATA_PERMANENTLY,
}),
getLineHitPoints: build.query<PointDescription[], { location: Location; sessionId: SessionId }>(
{
queryFn: async ({ location, sessionId }) => {
const data = await new Promise<PointDescription[]>(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<PointDescription[], Location>({
queryFn: async location => {
const data = await new Promise<PointDescription[]>(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<createPauseResult, { point: PointDescription; sessionId: SessionId }>({
queryFn: async ({ point, sessionId }) => {
return { data };
},
}),
getPause: build.query<createPauseResult, PointDescription>({
queryFn: async point => {
const pause = await client.Session.createPause({ point: point.point }, sessionId);
return { data: pause };
},
Expand Down
8 changes: 4 additions & 4 deletions packages/markerikson-stack-client-prototype/src/app/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<AppDispatch>();

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppStore: () => AppStore = useStore;
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export function makeStore() {

const store = makeStore();

export type AppStore = typeof store;
export type AppState = ReturnType<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;

export type AppThunk<ReturnType = void> = ThunkAction<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.Loader {
padding: 0.25rem;
background-color: yellow;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import styles from "./Loader.module.css";

export default function Loader() {
return <div className={styles.Loader}>Loading… ⏱️</div>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<ul>
Expand All @@ -26,7 +22,7 @@ export const SourcesList = () => {
const onLineClicked = () => dispatch(sourceEntrySelected(entry.sourceId));
return (
<li key={entry.sourceId} onClick={onLineClicked}>
{entryText}
{entryText} ({entry.kind})
</li>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof selectSourceGroupsEntry> | 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;
}
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -23,7 +24,9 @@ const IndexPage: NextPage = () => {
<div style={{ display: "flex" }}>
<div style={{ minWidth: 300 }}>
<h2>Sources Entries</h2>
<SourcesList />
<Suspense fallback={<Loader />}>
<SourcesList />
</Suspense>
</div>
<div style={{ marginLeft: 10 }}>
<h2>Source Contents</h2>
Expand Down