-
Notifications
You must be signed in to change notification settings - Fork 4
Implement Client-Side Caching for Calculations #287
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
Changes from all commits
3622402
8dde600
0d6554b
46a9b20
d9a9e27
cf5a68f
d08b93f
c2a449a
7c02707
03f71ab
a4f540e
164ad10
cee6246
4bf1665
59832c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@svenvw/fdm-app": minor | ||
| --- | ||
|
|
||
| Now some application calculation results are cached in the browser local storage. The server only performs a cached calculation again if the cache's input hash doesn't match the current input's hash. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { redirect } from "react-router" | ||
| import type { Route } from "../+types/root" | ||
| import type { CacheStore, DataWithInputHash } from "../store/calculation-cache" | ||
|
|
||
| /** | ||
| * Client middleware that redirects with the most recent cacheHash, obtained from the provided cache store, when the route matches the provided matcher, if needed. | ||
| * | ||
| * @param matcherProvider function that return a regexp that matches url strings like `/farm/b_id_farm/calendar/balance/nitrogen` | ||
| * @param storeProvider cache store to use for this match | ||
| * @param getId function to oobtain the id out of the client middleware function args | ||
| * | ||
| * @returns a client middleware function that either throws redirect or calls next as needed | ||
| */ | ||
| export function splatCacheMiddleware<T extends DataWithInputHash>( | ||
| matcherProvider: () => RegExp, | ||
| storeProvider: () => CacheStore<T>, | ||
| getId: (args: Parameters<Route.ClientMiddlewareFunction>[0]) => string, | ||
| ): Route.ClientMiddlewareFunction { | ||
| return (args, next) => { | ||
| const { request } = args | ||
| if (typeof window === "undefined") return next() | ||
|
|
||
| const requestUrl = new URL(request.url) | ||
| if (!matcherProvider().test(requestUrl.pathname)) return next() | ||
|
|
||
| const previousCacheHash = requestUrl.searchParams.get("cacheHash") | ||
| let newCacheHash: string | null = previousCacheHash | ||
|
|
||
| // Get cache hash for the cache we (possibly) have | ||
| const cachedData = storeProvider().get(getId(args)) | ||
| if (cachedData?.inputHash) { | ||
| newCacheHash = cachedData.inputHash | ||
| } else { | ||
| newCacheHash = null | ||
| } | ||
|
|
||
| // Redirect if the `cacheHash` search param was wrong | ||
| if (previousCacheHash !== newCacheHash) { | ||
| newCacheHash | ||
| ? requestUrl.searchParams.set("cacheHash", newCacheHash) | ||
| : requestUrl.searchParams.delete("cacheHash") | ||
| throw redirect(requestUrl.toString()) | ||
| } | ||
|
|
||
| return next() | ||
| } | ||
| } | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,14 +11,16 @@ import { | |
| CircleAlert, | ||
| CircleCheck, | ||
| } from "lucide-react" | ||
| import { Suspense, use } from "react" | ||
| import hash from "object-hash" | ||
| import { Suspense, use, useEffect } from "react" | ||
| import { | ||
| data, | ||
| type LoaderFunctionArgs, | ||
| type MetaFunction, | ||
| NavLink, | ||
| useLoaderData, | ||
| useLocation, | ||
| useSearchParams, | ||
| } from "react-router" | ||
| import { NitrogenBalanceChart } from "~/components/blocks/balance/nitrogen-chart" | ||
| import NitrogenBalanceDetails from "~/components/blocks/balance/nitrogen-details" | ||
|
|
@@ -38,6 +40,7 @@ import { clientConfig } from "~/lib/config" | |
| import { fdm } from "~/lib/fdm.server" | ||
| import { useCalendarStore } from "~/store/calendar" | ||
| import { serverConfig } from "../lib/config.server" | ||
| import { useFieldNitrogenBalanceCache } from "../store/calculation-cache" | ||
|
|
||
| // Meta | ||
| export const meta: MetaFunction = () => { | ||
|
|
@@ -91,6 +94,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { | |
| // Get details of field | ||
| const field = await getField(fdm, session.principal_id, b_id) | ||
|
|
||
| const url = new URL(request.url) | ||
| const cacheHash = url.searchParams.get("cacheHash") | ||
|
|
||
| // Return promise directly for React Router v7 Suspense pattern | ||
| const nitrogenBalancePromise = collectInputForNitrogenBalance( | ||
| fdm, | ||
|
|
@@ -100,6 +106,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { | |
| datasetsUrl, | ||
| ) | ||
| .then(async (input) => { | ||
| const inputHash = hash(input) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are various typescript warnings with this block of code. Please check |
||
| if (inputHash === cacheHash) { | ||
| return { useCache: true } | ||
| } | ||
| const result = await calculateNitrogenBalance(input) | ||
| return { | ||
| input: input.fields.find( | ||
|
|
@@ -110,6 +120,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { | |
| (field: { b_id: string }) => field.b_id === b_id, | ||
| ), | ||
| errorMessage: null, | ||
| inputHash: inputHash, | ||
| } | ||
| }) | ||
| .catch((error) => ({ | ||
|
|
@@ -154,12 +165,38 @@ function NitrogenBalance({ | |
| field, | ||
| nitrogenBalanceResult, | ||
| }: Awaited<ReturnType<typeof loader>>) { | ||
| const { input, result, errorMessage } = use(nitrogenBalanceResult) | ||
| const data = use(nitrogenBalanceResult) | ||
|
|
||
| const location = useLocation() | ||
| const [searchParams, setSearchParams] = useSearchParams() | ||
| const page = location.pathname | ||
| const calendar = useCalendarStore((state) => state.calendar) | ||
|
|
||
| const fieldNitrogenBalanceCache = useFieldNitrogenBalanceCache() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would prefer to move this into a separate function. The idea is that we can obtain the results of this calculation on various pages with a single function that handles the input collection, cache checking, calculation and cache storage. Otherwise we have to repeat this code on every page that will use the results of the calculation |
||
|
|
||
| const cachedData = fieldNitrogenBalanceCache.get(field.b_id) | ||
|
|
||
| useEffect(() => { | ||
| if ( | ||
| (!data.useCache || !cachedData?.inputHash) && | ||
| !data.errorMessage && | ||
| data.inputHash | ||
| ) { | ||
| fieldNitrogenBalanceCache.set(field.b_id, data) | ||
| } | ||
| }, [field.b_id, data, cachedData?.inputHash, fieldNitrogenBalanceCache.set]) | ||
|
|
||
| if (data.useCache && !cachedData && searchParams.get("cacheHash")) { | ||
| setSearchParams((searchParams) => { | ||
| searchParams.delete("cacheHash") | ||
| return searchParams | ||
| }) | ||
| return null | ||
| } | ||
|
|
||
| const { input, result, errorMessage } = | ||
| data.useCache && cachedData ? cachedData : data | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if (!input) { | ||
| return ( | ||
| <div className="flex items-center justify-center"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { redirect } from "react-router" | ||
| import { splatCacheMiddleware } from "~/lib/middleware" | ||
| import { | ||
| useFarmNitrogenBalanceCache, | ||
| useFieldNitrogenBalanceCache, | ||
| } from "~/store/calculation-cache" | ||
| import type { Route } from "./+types/farm.$b_id_farm.$calendar.balance" | ||
|
|
||
| // In case the user navigated directly by URL | ||
| export function loader({ params, request }: Route.LoaderArgs) { | ||
| if (/\/balance\/?($|\?)/.test(request.url)) { | ||
| throw redirect( | ||
| `/farm/${params.b_id_farm}/${params.calendar}/balance/nitrogen`, | ||
| ) | ||
| } | ||
|
|
||
| return {} | ||
| } | ||
|
|
||
| // In case the user navigated within the application | ||
| const redirectMiddleware: Route.ClientMiddlewareFunction = ( | ||
| { request, params }, | ||
| next, | ||
| ) => { | ||
| if (/\/balance\/?($|\?)/.test(request.url)) { | ||
| throw redirect( | ||
| `/farm/${params.b_id_farm}/${params.calendar}/balance/nitrogen`, | ||
| ) | ||
| } | ||
|
|
||
| return next() | ||
| } | ||
|
|
||
| export const clientMiddleware = [ | ||
| // Redirect to nitrogen balance if what kind of balance analysis needed is not known yet | ||
| redirectMiddleware, | ||
| // Farm nitrogen | ||
| splatCacheMiddleware( | ||
| () => /\/nitrogen\/?$/, | ||
| () => useFarmNitrogenBalanceCache.getState(), | ||
| ({ params }) => params.b_id_farm || "", | ||
| ), | ||
| // Field nitrogen | ||
| splatCacheMiddleware( | ||
| () => /\/nitrogen\/.+\/?$/, | ||
| () => useFieldNitrogenBalanceCache.getState(), | ||
| ({ params }) => params.b_id || "", | ||
| ), | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React router supports middleware as a stable feature since v7.9.0. Have you considered using those functions? https://reactrouter.com/how-to/middleware