Skip to content

Implement Client-Side Caching for Calculations#287

Closed
BoraIneviNMI wants to merge 15 commits into
developmentfrom
FDM193
Closed

Implement Client-Side Caching for Calculations#287
BoraIneviNMI wants to merge 15 commits into
developmentfrom
FDM193

Conversation

@BoraIneviNMI
Copy link
Copy Markdown
Collaborator

@BoraIneviNMI BoraIneviNMI commented Sep 30, 2025

  • New Features
    • Some routes have a client middleware that adds the input hash for a calculation result that is cached locally.
    • The server checks if this hash matches the hash of the input for the requested route.
    • If the hashes are the same the server doesn't do the expensive calculation again and instead tells the client to use its cached result instead.
    • The cached results are stored in a Zustand store backed by the browser local storage. The entire store is also versioned (with the npm package version number) so when the app is updated all the cache is reset.

Summary by CodeRabbit

  • New Features

    • Client-side persistent caching for calculations (nitrogen balance, nutrient advice, norms) with input-hash validation and per-farm/field caches.
  • Performance

    • Fewer recomputations and faster page loads when revisiting farms or fields; cached results served when valid.
  • Navigation

    • Smoother in-app routing: some breadcrumbs now use client-side links; balance index no longer auto-redirects; nutrient-advice restores last-viewed items via routing.

@BoraIneviNMI BoraIneviNMI self-assigned this Sep 30, 2025
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Sep 30, 2025

🦋 Changeset detected

Latest commit: 59832c1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@svenvw/fdm-app Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Sep 30, 2025

Walkthrough

Adds client-side caching for calculator results (norms, nitrogen balance, nutrient advice) using object-hash and persistent Zustand stores, wires cache-aware loaders/components and client middleware, updates some breadcrumb NavLink usage, and exposes PUBLIC_FDM_CALCULATOR_VERSION from an async Vite config.

Changes

Cohort / File(s) Summary
Caching infra & versioning
fdm-app/app/store/calculation-cache.ts, fdm-app/vite.config.ts, fdm-app/package.json, .changeset/rotten-rocks-argue.md
Introduces generic persistent Zustand caches (DataWithInputHash + CacheStore API) for multiple domains, exports per-domain cache hooks, adds object-hash dependency and types, and makes Vite config async to expose PUBLIC_FDM_CALCULATOR_VERSION. Changeset added.
Farm-level norms cache
fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx
Loader computes inputHash, compares to cacheHash query param and may return { useCache: true }; component uses useFarmNormsCache, persists loader results to cache, manages/clears cacheHash URL param, and exports clientMiddleware.
Nitrogen balance (farm & field)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx, fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx, fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx, fdm-app/app/routes/farm.$b_id_farm.$calendar.balance._index.tsx, fdm-app/app/components/blocks/header/balance.tsx
Loaders compute inputHash, check cacheHash and can return useCache; components switch to use(asyncData), use useFarmNitrogenBalanceCache / useFieldNitrogenBalanceCache, populate caches in useEffect, prefer cached data when indicated; balance route adds client middleware and a redirect loader; removed automatic redirect in balance index.
Nutrient advice (field + parent route)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx, fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx, fdm-app/app/components/blocks/header/nutrient-advice.tsx
Field loader computes inputHash and may return useCache; field component uses useFieldNutrientAdviceCache to persist/retrieve cached advice and clears cacheHash when serving cache; parent route adds clientMiddleware for redirect-to-cached-field and splat cache middleware; header link updated to NavLink with reload behavior.
UI breadcrumb tweaks
fdm-app/app/components/blocks/header/balance.tsx, fdm-app/app/components/blocks/header/nutrient-advice.tsx
Replace plain href BreadcrumbLink anchors with asChild + NavLink usage to enable router-driven navigation (one uses reloadDocument in nutrient-advice).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Browser
  participant Router
  participant Loader as Route Loader
  participant Server as Calculation Service
  participant Cache as Zustand Cache

  User->>Browser: Navigate to /.../route?cacheHash=Hc
  Browser->>Router: dispatch route
  Router->>Loader: call loader({ request })
  Loader->>Server: compute inputHash Hi (hash inputs)
  alt Hc == Hi
    Loader-->>Router: return { useCache: true, inputHash: Hi }
  else
    Loader->>Server: run calculation -> result R
    Server-->>Loader: return R
    Loader-->>Router: return { result: R, inputHash: Hi }
  end

  Router->>Cache: get cached[scope][id] -> Rc
  alt useCache == true and Rc exists
    Router-->>Browser: render Rc (cached)
    Browser->>Router: client clears `cacheHash` from URL
  else
    Router-->>Browser: render R (fresh)
    Browser->>Cache: set cached[scope][id] = { data: R, inputHash: Hi }
  end
Loading
sequenceDiagram
  autonumber
  actor User
  participant Router
  participant ClientMW as Client Middleware
  participant Cache as Cache Store

  User->>Router: Open /farm/:id/calendar/...
  Router->>ClientMW: run clientMiddleware()
  alt route is balance root
    ClientMW-->>Router: redirect to /.../balance/nitrogen
  else route matches cache predicate
    ClientMW->>Cache: get cached inputHash Hi for scope
    alt cached exists and cacheHash != Hi
      ClientMW-->>Router: redirect to same URL with cacheHash=Hi
    else
      ClientMW-->>Router: continue navigation
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • SvenVw
  • gerardhros

Poem

I’m a rabbit with a tiny stash of hashes,
I hop and guard your computed caches.
I nibble inputs, store the sum,
So pages leap and load more mum—
Carrots, cache, and speedy flashes! 🐇🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly captures the primary change in the pull request by clearly stating the implementation of client-side caching for calculation results, matching the PR’s focus on caching logic without unnecessary detail or noise.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch FDM193

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4bf1665 and 59832c1.

📒 Files selected for processing (1)
  • fdm-app/app/components/blocks/header/nutrient-advice.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • fdm-app/app/components/blocks/header/nutrient-advice.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Sep 30, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.07%. Comparing base (94f49fb) to head (59832c1).

Additional details and impacted files
@@             Coverage Diff              @@
##           development     #287   +/-   ##
============================================
  Coverage        93.07%   93.07%           
============================================
  Files               81       81           
  Lines            13118    13118           
  Branches          1326     1326           
============================================
  Hits             12209    12209           
  Misses             907      907           
  Partials             2        2           
Flag Coverage Δ
fdm-calculator 94.65% <ø> (ø)
fdm-core 91.92% <ø> (ø)
fdm-data 94.40% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@BoraIneviNMI BoraIneviNMI linked an issue Sep 30, 2025 that may be closed by this pull request
@BoraIneviNMI
Copy link
Copy Markdown
Collaborator Author

@coderabbitai can you help me with getting the npm package version for fdm-calculator or the entire project in createCache() in calculation-cache.ts

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 3, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the calculation-cache.ts file
echo "=== Finding calculation-cache.ts ==="
fd -t f "calculation-cache.ts"

echo -e "\n=== Reading calculation-cache.ts ==="
fd -t f "calculation-cache.ts" --exec cat {}

echo -e "\n=== Finding and reading package.json files ==="
fd -t f "^package.json$" --exec echo "File: {}" \; --exec cat {} \; --exec echo "" \;

echo -e "\n=== Checking project structure ==="
ls -la

Length of output: 17724


@BoraIneviNMI, I can help you access the npm package version in createCache(). Here's the solution:

Implementation

Since calculation-cache.ts is in the fdm-app package, you'll want to import the version from fdm-app/package.json. Here's how to modify your code:

import { create } from "zustand"
import { persist } from "zustand/middleware"
import packageJson from "../../package.json"

export interface DataWithInputHash {
    inputHash?: string | undefined
}

export interface CacheStore<T extends DataWithInputHash> {
    db: Record<string, T>
    get: (id: string) => T | undefined
    set: (id: string, val: T) => void
}

function createCache<T extends DataWithInputHash>(name: string) {
    return create(
        persist<CacheStore<T>>(
            (_set, _get) => ({
                db: {},
                get: (id) => _get().db[id],
                set: (id, val) => _set({ db: { ..._get().db, [id]: val } }),
            }),
            { 
                name,
                version: packageJson.version, // This will be "0.23.0"
            },
        ),
    )
}

export const useFarmNormsCache = createCache("farm-norms-cache")
export const useFarmNitrogenBalanceCache = createCache(
    "farm-nitrogen-balance-cache",
)
export const useFieldNitrogenBalanceCache = createCache(
    "field-nitrogen-balance-cache",
)

How it works

  1. The persist middleware from Zustand accepts a version option
  2. When the version changes, Zustand will automatically invalidate the old cache and start fresh
  3. By using packageJson.version (currently "0.23.0"), the cache will reset whenever you bump the version in package.json

TypeScript Configuration

Make sure your tsconfig.json allows JSON imports. You may need to add:

{
  "compilerOptions": {
    "resolveJsonModule": true,
    "esModuleInterop": true
  }
}

This approach ensures that every time you release a new version of fdm-app, all cached calculations will be automatically invalidated, which aligns perfectly with your PR objectives!

@BoraIneviNMI
Copy link
Copy Markdown
Collaborator Author

@coderabbitai what if only fdm-calculator version is bumped? It can alter calculation results even if fdm-app is unchanged. Does fdm-app get bumped too when fdm-calculator gets bumped, since fdm-app depends on it?

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 3, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

echo "=== Checking fdm-app's dependency on fdm-calculator ==="
cat packages/fdm-app/package.json | jq '.dependencies["@nmi/fdm-calculator"]'

echo -e "\n=== Checking root package.json for monorepo tooling ==="
cat package.json | jq '{workspaces, scripts, devDependencies: (.devDependencies | keys | map(select(. | contains("lerna") or contains("changeset") or contains("version"))))}'

echo -e "\n=== Looking for versioning/release configuration files ==="
fd -t f "(lerna.json|\.changeset|version|release)" -d 2

echo -e "\n=== Checking fdm-calculator package.json location ==="
fd -t f "package.json" | grep fdm-calculator

echo -e "\n=== Reading fdm-calculator package.json ==="
cat packages/fdm-calculator/package.json | jq '{name, version}'

echo -e "\n=== Checking if there's a versioning strategy documented ==="
rg -i "version|bump|release" -g "*.md" -C 2 | head -50

Length of output: 6183


🏁 Script executed:

#!/bin/bash

echo "=== Reading fdm-app package.json ==="
cat fdm-app/package.json | jq '{name, version, dependencies: .dependencies}'

echo -e "\n=== Reading fdm-calculator package.json ==="
cat fdm-calculator/package.json | jq '{name, version}'

echo -e "\n=== Checking calculation-cache.ts location ==="
fd -t f "calculation-cache.ts"

echo -e "\n=== Reading calculation-cache.ts ==="
fd -t f "calculation-cache.ts" --exec cat {}

Length of output: 3405


@BoraIneviNMI, excellent question! You've identified a critical issue with the caching strategy.

The Problem

Looking at the versions:

  • fdm-app: 0.23.0
  • fdm-calculator: 0.7.0

The project uses changesets for independent package versioning. This means fdm-calculator can be bumped (e.g., from 0.7.0 to 0.7.1) without changing fdm-app's version. Since fdm-app depends on fdm-calculator using the workspace protocol ("workspace:^"), when fdm-calculator is updated, fdm-app will use the new version at build time without requiring an fdm-app version bump.

This means if we only use fdm-app's version for cache invalidation, the cache won't be cleared when fdm-calculator changes, leading to stale calculation results.

The Solution

Use fdm-calculator's version for cache invalidation instead, since it's the actual engine performing the calculations:

import { create } from "zustand"
import { persist } from "zustand/middleware"
import calculatorPackage from "@svenvw/fdm-calculator/package.json"

export interface DataWithInputHash {
    inputHash?: string | undefined
}

export interface CacheStore<T extends DataWithInputHash> {
    db: Record<string, T>
    get: (id: string) => T | undefined
    set: (id: string, val: T) => void
}

function createCache<T extends DataWithInputHash>(name: string) {
    return create(
        persist<CacheStore<T>>(
            (_set, _get) => ({
                db: {},
                get: (id) => _get().db[id],
                set: (id, val) => _set({ db: { ..._get().db, [id]: val } }),
            }),
            { 
                name,
                version: calculatorPackage.version, // Currently "0.7.0"
            },
        ),
    )
}

export const useFarmNormsCache = createCache("farm-norms-cache")
export const useFarmNitrogenBalanceCache = createCache(
    "farm-nitrogen-balance-cache",
)
export const useFieldNitrogenBalanceCache = createCache(
    "field-nitrogen-balance-cache",
)

Why This Works

  1. Any bump to fdm-calculator (even patch versions like 0.7.00.7.1) will invalidate all caches
  2. When fdm-app is rebuilt/redeployed, it will bundle the new fdm-calculator version
  3. The cache version will automatically update to match the new calculator version
  4. All clients will have their caches cleared on the next app load

This ensures calculation results are always fresh when the calculation engine changes, regardless of whether fdm-app's version was bumped!


🧠 Learnings used
Learnt from: SvenVw
PR: SvenVw/fdm#0
File: :0-0
Timestamp: 2025-08-13T10:33:05.313Z
Learning: In the fdm project, fdm-calculator integration for new features like b_lu_variety is handled in separate updates from the core data model changes. When fdm-core functions are updated to support new fields, fdm-calculator can consume these enhanced APIs without requiring changes in the same PR that introduces the core functionality.

@BoraIneviNMI BoraIneviNMI marked this pull request as ready for review October 6, 2025 11:11
@coderabbitai coderabbitai Bot added branch:development Issue only affecting development, not the main branch (yet) dependencies Pull requests that update a dependency file enhancement New feature or request fdm-app labels Oct 6, 2025
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (1)

176-189: Prevent crash when cacheHash exists but cache is empty

Directly visited URLs can carry cacheHash. On a fresh browser (or SSR) there’s no persisted entry yet, so cachedData is undefined while data.useCache === true. The ternary falls back to data, leaving resolvedNitrogenBalanceResult undefined, and the very next use (resolvedNitrogenBalanceResult.fields) throws at runtime. Guard this case (e.g. detect data.useCache && !cachedData, clear the cacheHash and re-run the loader, or have the loader fall back to recomputing) before destructuring so the page can render instead of crashing.

🧹 Nitpick comments (4)
fdm-app/package.json (1)

55-55: Pin object-hash for deterministic cache keys

Hash output is part of your cache key contract. Pin to 3.0.0 to avoid accidental changes.

-        "object-hash": "^3.0.0",
+        "object-hash": "3.0.0",

If you rely on a lockfile for determinism across environments, you may keep the caret. Otherwise, pin.

fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx (1)

125-149: Redirect picks arbitrary cached field; consider MRU selection

Using Object.keys(db)[0] is arbitrary. Prefer the most recently used/updated field id for a better UX.

  • Track last-used field id in the cache store and use it here.
  • Fallback to any key when MRU is absent.

Also verify the persist key for this cache: calculation-cache.ts shows "field-norms-cache" for useFieldNutrientAdviceCache — that likely collides with norms. Use a distinct key (e.g., "field-nutrient-advice-cache"). Based on learnings

fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (2)

80-82: Minor: include inputHash in the short‑circuit path

Returning { useCache: true } omits inputHash. Including it can simplify client logic/telemetry without recomputation.

-                if (inputHash === cacheHash) {
-                    return { useCache: true }
-                }
+                if (inputHash === cacheHash) {
+                    return { useCache: true, inputHash }
+                }

234-247: Add asyncData to useEffect deps and destructure set for stability

  • Move the const asyncData = … declaration above the effect and include asyncData in its dependency array.
  • Destructure set from useFieldNutrientAdviceCache() and use set(field.b_id, asyncData) instead of fieldNutrientAdviceCache.set.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 19a512d and c2a449a.

⛔ Files ignored due to path filters (2)
  • fdm-app/app/lib/middleware.ts is excluded by !fdm-app/app/lib/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • .changeset/rotten-rocks-argue.md (1 hunks)
  • fdm-app/app/components/blocks/header/balance.tsx (1 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance._index.tsx (0 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx (1 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx (3 hunks)
  • fdm-app/app/store/calculation-cache.ts (1 hunks)
  • fdm-app/package.json (2 hunks)
  • fdm-app/vite.config.ts (3 hunks)
💤 Files with no reviewable changes (1)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance._index.tsx
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2024-11-25T12:42:32.783Z
Learnt from: SvenVw
PR: SvenVw/fdm#6
File: fdm-app/vite.config.ts:5-9
Timestamp: 2024-11-25T12:42:32.783Z
Learning: In the `fdm-app` project, SvenVw is preparing for migration to Remix v3 and may include type declarations or configurations for v3 features in advance, such as in `vite.config.ts`.

Applied to files:

  • fdm-app/vite.config.ts
📚 Learning: 2024-12-16T10:56:07.561Z
Learnt from: SvenVw
PR: SvenVw/fdm#16
File: fdm-app/app/routes/app.addfarm.$b_id_farm.cultivations.$b_lu_catalogue.fertilizers.tsx:1-1
Timestamp: 2024-12-16T10:56:07.561Z
Learning: The project uses `react-router` v7, and the `data` function is exported and used for error handling in loaders and actions.

Applied to files:

  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx
📚 Learning: 2025-01-14T16:06:24.294Z
Learnt from: SvenVw
PR: SvenVw/fdm#45
File: fdm-app/app/routes/farm.$b_id_farm._index.tsx:1-1
Timestamp: 2025-01-14T16:06:24.294Z
Learning: In the fdm-app codebase, the `redirect` function should be imported from `react-router`, not `react-router-dom`.

Applied to files:

  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx
📚 Learning: 2025-09-23T10:02:32.123Z
Learnt from: BoraIneviNMI
PR: SvenVw/fdm#272
File: fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fertilizers.$b_lu_catalogue.manage.$p_id.tsx:151-164
Timestamp: 2025-09-23T10:02:32.123Z
Learning: The getFertilizer function from svenvw/fdm-core throws an exception if the fertilizer doesn't exist, rather than returning null or undefined.

Applied to files:

  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx
📚 Learning: 2025-09-23T12:37:58.711Z
Learnt from: SvenVw
PR: SvenVw/fdm#274
File: fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx:113-148
Timestamp: 2025-09-23T12:37:58.711Z
Learning: In the FDM application, the current field data fetching implementation using Promise.all with individual API calls (getCultivations, getFertilizerApplications, getCurrentSoilData) performs acceptably even with farms containing 90+ fields. No performance issues have been observed in practice with this approach.

Applied to files:

  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx
🧬 Code graph analysis (6)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx (3)
fdm-app/app/store/calculation-cache.ts (1)
  • useFieldNutrientAdviceCache (37-37)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx (1)
  • clientMiddleware (35-50)
fdm-app/app/lib/middleware.ts (1)
  • splatCacheMiddleware (14-47)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (1)
fdm-app/app/store/calculation-cache.ts (1)
  • useFieldNutrientAdviceCache (37-37)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx (5)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx (2)
  • loader (49-123)
  • clientMiddleware (151-160)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (1)
  • loader (55-121)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx (1)
  • loader (57-136)
fdm-app/app/lib/middleware.ts (1)
  • splatCacheMiddleware (14-47)
fdm-app/app/store/calculation-cache.ts (2)
  • useFarmNitrogenBalanceCache (31-33)
  • useFieldNitrogenBalanceCache (34-36)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (2)
fdm-app/app/store/field-filter.ts (1)
  • useFieldFilterStore (10-24)
fdm-app/app/store/calculation-cache.ts (1)
  • useFarmNitrogenBalanceCache (31-33)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx (2)
fdm-app/app/store/calendar.tsx (1)
  • useCalendarStore (11-15)
fdm-app/app/store/calculation-cache.ts (1)
  • useFieldNitrogenBalanceCache (34-36)
fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (3)
fdm-calculator/src/norms/index.ts (1)
  • createFunctionsForNorms (7-21)
fdm-app/app/store/calculation-cache.ts (1)
  • useFarmNormsCache (30-30)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx (1)
  • clientMiddleware (35-50)
🪛 Biome (2.1.2)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx

[error] 234-234: This hook does not specify its dependency on asyncData.

This dependency is being used here, but is not specified in the hook dependency list.

Unsafe fix: Add the missing dependency to the list.

(lint/correctness/useExhaustiveDependencies)

🔇 Additional comments (5)
fdm-app/app/components/blocks/header/balance.tsx (2)

33-37: Well-implemented client-side navigation with asChild pattern.

The change correctly uses the asChild prop to compose BreadcrumbLink with NavLink, enabling React Router's client-side navigation while preserving the breadcrumb's semantic structure and styling. This pattern is the recommended approach for integrating React Router with Radix UI/shadcn components.


34-34: Calendar state never undefined Initialized to current year and setter falls back to it, so calendar is always defined.

fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx (1)

151-160: Regex guard looks correct for field-level route

The splatCacheMiddleware matcher //nutrient_advice/.+/?$/ correctly targets the field variant.

fdm-app/package.json (1)

97-97: No changes needed for @types/object-hash import
Version ^3.0.6 is published and esModuleInterop is enabled, so import hash from "object-hash" works as expected.

fdm-app/app/store/calculation-cache.ts (1)

14-27: Make persist storage SSR-safe

persist defaults to localStorage; during SSR (all loaders import this module) localStorage is undefined and the store creation throws. Mirror the approach in useFieldFilterStore: import createJSONStorage and ssrSafeJSONStorage, and pass storage: createJSONStorage(() => ssrSafeJSONStorage) (or an equivalent noop storage) so the cache initializes safely on the server.

-import { persist } from "zustand/middleware"
+import { createJSONStorage, persist } from "zustand/middleware"
+import { ssrSafeJSONStorage } from "~/lib/ssr-safe-storage"
@@
         persist<CacheStore<T>>(
             (_set, _get) => ({
                 db: {},
                 get: (id) => _get().db[id],
                 set: (id, val) => _set({ db: { ..._get().db, [id]: val } }),
             }),
             {
                 name,
                 version: `fdm-calculator:${PUBLIC_FDM_CALCULATOR_VERSION}`,
+                storage: createJSONStorage(() => ssrSafeJSONStorage),
             },
         ),
     )
⛔ Skipped due to learnings
Learnt from: SvenVw
PR: SvenVw/fdm#272
File: fdm-app/app/store/field-fertilizer-form.tsx:45-47
Timestamp: 2025-09-25T15:10:59.708Z
Learning: In the FDM application, Zustand stores with persist middleware using sessionStorage/localStorage don't require SSR hardening guards. The existing store patterns in fdm-app work without typeof window checks or memory storage fallbacks, as evidenced by the changelog store using createJSONStorage(() => localStorage) directly.
Learnt from: SvenVw
PR: SvenVw/fdm#272
File: fdm-app/app/store/field-fertilizer-form.tsx:45-47
Timestamp: 2025-09-25T15:10:59.708Z
Learning: In the FDM application, Zustand stores with persist middleware using sessionStorage/localStorage don't require SSR hardening guards. The existing store patterns in fdm-app work without typeof window checks or memory storage fallbacks.

Comment thread fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx
Comment thread fdm-app/vite.config.ts
Comment on lines +12 to +21
export default defineConfig(async (config) => {
// We need to go one directory up since package.json is not inside the dist folder
const fdmCalculatorPackageJsonPath = new URL(
"../package.json",
import.meta.resolve("@svenvw/fdm-calculator"),
)
const fdmCalculatorPackage = JSON.parse(
await fs.readFile(fdmCalculatorPackageJsonPath, { encoding: "utf-8" }),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard package.json resolution/read with fallback

A failure in import.meta.resolve/readFile will crash the build. Wrap in try/catch and fall back to a default string.

-export default defineConfig(async (config) => {
-    // We need to go one directory up since package.json is not inside the dist folder
-    const fdmCalculatorPackageJsonPath = new URL(
-        "../package.json",
-        import.meta.resolve("@svenvw/fdm-calculator"),
-    )
-    const fdmCalculatorPackage = JSON.parse(
-        await fs.readFile(fdmCalculatorPackageJsonPath, { encoding: "utf-8" }),
-    )
+export default defineConfig(async (config) => {
+    // Resolve calculator version from its package.json; fall back safely
+    let calculatorVersion = "0.7.0"
+    try {
+        const pkgUrl = new URL(
+            "../package.json",
+            import.meta.resolve("@svenvw/fdm-calculator"),
+        )
+        const pkgJson = JSON.parse(
+            await fs.readFile(pkgUrl, { encoding: "utf-8" }),
+        )
+        if (typeof pkgJson?.version === "string") calculatorVersion = pkgJson.version
+    } catch {
+        // keep default fallback
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default defineConfig(async (config) => {
// We need to go one directory up since package.json is not inside the dist folder
const fdmCalculatorPackageJsonPath = new URL(
"../package.json",
import.meta.resolve("@svenvw/fdm-calculator"),
)
const fdmCalculatorPackage = JSON.parse(
await fs.readFile(fdmCalculatorPackageJsonPath, { encoding: "utf-8" }),
)
export default defineConfig(async (config) => {
// Resolve calculator version from its package.json; fall back safely
let calculatorVersion = "0.7.0"
try {
const pkgUrl = new URL(
"../package.json",
import.meta.resolve("@svenvw/fdm-calculator"),
)
const pkgJson = JSON.parse(
await fs.readFile(pkgUrl, { encoding: "utf-8" }),
)
if (typeof pkgJson?.version === "string") calculatorVersion = pkgJson.version
} catch {
// keep default fallback
}
// …rest of your config, now using calculatorVersion…
🤖 Prompt for AI Agents
fdm-app/vite.config.ts around lines 12 to 21: the import.meta.resolve and
fs.readFile calls can throw and currently will crash the build; wrap the
resolution and file-read in a try/catch, and on error assign a safe fallback
(e.g. a default package JSON string/object or "{}" parsed) to
fdmCalculatorPackage so the build continues; ensure both import.meta.resolve and
the await fs.readFile are inside the try block and that any caught error is
silently handled or logged, then parse/use the fallback value.

Comment thread fdm-app/vite.config.ts
Comment on lines +49 to +51
PUBLIC_FDM_CALCULATOR_VERSION: JSON.stringify(
fdmCalculatorPackage.version || "0.7.0",
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Prefer defining an env var instead of a bare global

If app code reads import.meta.env.PUBLIC_FDM_CALCULATOR_VERSION, define that key to avoid TS/global typing issues.

-            PUBLIC_FDM_CALCULATOR_VERSION: JSON.stringify(
-                fdmCalculatorPackage.version || "0.7.0",
-            ),
+            "import.meta.env.PUBLIC_FDM_CALCULATOR_VERSION": JSON.stringify(
+                calculatorVersion
+            ),

Please confirm the code references this as import.meta.env.PUBLIC_FDM_CALCULATOR_VERSION (not a bare identifier). If it uses the bare name, keep both defines for a transitional period.


🏁 Script executed:

#!/bin/bash
# Find usages of the calculator version in app code
rg -n -C2 'PUBLIC_FDM_CALCULATOR_VERSION|import\.meta\.env\.PUBLIC_FDM_CALCULATOR_VERSION' --type=ts --type=tsx

Length of output: 27


🏁 Script executed:

#!/bin/bash
rg -n -C2 'PUBLIC_FDM_CALCULATOR_VERSION|import\.meta\.env\.PUBLIC_FDM_CALCULATOR_VERSION' -g '*.ts' -g '*.tsx' -g '*.js' -g '*.jsx'

Length of output: 653


Preserve both env var definitions for transition
The app code in fdm-app/app/store/calculation-cache.ts (line 24) uses the bare PUBLIC_FDM_CALCULATOR_VERSION. To avoid runtime errors, retain the existing PUBLIC_FDM_CALCULATOR_VERSION define alongside the new import.meta.env.PUBLIC_FDM_CALCULATOR_VERSION (or update all references to use import.meta.env.PUBLIC_FDM_CALCULATOR_VERSION).

🤖 Prompt for AI Agents
In fdm-app/vite.config.ts around lines 49 to 51, preserve backward compatibility
by defining both PUBLIC_FDM_CALCULATOR_VERSION and
import.meta.env.PUBLIC_FDM_CALCULATOR_VERSION in the Vite define map so existing
code that references PUBLIC_FDM_CALCULATOR_VERSION continues to work while new
code can use import.meta.env.PUBLIC_FDM_CALCULATOR_VERSION; set both to the same
value (e.g., JSON.stringify(fdmCalculatorPackage.version || "0.7.0")).

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (1)

369-375: Stale cacheHash guard handled correctly.

This addresses the earlier concern about { useCache: true } without a local cache by removing the hash and short‑circuiting the render.

fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx (1)

189-195: Resolved: handle { useCache: true } without cached payload.

Removing cacheHash and returning null avoids the incorrect “invalid field” branch. This addresses the earlier concern.

🧹 Nitpick comments (6)
fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (3)

153-158: Pin object-hash options for stability (algorithm/encoding, optional unordered).

Defaults can change across versions; make hashes explicit and consistent across routes.

Based on learnings

-                const inputs = await Promise.all(inputPromises)
-                inputHash = hash(inputs)
+                const inputs = await Promise.all(inputPromises)
+                inputHash = hash(inputs, { algorithm: "sha1", encoding: "hex" })
                 if (inputHash === cacheHash) {
                     return { useCache: true }
                 }

159-166: Destructure field only after error check.

Avoid reading from potentially undefined shapes and reduce confusion.

-                const fieldNormPromises = inputs.map(async (input) => {
-                    const { field } = input
-                    if (input.errorMessage) {
-                        return input
-                    }
+                const fieldNormPromises = inputs.map(async (input) => {
+                    if (input.errorMessage) {
+                        return input
+                    }
+                    const { field } = input

276-310: Client middleware looks good; consider reusing the shared helper.

Logic is solid and fixes stale cacheHash. Prefer splatCacheMiddleware from app/lib/middleware.ts for consistency/DRY if it fits this route’s shape.

fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (1)

128-136: Make hash deterministic by setting options explicitly.

Set algorithm and encoding (and keep consistent across routes).

Based on learnings

-                const inputHash = hash([
+                const inputHash = hash([
                     resolvedCurrentSoilData,
                     resolvedFertilizerApplications,
                     resolvedFertilizers,
                     b_lu_catalogue,
-                ])
+                ], { algorithm: "sha1", encoding: "hex" })
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (1)

96-100: Explicitly configure object-hash for consistent cache keys.

Align with other routes; set algorithm/encoding.

Based on learnings

-        const inputHash = hash(nitrogenBalanceInput)
+        const inputHash = hash(nitrogenBalanceInput, {
+            algorithm: "sha1",
+            encoding: "hex",
+        })
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx (1)

109-113: Stabilize hash function options.

Pin algorithm and encoding to avoid future default changes.

Based on learnings

-            const inputHash = hash(input)
+            const inputHash = hash(input, { algorithm: "sha1", encoding: "hex" })
             if (inputHash === cacheHash) {
                 return { useCache: true }
             }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c2a449a and 7c02707.

📒 Files selected for processing (5)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx (1 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-23T12:37:58.711Z
Learnt from: SvenVw
PR: SvenVw/fdm#274
File: fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx:113-148
Timestamp: 2025-09-23T12:37:58.711Z
Learning: In the FDM application, the current field data fetching implementation using Promise.all with individual API calls (getCultivations, getFertilizerApplications, getCurrentSoilData) performs acceptably even with farms containing 90+ fields. No performance issues have been observed in practice with this approach.

Applied to files:

  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx
🧬 Code graph analysis (4)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (1)
fdm-app/app/store/calculation-cache.ts (1)
  • useFarmNitrogenBalanceCache (31-33)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx (2)
fdm-app/app/store/calendar.tsx (1)
  • useCalendarStore (11-15)
fdm-app/app/store/calculation-cache.ts (1)
  • useFieldNitrogenBalanceCache (34-36)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (1)
fdm-app/app/store/calculation-cache.ts (1)
  • useFieldNutrientAdviceCache (37-37)
fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (3)
fdm-calculator/src/norms/index.ts (1)
  • createFunctionsForNorms (7-21)
fdm-app/app/store/calculation-cache.ts (1)
  • useFarmNormsCache (30-30)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx (1)
  • clientMiddleware (34-49)
🪛 Biome (2.1.2)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx

[error] 236-236: This hook does not specify its dependency on asyncData.

This dependency is being used here, but is not specified in the hook dependency list.

Unsafe fix: Add the missing dependency to the list.

(lint/correctness/useExhaustiveDependencies)

🔇 Additional comments (2)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (1)

251-261: Good defensive handling of stale cacheHash.

Clearing the param and short‑circuiting avoids rendering with incomplete data.

fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx (1)

178-185: Stale cacheHash guard is correct.

Clears the query param and avoids rendering stale state.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (2)

81-82: Caching logic is sound; consider SHA-256 for future-proofing.

The implementation correctly extracts cacheHash from the URL, computes an inputHash from the resolved input data, and performs an early return when they match, avoiding redundant calculations. This pattern effectively leverages client-side caching to improve performance.

Optional enhancement: The object-hash library defaults to SHA-1. While SHA-1 is acceptable for cache invalidation (non-cryptographic use), you could specify SHA-256 for better collision resistance and future-proofing:

 const inputHash = hash([
     resolvedCurrentSoilData,
     resolvedFertilizerApplications,
     resolvedFertilizers,
     b_lu_catalogue,
-])
+], { algorithm: 'sha256' })

Based on learnings.

Also applies to: 128-136, 156-156


263-265: Type cast is pragmatic but could be more explicit.

The type cast as typeof serverAsyncData assumes cachedData has a compatible shape with the full server response. While this works in practice (the cache stores previously received server data), TypeScript can't verify this structural compatibility at compile time.

For better type safety, consider defining explicit union types for the different response shapes:

type AsyncDataResponse = 
  | { useCache: true }
  | { nutrientAdvice: ..., doses: ..., inputHash: string, errorMessage?: undefined }
  | { errorMessage: string }

// Then in the loader:
const asyncData: Promise<AsyncDataResponse> = (async () => { ... })()

// And in the component:
const asyncData: AsyncDataResponse = (
    serverAsyncData.useCache && cachedData ? cachedData : serverAsyncData
)

This makes the different response shapes explicit and allows TypeScript to catch shape mismatches.

fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (1)

136-151: Consider whether error messages should affect the inputHash.

The current implementation includes the entire input object (including errorMessage when errors occur) in the inputHash computation at line 154. This means that if the same field fails with a slightly different error message on subsequent visits, the inputHash will differ and the cache will be invalidated, even though the underlying field data might be identical.

If error messages are deterministic and stable (e.g., always "Missing soil type"), this is fine. However, if they include timestamps, request IDs, or other volatile information, consider excluding them from the hash computation or normalizing errors before hashing.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7c02707 and 164ad10.

📒 Files selected for processing (3)
  • fdm-app/app/components/blocks/header/nutrient-advice.tsx (1 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (6 hunks)
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (6 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-23T12:37:58.711Z
Learnt from: SvenVw
PR: SvenVw/fdm#274
File: fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx:113-148
Timestamp: 2025-09-23T12:37:58.711Z
Learning: In the FDM application, the current field data fetching implementation using Promise.all with individual API calls (getCultivations, getFertilizerApplications, getCurrentSoilData) performs acceptably even with farms containing 90+ fields. No performance issues have been observed in practice with this approach.

Applied to files:

  • fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx
🧬 Code graph analysis (3)
fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (1)
fdm-app/app/store/calculation-cache.ts (1)
  • useFieldNutrientAdviceCache (37-37)
fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (3)
fdm-calculator/src/norms/index.ts (1)
  • createFunctionsForNorms (7-21)
fdm-app/app/store/calculation-cache.ts (1)
  • useFarmNormsCache (30-30)
fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx (1)
  • clientMiddleware (34-49)
fdm-app/app/components/blocks/header/nutrient-advice.tsx (1)
fdm-app/app/components/ui/breadcrumb.tsx (1)
  • BreadcrumbLink (111-111)
🔇 Additional comments (7)
fdm-app/app/components/blocks/header/nutrient-advice.tsx (1)

33-39: LGTM! Client-side navigation enables cache optimization.

The refactor to use NavLink with asChild composition is correct and aligns well with the PR's client-side caching objectives. Client-side navigation allows the cache middleware to intercept requests without full page reloads, improving UX and enabling the hash-based cache checks.

The asChild pattern properly delegates rendering to the NavLink component, which is the standard Radix UI composition approach for custom navigation components.

fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx (2)

230-261: Defensive cache handling is well-implemented.

The defensive check on lines 251-261 correctly handles the edge case where serverAsyncData.useCache is true but cachedData is missing (e.g., browser history link with stale cacheHash, cleared localStorage). Removing the cacheHash parameter and returning null triggers a re-render with fresh data fetched from the server.

The useEffect (lines 236-249) appropriately caches fresh server data when the server didn't use the cache, ensuring future requests can benefit from the cached result.

Note: Past review comments suggested moving the asyncData computation before the useEffect and including it in the dependency array. However, since the current useEffect uses serverAsyncData (line 242) rather than asyncData, there's no temporal dead zone (TDZ) risk, and the implementation works correctly within the middleware contract. The current approach is acceptable.


9-10: LGTM! Imports and overall structure are well-organized.

The imports are appropriate for the client-side caching implementation:

  • object-hash for deterministic input hashing
  • useEffect for cache lifecycle management
  • useSearchParams for defensive cache parameter handling
  • useFieldNutrientAdviceCache from the shared calculation-cache store

The overall structure cleanly separates concerns: the loader computes the input hash and decides whether to use cached data, while the component manages the cache lifecycle and fallback scenarios.

Also applies to: 16-16, 33-33

fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx (4)

7-7: LGTM!

The new imports support the caching implementation correctly.

Also applies to: 14-14, 17-17, 36-36, 38-38


276-310: LGTM!

The client middleware correctly synchronizes the cacheHash URL parameter with the cached data. The SSR check, null safety, and redirect logic are all appropriate.


369-375: LGTM!

This correctly handles the case where the server instructs the client to use cache (data.useCache) but the client has no cached data. Removing the cacheHash parameter and returning null triggers a re-render, and the client middleware will then redirect to a clean URL without the hash, prompting the server to compute fresh data.

✅ This addresses the previous review comment about guarding against cacheHash without local cache.


377-386: Verify data selection logic is safe after fixing the missing inputHash issue.

The data selection logic correctly chooses cached data when data.useCache && cachedData, and falls back to fresh data otherwise. However, this assumes that when data.useCache is true, either cachedData exists or lines 369-375 have already handled the missing cache case.

After applying the fix for the missing inputHash in the loader's early return (see review comment on lines 155-157), this logic should work correctly.

Comment on lines +155 to +157
if (inputHash === cacheHash) {
return { useCache: true }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Include inputHash in the early return payload.

When returning early due to a cache hit, the payload only contains { useCache: true }. However, the Norms component at line 363 checks data.inputHash, and the useEffect at line 361 also references it. Without inputHash in the payload, these checks may behave unexpectedly.

Apply this diff to include inputHash in the early return:

                 inputHash = hash(inputs)
                 if (inputHash === cacheHash) {
-                    return { useCache: true }
+                    return { useCache: true, inputHash }
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (inputHash === cacheHash) {
return { useCache: true }
}
inputHash = hash(inputs)
if (inputHash === cacheHash) {
return { useCache: true, inputHash }
}
🤖 Prompt for AI Agents
In fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx around lines 155 to
157, the early return on cache hit only returns { useCache: true } but the
component expects data.inputHash; update the return to include inputHash (e.g.,
return { useCache: true, inputHash }) so the Norms component and its useEffect
can read the hash; ensure the inputHash variable is in scope and returned
whenever you short-circuit on cache.

Comment on lines +359 to +367
useEffect(() => {
if (
(!data.useCache || !cachedData?.inputHash) &&
!data.errorMessage &&
data.inputHash
) {
farmNormsCache.set(loaderData.b_id_farm, data)
}
}, [loaderData.b_id_farm, data, cachedData?.inputHash, farmNormsCache.set])
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refine the useEffect condition to prevent redundant cache updates.

The current condition (!data.useCache || !cachedData?.inputHash) will always be true when data.useCache is false (fresh data), causing the effect to run repeatedly even after the cache has been populated. This could lead to unnecessary re-renders and potential infinite loops if the cache setter triggers a component update.

Apply this diff to only save when the inputHash differs from the cached inputHash:

     useEffect(() => {
         if (
-            (!data.useCache || !cachedData?.inputHash) &&
+            !data.useCache &&
+            data.inputHash &&
+            data.inputHash !== cachedData?.inputHash &&
-            !data.errorMessage &&
-            data.inputHash
+            !data.errorMessage
         ) {
             farmNormsCache.set(loaderData.b_id_farm, data)
         }
-    }, [loaderData.b_id_farm, data, cachedData?.inputHash, farmNormsCache.set])
+    }, [loaderData.b_id_farm, data.inputHash, cachedData?.inputHash, farmNormsCache.set, data.errorMessage])

This ensures the effect only runs when:

  • Data is fresh from the server (!data.useCache)
  • Data has an inputHash
  • The inputHash differs from what's cached (or cache is empty)
  • No error occurred

Additionally, refine the dependency array to include only the specific properties of data that are checked in the condition, rather than the entire data object, to avoid unnecessary re-runs.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (
(!data.useCache || !cachedData?.inputHash) &&
!data.errorMessage &&
data.inputHash
) {
farmNormsCache.set(loaderData.b_id_farm, data)
}
}, [loaderData.b_id_farm, data, cachedData?.inputHash, farmNormsCache.set])
useEffect(() => {
if (
!data.useCache &&
data.inputHash &&
data.inputHash !== cachedData?.inputHash &&
!data.errorMessage
) {
farmNormsCache.set(loaderData.b_id_farm, data)
}
}, [loaderData.b_id_farm, data.inputHash, cachedData?.inputHash, farmNormsCache.set, data.errorMessage])
🤖 Prompt for AI Agents
In fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx around lines 359 to
367, the useEffect saves to cache unconditionally when data.useCache is false
which can cause redundant updates; change the condition to only call
farmNormsCache.set(loaderData.b_id_farm, data) when data.inputHash exists and
differs from cachedData?.inputHash (i.e., !data.useCache && data.inputHash &&
data.inputHash !== cachedData?.inputHash && !data.errorMessage), and tighten the
dependency array to only include loaderData.b_id_farm, data.useCache,
data.inputHash, data.errorMessage, and cachedData?.inputHash (remove the whole
data object and farmNormsCache.set) so the effect only runs when those specific
values change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the data object will become a different one between renders with the same async data? If this is not the case, then b_id_farm, inputHash, farmNormsCacheset, and errorMessage won't be different during the second render.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BoraIneviNMI, excellent question! You're absolutely right to scrutinize this.

With React Router's loader data and the use() hook, the data object should indeed be referentially stable across renders as long as the underlying loaderData.asyncData promise hasn't changed. React's use() hook caches the resolved value based on the promise's identity, so the same promise will return the same data object reference.

Given this stability, you're correct that including individual properties like data.inputHash versus the whole data object in the dependency array would have similar behavior in terms of preventing unnecessary effect runs - they'll only trigger when the loader runs again with new data.

The primary issue is actually the condition logic, not the dependency array.

The original condition (!data.useCache || !cachedData?.inputHash) will remain true even after caching completes (because !data.useCache stays true for fresh data), which could cause the effect to run on every render if something else triggers re-renders. The fix to check data.inputHash !== cachedData?.inputHash prevents this by ensuring we only cache when the input has actually changed.

So a simpler fix focusing on the condition would be sufficient:

useEffect(() => {
    if (
        !data.useCache &&
        data.inputHash &&
        data.inputHash !== cachedData?.inputHash &&
        !data.errorMessage
    ) {
        farmNormsCache.set(loaderData.b_id_farm, data)
    }
}, [loaderData.b_id_farm, data, cachedData?.inputHash, farmNormsCache.set])

The dependency array can remain with data since it's stable. The refined dependencies I suggested earlier were following the general React best practice of "include only what you use," but with stable data, it's not strictly necessary for correctness here.

Does this clarify the situation?

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 164ad10 and 4bf1665.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (1)
  • fdm-app/app/components/blocks/header/nutrient-advice.tsx (1 hunks)

Comment on lines 33 to 35
<BreadcrumbLink onClick={() => typeof window !== "undefined" && window.location.reload()}>
Bemestingsadvies
</BreadcrumbLink>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Replace page reload with proper router navigation.

The onClick handler forces a full page reload instead of using React Router's client-side navigation. This causes several issues:

  1. Incorrect navigation: window.location.reload() reloads the current page rather than navigating to the base nutrient advice path (/farm/{b_id_farm}/{calendar}/nutrient_advice). If a user is viewing a specific field, clicking this breadcrumb should take them to the overview, not reload the field page.
  2. Performance: Full page reloads are significantly slower than client-side navigation.
  3. Inconsistency: Field selection (lines 56-60) uses NavLink for proper routing, making this reload approach inconsistent.
  4. Lost state: Reloading discards browser state, scroll position, and other ephemeral UI state.

Given that this PR introduces cache-aware routing with client middleware (per PR objectives), integrate this breadcrumb with the new routing system instead of forcing reloads.

Apply this diff to use proper navigation:

-                <BreadcrumbLink onClick={() => typeof window !== "undefined" && window.location.reload()}>
+                <BreadcrumbLink asChild>
+                    <NavLink to={`/farm/${b_id_farm}/${calendar}/nutrient_advice`}>
-                    Bemestingsadvies
+                        Bemestingsadvies
+                    </NavLink>
                 </BreadcrumbLink>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<BreadcrumbLink onClick={() => typeof window !== "undefined" && window.location.reload()}>
Bemestingsadvies
</BreadcrumbLink>
<BreadcrumbLink asChild>
<NavLink to={`/farm/${b_id_farm}/${calendar}/nutrient_advice`}>
Bemestingsadvies
</NavLink>
</BreadcrumbLink>

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 164ad10 and 4bf1665.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (1)
  • fdm-app/app/components/blocks/header/nutrient-advice.tsx (1 hunks)

Comment on lines 33 to 35
<BreadcrumbLink onClick={() => typeof window !== "undefined" && window.location.reload()}>
Bemestingsadvies
</BreadcrumbLink>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Restore proper navigation instead of forcing page reload.

This change breaks accessibility and navigation:

  1. Accessibility: The link has no href attribute, making it non-keyboard accessible and invisible to screen readers and search engines.
  2. Navigation: window.location.reload() reloads the current page instead of navigating to /farm/${b_id_farm}/${calendar}/nutrient_advice. If the user is on a field-specific page (e.g., /farm/123/2025/nutrient_advice/field456), clicking this breadcrumb keeps them on the same page rather than navigating up to the parent nutrient advice route.
  3. UX degradation: Full page reloads are slower and more disruptive than React Router's client-side navigation.
  4. Inconsistency: The dropdown menu items (lines 56-58) use proper NavLink navigation, but the parent breadcrumb uses a reload.

Given that the PR implements automatic cache versioning (as stated in the PR description), cache invalidation should be handled by the versioning system rather than forcing manual page reloads in navigation links.

Replace the reload with proper navigation:

-<BreadcrumbLink onClick={() => typeof window !== "undefined" && window.location.reload()}>
+<BreadcrumbLink asChild>
+  <NavLink to={`/farm/${b_id_farm}/${calendar}/nutrient_advice`}>
     Bemestingsadvies
+  </NavLink>
 </BreadcrumbLink>

If cache invalidation is required on navigation, implement it in the route component's loader or use React Router's reloadDocument prop on the NavLink, but avoid window.location.reload() which doesn't navigate to the intended route.

Can you clarify the intent behind forcing a page reload here? If the goal is cache invalidation, please verify that:


🏁 Script executed:

#!/bin/bash
# Search for cache invalidation logic in route loaders and stores
rg -nP -C3 "nutrient_advice|calculation-cache" --type=ts --type=tsx -g '!**/node_modules/**'

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for cache invalidation logic in route loaders and stores
rg -nP -C3 "nutrient_advice|calculation-cache" -g '*.ts' -g '*.tsx' -g '!**/node_modules/**'

Length of output: 9613


Restore proper navigation on the breadcrumb link.

  • The link has no href, breaking keyboard and screen-reader access.
  • window.location.reload() reloads the current page instead of navigating to /farm/${b_id_farm}/${calendar}/nutrient_advice.
  • Full-page reloads are slower and more disruptive than client-side routing.
  • Dropdown menu items use <NavLink> correctly; this breadcrumb should too.
-<BreadcrumbLink onClick={() => typeof window !== "undefined" && window.location.reload()}>
+<BreadcrumbLink asChild>
+  <NavLink reloadDocument to={`/farm/${b_id_farm}/${calendar}/nutrient_advice`}>
     Bemestingsadvies
+  </NavLink>
 </BreadcrumbLink>

@BoraIneviNMI BoraIneviNMI requested a review from SvenVw October 13, 2025 11:31
Copy link
Copy Markdown
Collaborator

@SvenVw SvenVw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think I have made a faulty assumption in the original issue that the server knows the state of the localstorage to check whether it should calculate the values or not. Let's discuss it first if there are other ways to implement it

@@ -0,0 +1,47 @@
import { redirect } from "react-router"
Copy link
Copy Markdown
Collaborator

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


export async function loader() {
// Redirect to nitrogen page
return redirect("./nitrogen")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this redirect removed?

const page = location.pathname
const calendar = useCalendarStore((state) => state.calendar)

const fieldNitrogenBalanceCache = useFieldNitrogenBalanceCache()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

datasetsUrl,
)
.then(async (input) => {
const inputHash = hash(input)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are various typescript warnings with this block of code. Please check

Comment thread fdm-app/vite.config.ts
envPrefix: "PUBLIC_",
define: {
global: {},
PUBLIC_FDM_CALCULATOR_VERSION: JSON.stringify(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if this works in production, as build times variables are not present at runtime always. Maybe better to use at the store the clientConfig

@SvenVw SvenVw closed this Oct 14, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

branch:development Issue only affecting development, not the main branch (yet) dependencies Pull requests that update a dependency file enhancement New feature or request fdm-app fdm-calculator

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Client-Side Caching for Calculations

2 participants