Skip to content

Implement AbortController for Atlas map request cancellation#485

Merged
SvenVw merged 7 commits into
developmentfrom
fix-atlas-aggregateError
Mar 3, 2026
Merged

Implement AbortController for Atlas map request cancellation#485
SvenVw merged 7 commits into
developmentfrom
fix-atlas-aggregateError

Conversation

@SvenVw
Copy link
Copy Markdown
Collaborator

@SvenVw SvenVw commented Mar 2, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Better cancellation of in-progress network requests to prevent stale UI updates and spurious error reporting.
    • More robust error handling for aborted requests to avoid noisy logs and incorrect network state.
  • Performance

    • Optimized map data sampling and reduced concurrent sampling to improve responsiveness during rapid map interactions.
  • Chores

    • Added a patch release changeset and removed an unnecessary debug log.

…bortController for network requests and optimizing data sampling
@SvenVw SvenVw self-assigned this Mar 2, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 2, 2026

📝 Walkthrough

Walkthrough

Adds AbortController-driven cancellation and timeout handling across Atlas components and route loaders, propagates signals through tile and soil fetch flows, reduces elevation sampling density (gridSize/chunkSize), removes a stray log, and ensures in-flight requests are aborted and cleaned up to avoid stale processing.

Changes

Cohort / File(s) Summary
Atlas component cancellation
fdm-app/app/components/blocks/atlas/atlas-sources.tsx
Adds per-request AbortController ref; cancels prior requests before new loads; inserts signal.aborted guards during async processing; prevents state updates after abort; aborts throttled work on cleanup.
Elevation route & sampling
fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx
Introduces AbortController ref and abort guards across tile sampling flow; adds early returns on abort; reduces gridSize 3→2 and chunkSize 4→2; ensures in-flight work is aborted on unmount.
Soil UI & fetch flow
fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx
Adds abortControllerRef, cancels in-flight requests on map clicks/toggles and on unmount; passes signal to GetFeatureInfo and fetchBodemData; rethrows/suppresses AbortError appropriately; integrates cancellation with MRU cache usage.
BodemData loader & timeout
fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.bodemdata.$soilcode.ts
Changes loader signature to accept request; composes AbortSignal.any with a timeout; returns HTTP 499 on client abort; ensures timeout cleared in finally block; distinguishes AbortError vs other errors.
Minor docs & cleanup
.changeset/cool-adults-report.md, fdm-app/app/integrations/ahn-cache.server.ts
Adds release changeset entry; removes a stray console.log from AHN cache fetch.

Sequence Diagram(s)

sequenceDiagram
    participant User as Map UI
    participant Atlas as Atlas Component
    participant Loader as Route Loader
    participant API as External API

    User->>Atlas: pan / click → start request
    Atlas->>Atlas: abort previous controller (if present)
    Atlas->>Loader: fetch tiles/soil (pass AbortSignal)
    Loader->>API: fetch data (with combined timeout signal)
    API-->>Loader: response or error
    alt aborted
        Loader->>Atlas: abort detected → stop processing
        Atlas-->>User: no update
    else success
        Loader->>Atlas: processed data
        Atlas->>User: render tiles/soil
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

fdm-app, bug, branch:development

Suggested reviewers

  • BoraIneviNMI
  • gerardhros

Poem

🐰 I nibbled through signals, clipped each lingering thread,
Aborted stale fetches so fresh maps could be fed.
Tiles hush their chatter, soil answers come by,
Quick paws, clean exits — no ghosts left to sigh. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ 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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed Title check skipped as CodeRabbit has written the PR title.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-atlas-aggregateError

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.

@coderabbitai coderabbitai Bot changed the title @coderabbitai Implement AbortController for Atlas map request cancellation Mar 2, 2026
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: 3

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/components/blocks/atlas/atlas-sources.tsx (1)

278-290: ⚠️ Potential issue | 🟠 Major

Cancel the throttled callback in cleanup.

With trailing: true, a pending throttled call may execute after unmount or dependency change and trigger state updates on a destroyed component.

💡 Suggested fix
         if (map) {
             map.on("moveend", throttledLoadData)
             map.on("zoomend", throttledLoadData)
             map.once("load", loadData)
             return () => {
                 map.off("moveend", throttledLoadData)
                 map.off("zoomend", throttledLoadData)
+                throttledLoadData.cancel()
                 if (abortControllerRef.current) {
                     abortControllerRef.current.abort()
                 }
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/blocks/atlas/atlas-sources.tsx` around lines 278 -
290, The throttled callback (throttledLoadData) can fire after unmount because
it was created with trailing: true; in the cleanup for the map event listeners
(where you remove moveend/zoomend and abortControllerRef.abort()), also call
throttledLoadData.cancel() to cancel any pending trailing invocation. Locate the
throttle creation (const throttledLoadData = throttle(loadData, 250, { trailing:
true })) and add a call to throttledLoadData.cancel() in the returned cleanup
function so no pending throttled call runs after the component is torn down.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.elevation.tsx:
- Around line 337-345: The abort logic that creates/assigns abortControllerRef
and calls abort() must run before the zoom < 13 early return to cancel in-flight
requests; move the block that checks abortControllerRef.current, calls abort(),
then creates a new AbortController and assigns abortControllerRef.current (and
derives signal) so it executes before the zoom threshold guard in the same
function (referencing abortControllerRef and abortController) to ensure stale
tile fetches are cancelled prior to returning.

In
`@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.soil.bodemdata.$soilcode.ts:
- Around line 8-21: The timeout cleanup (clearTimeout(timeoutId)) must run even
if fetch rejects: wrap the fetch call and any use of the composed signal in a
try/finally so clearTimeout(timeoutId) (and any AbortController cleanup)
executes in the finally block; keep creation of timeoutController, timeoutId and
the combined signal (AbortSignal.any([...])) in the same scope, call fetch(...)
inside the try, and move clearTimeout(timeoutId) into the finally so timers
don't remain active after a rejected fetch.

In `@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.soil.tsx:
- Around line 261-282: The current code aborts requests on unmount and when
starting a new request but does not abort an in-flight request when the soil
layer is toggled off; update the onToggleSoil handler to call
abortControllerRef.current?.abort() before clearing state so any inflight fetch
is cancelled and cannot later set selectedSoilFeature. Specifically, in the
onToggleSoil function (the toggle that flips showSoil/clears
selectedSoilFeature), call abortControllerRef.current.abort() (or null-check and
call) then set abortControllerRef.current = null and then clear
selectedSoilFeature and setShowSoil to ensure no stale responses re-populate the
UI. Ensure the existing useEffect cleanup and onMapClick usage of
abortControllerRef remain unchanged.

---

Outside diff comments:
In `@fdm-app/app/components/blocks/atlas/atlas-sources.tsx`:
- Around line 278-290: The throttled callback (throttledLoadData) can fire after
unmount because it was created with trailing: true; in the cleanup for the map
event listeners (where you remove moveend/zoomend and
abortControllerRef.abort()), also call throttledLoadData.cancel() to cancel any
pending trailing invocation. Locate the throttle creation (const
throttledLoadData = throttle(loadData, 250, { trailing: true })) and add a call
to throttledLoadData.cancel() in the returned cleanup function so no pending
throttled call runs after the component is torn down.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between db4f1fa and bf4448c.

📒 Files selected for processing (6)
  • .changeset/cool-adults-report.md
  • fdm-app/app/components/blocks/atlas/atlas-sources.tsx
  • fdm-app/app/integrations/ahn-cache.server.ts
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.bodemdata.$soilcode.ts
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx
💤 Files with no reviewable changes (1)
  • fdm-app/app/integrations/ahn-cache.server.ts

Comment thread fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx Outdated
Comment thread fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.bodemdata.$soilcode.ts Outdated
Comment thread fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx
Ensures stale network requests are cancelled before zoom guards, guarantees timeout cleanup in the soil loader, and cancels pendingthrottled calls on unmount to prevent race conditions and memory leaks.
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

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.atlas.elevation.tsx (1)

337-342: ⚠️ Potential issue | 🟠 Major

Reset loading/status on zoom-out abort path to avoid stuck UI state.

When a running request is aborted by a zoom-out call, the aborted request skips setIsUpdating(false) in finally, and the zoom-out branch returns without resetting state. This can leave loading indicators stuck.

Suggested fix
         if (zoom < 13) {
             if (activeTilesLengthRef.current > 0) {
                 setActiveTiles([])
             }
+            setIsUpdating(false)
+            setNetworkStatus("idle")
             return
         }

Also applies to: 510-511

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.elevation.tsx around
lines 337 - 342, The zoom-out branch that returns early when zoom < 13 currently
clears active tiles via setActiveTiles([]) but doesn’t reset the request state,
causing loading indicators to stick; update that branch in
farm.$b_id_farm.$calendar.atlas.elevation (the block that checks `if (zoom <
13)`) to also call the relevant state-resetters (e.g., `setIsUpdating(false)`
and any other loading flags you use alongside `setIsUpdating`) so aborted
requests don’t leave the UI in a loading state, and apply the same change to the
analogous early-return block around the code at the other occurrence (the block
referenced by lines ~510-511).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/components/blocks/atlas/atlas-sources.tsx`:
- Around line 216-223: Move the request-cancellation block so it runs before any
early-return/branch checks: call abortControllerRef.current?.abort(), create a
new AbortController and assign it to abortControllerRef.current (and extract
signal) at the start of the function/run that may early-exit, not only inside
the high-zoom/bounds branch. This ensures any previous in-flight request is
aborted even if the current run returns early; update uses of abortController/
signal accordingly (look for abortControllerRef, abortController, and signal in
the function).

---

Outside diff comments:
In `@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.elevation.tsx:
- Around line 337-342: The zoom-out branch that returns early when zoom < 13
currently clears active tiles via setActiveTiles([]) but doesn’t reset the
request state, causing loading indicators to stick; update that branch in
farm.$b_id_farm.$calendar.atlas.elevation (the block that checks `if (zoom <
13)`) to also call the relevant state-resetters (e.g., `setIsUpdating(false)`
and any other loading flags you use alongside `setIsUpdating`) so aborted
requests don’t leave the UI in a loading state, and apply the same change to the
analogous early-return block around the code at the other occurrence (the block
referenced by lines ~510-511).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf4448c and d41161b.

📒 Files selected for processing (4)
  • fdm-app/app/components/blocks/atlas/atlas-sources.tsx
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.bodemdata.$soilcode.ts
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx

Comment thread fdm-app/app/components/blocks/atlas/atlas-sources.tsx Outdated
@SvenVw SvenVw requested a review from BoraIneviNMI March 2, 2026 12:49
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 (1)
fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx (1)

332-339: Create the new AbortController only when zoom is eligible for tile requests.

You already correctly abort the previous request before the zoom guard. A small cleanup: move creation/assignment of the new controller to after the zoom >= 13 check so zoomed-out updates don’t allocate unused controllers.

Refactor sketch
         // Cancel previous request
         if (abortControllerRef.current) {
             abortControllerRef.current.abort()
         }
-        const abortController = new AbortController()
-        abortControllerRef.current = abortController
-        const signal = abortController.signal

         // If zoomed out, clear active tiles to save resources (WMS will take over)
         if (zoom < 13) {
             if (activeTilesLengthRef.current > 0) {
                 setActiveTiles([])
             }
             setIsUpdating(false)
             setNetworkStatus("idle")
             return
         }
+
+        const abortController = new AbortController()
+        abortControllerRef.current = abortController
+        const signal = abortController.signal

Also applies to: 341-348

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.elevation.tsx around
lines 332 - 339, Move creation/assignment of the new AbortController so it only
happens when the zoom check allows tile requests: keep the existing abort of
abortControllerRef.current before the zoom guard, then after verifying zoom >=
13 (the guard that prevents requests when zoomed out) create const
abortController = new AbortController(), assign abortControllerRef.current =
abortController and extract signal from abortController.signal; apply the same
change for the second occurrence around lines with
abortControllerRef/AbortController in the file.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/components/blocks/atlas/atlas-sources.tsx`:
- Line 237: deserialize(availableFieldsUrl, bbox) performs an uncancelable
internal fetch; wrap the HTTP request with fetch(availableFieldsUrl, { signal })
and pass response.body (a ReadableStream) into deserialize to allow
AbortController cancellation. Specifically, replace the direct call to
deserialize(availableFieldsUrl, bbox) with a pattern that calls
fetch(availableFieldsUrl, { signal }), checks response.ok, obtains
response.body, and then calls deserialize(response.body, bbox); keep existing
signal.aborted checks and ensure any thrown errors from fetch or deserialize are
handled so aborted requests don't proceed.

In `@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.elevation.tsx:
- Around line 323-327: The early-return branch in updateVisibleTiles that checks
mapRef.current and indexData should not reset network status; remove or stop
calling setNetworkStatus("idle") inside that if-block so you only call
setIsUpdating(false) and return when prerequisites are missing (refer to the
mapRef, indexData, setIsUpdating, setNetworkStatus, and updateVisibleTiles
symbols) to avoid overwriting real loading/error states from the index fetch.

---

Nitpick comments:
In `@fdm-app/app/routes/farm`.$b_id_farm.$calendar.atlas.elevation.tsx:
- Around line 332-339: Move creation/assignment of the new AbortController so it
only happens when the zoom check allows tile requests: keep the existing abort
of abortControllerRef.current before the zoom guard, then after verifying zoom
>= 13 (the guard that prevents requests when zoomed out) create const
abortController = new AbortController(), assign abortControllerRef.current =
abortController and extract signal from abortController.signal; apply the same
change for the second occurrence around lines with
abortControllerRef/AbortController in the file.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d41161b and b6ef34f.

📒 Files selected for processing (2)
  • fdm-app/app/components/blocks/atlas/atlas-sources.tsx
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx

Comment thread fdm-app/app/components/blocks/atlas/atlas-sources.tsx
Comment thread fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx
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.

♻️ Duplicate comments (1)
fdm-app/app/components/blocks/atlas/atlas-sources.tsx (1)

238-238: ⚠️ Potential issue | 🟠 Major

URL-based FlatGeobuf deserialization still bypasses true transport cancellation.

At Line 238, deserialize(availableFieldsUrl, bbox) keeps fetch ownership inside FlatGeobuf. Your signal.aborted checks prevent stale state writes, but may still allow the underlying network transfer to continue.

Suggested fix
-                            const iter = deserialize(availableFieldsUrl, bbox)
+                            const response = await fetch(availableFieldsUrl, {
+                                signal,
+                            })
+                            if (!response.ok || !response.body) {
+                                throw new Error(
+                                    `Failed to fetch fields: ${response.status}`,
+                                )
+                            }
+                            const iter = deserialize(response.body, bbox)
For the exact flatgeobuf version used in this repo, does `deserialize(url, rect)` support AbortSignal-backed HTTP cancellation, or is `fetch(url, { signal })` + `deserialize(response.body, rect)` the recommended abortable pattern?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/blocks/atlas/atlas-sources.tsx` at line 238, The
current call const iter = deserialize(availableFieldsUrl, bbox) hands a URL to
FlatGeobuf which may not honor our AbortSignal; instead perform a
fetch(availableFieldsUrl, { signal }) to get a Response and pass response.body
(the ReadableStream) into deserialize(response.body, bbox) so network reads are
cancelable by the existing signal; update the usage site that references
availableFieldsUrl, bbox and the local AbortSignal (e.g., signal or
abortController.signal) to use this fetch-then-deserialize pattern and
close/cleanup the response if aborted or on error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@fdm-app/app/components/blocks/atlas/atlas-sources.tsx`:
- Line 238: The current call const iter = deserialize(availableFieldsUrl, bbox)
hands a URL to FlatGeobuf which may not honor our AbortSignal; instead perform a
fetch(availableFieldsUrl, { signal }) to get a Response and pass response.body
(the ReadableStream) into deserialize(response.body, bbox) so network reads are
cancelable by the existing signal; update the usage site that references
availableFieldsUrl, bbox and the local AbortSignal (e.g., signal or
abortController.signal) to use this fetch-then-deserialize pattern and
close/cleanup the response if aborted or on error.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b6ef34f and 719bf9b.

📒 Files selected for processing (2)
  • fdm-app/app/components/blocks/atlas/atlas-sources.tsx
  • fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx

@SvenVw SvenVw merged commit 2b95433 into development Mar 3, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant