Skip to content

Commit

Permalink
Try creating an RTKQ-based Suspense cache for the sources list
Browse files Browse the repository at this point in the history
  • Loading branch information
markerikson committed Jun 17, 2022
1 parent 0976813 commit 1e93e30
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 5 deletions.
7 changes: 6 additions & 1 deletion packages/markerikson-stack-client-prototype/src/app/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import groupBy from "lodash/groupBy";
import { client, initSocket } from "protocol/socket";
import { replayClient } from "../client/ReplayClient";

interface SourceGroups {
export interface SourceGroups {
src: newSource[];
node_modules: newSource[];
other: newSource[];
Expand All @@ -23,13 +23,18 @@ const reIsJsSourceFile = /(js|ts)x?$/;

const CACHE_DATA_PERMANENTLY = 10 * 365 * 24 * 60 * 60;

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export const api = createApi({
baseQuery: fakeBaseQuery(),
endpoints: build => ({
getSources: build.query<SourceGroups, void>({
queryFn: async () => {
const sources: newSource[] = [];

// TODO Artificial delay just to see the "Loading..." for Suspense
await sleep(3000);

// Fetch the sources
client.Debugger.addNewSourceListener(source => sources.push(source));
await client.Debugger.findSources({}, replayClient.getSessionIdThrows());
Expand Down
3 changes: 2 additions & 1 deletion packages/markerikson-stack-client-prototype/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ export function makeStore() {
});
}

const store = makeStore();
export const store = makeStore();

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

export type AppDispatch = typeof store.dispatch;
export type AppStore = typeof store;

export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
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
@@ -1,12 +1,15 @@
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { useGetSourcesQuery } from "../../app/api";
// import { useGetSourcesQuery } from "../../app/api";

import { sourceEntrySelected } from "./sourcesSlice";
import { getSourceGroups } from "./sourcesCache";

export const SourcesList = () => {
const dispatch = useAppDispatch();
const selectedSourceId = useAppSelector(state => state.sources.selectedSourceId);
const { data } = useGetSourcesQuery();
// const { data } = useGetSourcesQuery();
console.log("SourcesList trying to read data via Suspense...");
const data = getSourceGroups();

return (
<ul>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { unstable_getCacheForType as getCacheForType } from "react";
import type { AppStore } from "../../app/store";

import { api, SourceGroups } from "../../app/api";

/*
* This entire file is ENTIRELY 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)
- 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.
I've copy-pasted some of the boolean flag derivation logic from the guts of
RTKQ to help figure out if the RTKQ cache entry has data available or not.
*/

let store: AppStore;

export const setStore = (_store: AppStore) => {
store = _store;
};

// Placeholder for the result given to React's Suspense Cache
interface SourceGroupsResult {
groups: SourceGroups | null;
}

// 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();

function createSourceGroupsRecord(): SourceGroupsResult {
return {
groups: null,
};
}

export function getSourceGroups(): SourceGroups {
// Create or read the cache for this section of the UI (????)
const sourceGroupsRecord = getCacheForType(createSourceGroupsRecord);

// Do we have this data already cached? If so, return it
if (sourceGroupsRecord.groups !== null) {
console.log("Found existing cached sources data, returning");
return sourceGroupsRecord.groups;
}

// RTKQ side: have we ever tried to request this data?
let sourcesEntry: ReturnType<typeof selectSourceGroupsEntry> | undefined =
selectSourceGroupsEntry(store.getState());

if (!sourcesEntry || sourcesEntry.isUninitialized) {
console.log("No RTKQ entry found, initiating request");
// If not, actually start fetching
store.dispatch(api.endpoints.getSources.initiate());

// Now we've got an entry in the store
sourcesEntry = selectSourceGroupsEntry(store.getState());
}

//Copy-pasted from the guts of RTKQ's `buildHooks.ts`,
// since this is derived state

// data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args
let data = sourcesEntry.data;

const hasData = data !== undefined;

// isFetching = true any time a request is in flight
const isFetching = sourcesEntry.isLoading;
// isLoading = true only when loading while no data is present yet (initial load with no data in the cache)
const isLoading = !hasData && isFetching;
// isSuccess = true when data is present
const isSuccess = sourcesEntry.isSuccess || (isFetching && hasData);

if (isFetching || sourcesEntry.isUninitialized) {
console.log("Getting API promise and throwing");
const promise = api.util.getRunningOperationPromise("getSources", undefined);
promise.then(result => {
// TODO What about timing? Will this happen _after_ React re-renders?
// _Looks_ like this runs first, possibly because we add a `.then` right now
// before React has a chance to add one, and those run in order added.
console.log("Data received, updating sources record");
sourceGroupsRecord.groups = result.data as SourceGroups;
});

// Let React know the data isn't available yet
throw promise;
} else if (isSuccess) {
// This mutation might be redundant, but shouldn't hurt.
sourceGroupsRecord.groups = data;
console.log("Returning existing successful data");
return data;
} else {
// TODO How do caches handle errors?
console.error("Unexpected error reading a cache entry!");
}
}
12 changes: 11 additions & 1 deletion packages/markerikson-stack-client-prototype/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import React, { Suspense } 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";

import { store } from "../app/store";
import { setStore } from "../features/sources/sourcesCache";

setStore(store);

const IndexPage: NextPage = () => {
const sessionData = useContext(SessionContext);
const { currentUserInfo } = sessionData;
Expand All @@ -23,7 +30,10 @@ 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

0 comments on commit 1e93e30

Please sign in to comment.