Converts using full document state management

This commit is contained in:
2025-07-02 17:01:56 -07:00
parent 32c5c40466
commit f27432ef05
13 changed files with 468 additions and 211 deletions

View File

@@ -1,74 +0,0 @@
import { createContext, use, useContext, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { RecordCache } from "@/lib/recordCache";
import { pb } from "@/lib/pocketbase";
import { type DocumentId, type AnyDocument, CollectionIds } from "@/lib/types";
import { useQueryClient } from "@tanstack/react-query";
/**
* Context value for the record cache singleton.
*/
export type CacheContextValue = RecordCache;
const CacheContext = createContext<CacheContextValue | undefined>(undefined);
/**
* Provider for the record cache context. Provides a singleton RecordCache instance to children.
*/
export function CacheProvider({ children }: { children: ReactNode }) {
const cacheRef = useRef<RecordCache | undefined>(undefined);
if (!cacheRef.current) {
cacheRef.current = new RecordCache();
}
return (
<CacheContext.Provider value={cacheRef.current}>
{children}
</CacheContext.Provider>
);
}
/**
* Hook to access the record cache context. Throws if used outside of CacheProvider.
*/
export function useCache(): CacheContextValue {
const ctx = useContext(CacheContext);
if (!ctx) throw new Error("useCache must be used within a CacheProvider");
return ctx;
}
export function useDocument(documentId: DocumentId): AnyDocument {
const cache = useCache();
const queryClient = useQueryClient();
async function fetchItems() {
const cacheValue = cache.getDocument(documentId);
if (cacheValue) {
console.info(`Serving ${documentId} from cache.`);
return cacheValue;
}
const { doc } = await queryClient.fetchQuery({
queryKey: [CollectionIds.Documents, documentId],
queryFn: async () => {
const doc: AnyDocument = await pb
.collection("documents")
.getOne(documentId, {
expand:
"relationships_via_primary,relationships_via_primary.secondary",
});
console.info(`Saving ${documentId} to cache.`);
cache.set(doc);
return { doc };
},
});
return doc;
}
const items = useMemo(fetchItems, [documentId, cache, queryClient]);
return use(items);
}

View File

@@ -0,0 +1,75 @@
import { pb } from "@/lib/pocketbase";
import { type AnyDocument, type DocumentId } from "@/lib/types";
import type { ReactNode } from "react";
import { createContext, useContext, useEffect, useReducer } from "react";
import type { DocumentAction } from "./actions";
import { reducer } from "./reducer";
import { loading, type DocumentState } from "./state";
import { useQueryClient } from "@tanstack/react-query";
import type { RecordModel } from "pocketbase";
type DocumentContextValue = {
state: DocumentState<AnyDocument>;
dispatch: (action: DocumentAction<AnyDocument>) => void;
};
const DocumentContext = createContext<DocumentContextValue | undefined>(
undefined,
);
/**
* Provider for the record cache context. Provides a singleton RecordCache instance to children.
*/
export function DocumentProvider({
documentId,
children,
}: {
documentId: DocumentId;
children: ReactNode;
}) {
const queryClient = useQueryClient();
const [state, dispatch] = useReducer(reducer, loading());
useEffect(() => {
async function fetchDocumentAndRelations() {
const doc: AnyDocument = await queryClient.fetchQuery({
queryKey: ["document", documentId],
staleTime: 5 * 60 * 1000, // 5 mintues
queryFn: () =>
pb.collection("documents").getOne(documentId, {
expand:
"relationships_via_primary,relationships_via_primary.secondary",
}),
});
dispatch({
type: "ready",
doc,
relationships: doc.expand?.relationships_via_primary || [],
relatedDocuments:
doc.expand?.relationships_via_primary?.flatMap(
(r: RecordModel) => r.expand?.secondary,
) || [],
});
}
fetchDocumentAndRelations();
}, [documentId]);
return (
<DocumentContext.Provider
value={{
state,
dispatch,
}}
>
{children}
</DocumentContext.Provider>
);
}
export function useDocument(): DocumentContextValue {
const ctx = useContext(DocumentContext);
if (!ctx)
throw new Error("useDocument must be used within a DocumentProvider");
return ctx;
}

View File

@@ -0,0 +1,24 @@
import type { AnyDocument, Relationship } from "@/lib/types";
export type DocumentAction<D extends AnyDocument> =
| {
type: "loading";
}
| {
type: "ready";
doc: D;
relationships: Relationship[];
relatedDocuments: AnyDocument[];
}
| {
type: "update";
data: D["data"];
}
| {
type: "setRelationship";
relationship: Relationship;
}
| {
type: "setRelatedDocument";
doc: AnyDocument;
};

View File

@@ -0,0 +1,75 @@
import _ from "lodash";
import type {
AnyDocument,
DocumentId,
Relationship,
RelationshipId,
RelationshipType,
} from "@/lib/types";
import type { DocumentAction } from "./actions";
import type { DocumentState } from "./state";
function ifStatus<D extends AnyDocument, S extends DocumentState<D>["status"]>(
status: S,
state: DocumentState<D>,
newState: (state: DocumentState<D> & { status: S }) => DocumentState<D>,
) {
// TODO: Is there a better way to express the type of type narrowing?
return state.status === status
? newState(state as DocumentState<D> & { status: S })
: state;
}
export function reducer<D extends AnyDocument>(
state: DocumentState<D>,
action: DocumentAction<D>,
): DocumentState<D> {
switch (action.type) {
case "loading":
return {
status: "loading",
};
case "ready":
return {
status: "ready",
doc: action.doc,
relationships: _.keyBy(action.relationships, (r) => r.type) as Record<
RelationshipType,
Relationship
>,
relatedDocs: _.keyBy(action.relatedDocuments, (r) => r.id) as Record<
DocumentId,
AnyDocument
>,
};
case "update":
if (state.status === "ready") {
return {
...state,
doc: {
...state.doc,
data: action.data,
},
};
} else {
return state;
}
case "setRelationship":
return ifStatus("ready", state, (state) => ({
...state,
relationships: {
...state.relationships,
[action.relationship.type]: action.relationship,
},
}));
case "setRelatedDocument":
return ifStatus("ready", state, (state) => ({
...state,
relatedDocs: {
...state.relatedDocs,
[action.doc.id]: action.doc,
},
}));
}
}

View File

@@ -0,0 +1,21 @@
import type {
AnyDocument,
DocumentId,
Relationship,
RelationshipType,
} from "@/lib/types";
export type DocumentState<D extends AnyDocument> =
| {
status: "loading";
}
| {
status: "ready";
doc: D;
relationships: Record<RelationshipType, Relationship>;
relatedDocs: Record<DocumentId, AnyDocument>;
};
export const loading = <D extends AnyDocument>(): DocumentState<D> => ({
status: "loading",
});