From b7420991632b37bd6eb882f87f3438af31bbb563 Mon Sep 17 00:00:00 2001 From: Joyce Yuki <82857964+kathyavini@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:12:48 -0700 Subject: [PATCH 1/2] LF-5381 Guard task selectors against plan entities missing from the store Tasks and management plans arrive from independent fetches, and the tasks response embeds no planting management plans, so any tasks fetch that is not paired with a plans fetch stores tasks whose plan entities are absent; redux-persist then replays such snapshots across reloads. Make the task selector joins total over partial state: drop unresolvable plan references, skip tasks with no cached task type, and skip plant/transplant tasks whose planting management plan has not loaded. Co-Authored-By: Claude Fable 5 --- .../slice/taskSlice/plantTaskSlice.js | 8 +++-- .../slice/taskSlice/transplantTaskSlice.js | 7 +++- packages/webapp/src/containers/taskSlice.js | 32 +++++++++++++------ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js b/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js index 8f5ba2bc26..ef57b79bb2 100644 --- a/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js +++ b/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js @@ -86,8 +86,12 @@ export const plantTasksByManagementPlanIdEntitiesSelector = createSelector( (plantTaskEntities) => { return Object.values(plantTaskEntities).reduce( (plantTasksByManagementPlanIdEntities, plantTask) => { - const { management_plan_id } = plantTask.planting_management_plan; - plantTasksByManagementPlanIdEntities[management_plan_id] = plantTask; + // Plan entities arrive from a separate fetch; skip plant tasks whose plan is not in the + // store yet. + const management_plan_id = plantTask.planting_management_plan?.management_plan_id; + if (management_plan_id !== undefined) { + plantTasksByManagementPlanIdEntities[management_plan_id] = plantTask; + } return plantTasksByManagementPlanIdEntities; }, {}, diff --git a/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js b/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js index c33dfea308..b8d8aa596c 100644 --- a/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js +++ b/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js @@ -95,7 +95,12 @@ export const transplantTasksByManagementPlanIdEntitiesSelector = createSelector( (transplantTaskEntities) => { return Object.values(transplantTaskEntities).reduce( (transplantTasksByManagementPlanIdEntities, transplantTask) => { - const { management_plan_id } = transplantTask.planting_management_plan; + // Plan entities arrive from a separate fetch; skip transplant tasks whose plan is not in + // the store yet. + const management_plan_id = transplantTask.planting_management_plan?.management_plan_id; + if (management_plan_id === undefined) { + return transplantTasksByManagementPlanIdEntities; + } if (!transplantTasksByManagementPlanIdEntities[management_plan_id]) { transplantTasksByManagementPlanIdEntities[management_plan_id] = []; } diff --git a/packages/webapp/src/containers/taskSlice.js b/packages/webapp/src/containers/taskSlice.js index f530b9bb91..332d6317a5 100644 --- a/packages/webapp/src/containers/taskSlice.js +++ b/packages/webapp/src/containers/taskSlice.js @@ -232,6 +232,11 @@ export const taskEntitiesSelector = createSelector( }) => { const management_plan_id = plantingManagementPlanEntities[planting_management_plan_id]?.management_plan_id; + // Plan entities arrive from a separate fetch and can be absent while tasks are already in + // the store; return undefined so callers can drop the reference until the store catches up. + if (!managementPlanEntities[management_plan_id]) { + return undefined; + } return produce(managementPlanEntities[management_plan_id], (managementPlan) => { managementPlan.planting_management_plan = plantingManagementPlanEntities[planting_management_plan_id]; @@ -243,29 +248,38 @@ export const taskEntitiesSelector = createSelector( return produce(taskEntities, (taskEntities) => { for (const task_id in taskEntities) { taskEntities[task_id].managementPlans = - taskEntities[task_id].managementPlans?.map(getManagementPlanByPlantingManagementPlan) || - []; + taskEntities[task_id].managementPlans + ?.map(getManagementPlanByPlantingManagementPlan) + .filter(Boolean) || []; // Drop location_ids with no cached location so tasksSelector never reads farm_id off undefined. taskEntities[task_id].locations = taskEntities[task_id].locations ?.map((location_id) => locationEntities[location_id]) .filter(Boolean) || []; const taskType = taskTypeEntities[taskEntities[task_id].task_type_id]; + // Every consumer dereferences taskType unconditionally; a task whose type is not in the + // store cannot be rendered, so drop it until the task types fetch lands. + if (!taskType) { + delete taskEntities[task_id]; + continue; + } taskEntities[task_id].taskType = taskType; const { task_translation_key, farm_id } = taskType; const subtask = subTaskEntities[task_id]; !farm_id && (taskEntities[task_id][getSubtaskName(task_translation_key)] = subtask); if (!farm_id && ['PLANT_TASK', 'TRANSPLANT_TASK'].includes(task_translation_key)) { - // Keep the location only when its record is cached, so an unloaded location yields [] not [undefined]. - taskEntities[task_id].locations = subtask.planting_management_plan.location_id - ? [locationEntities[subtask.planting_management_plan.location_id]].filter(Boolean) + // The subtask, its planting management plan, and the location each come from separate + // fetches; join only what is in the store, so the task holds [] rather than [undefined]. + const planting_management_plan = subtask?.planting_management_plan; + taskEntities[task_id].locations = planting_management_plan?.location_id + ? [locationEntities[planting_management_plan.location_id]].filter(Boolean) + : []; + taskEntities[task_id].managementPlans = subtask + ? [getManagementPlanByPlantingManagementPlan(subtask)].filter(Boolean) : []; - taskEntities[task_id].managementPlans = [ - getManagementPlanByPlantingManagementPlan(subtask), - ]; } taskEntities[task_id].assignee = - userFarmEntities[userFarm.farm_id][taskEntities[task_id].assignee_user_id]; + userFarmEntities[userFarm.farm_id]?.[taskEntities[task_id].assignee_user_id]; } }); }, From ead4009eaa1790b8becb2fe165f8809957d71d94 Mon Sep 17 00:00:00 2001 From: Joyce Yuki <82857964+kathyavini@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:48:35 -0700 Subject: [PATCH 2/2] LF-5381 Clean up comments --- .../src/containers/slice/taskSlice/plantTaskSlice.js | 3 +-- .../containers/slice/taskSlice/transplantTaskSlice.js | 3 +-- packages/webapp/src/containers/taskSlice.js | 9 +++------ 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js b/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js index ef57b79bb2..14e178e2df 100644 --- a/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js +++ b/packages/webapp/src/containers/slice/taskSlice/plantTaskSlice.js @@ -86,8 +86,7 @@ export const plantTasksByManagementPlanIdEntitiesSelector = createSelector( (plantTaskEntities) => { return Object.values(plantTaskEntities).reduce( (plantTasksByManagementPlanIdEntities, plantTask) => { - // Plan entities arrive from a separate fetch; skip plant tasks whose plan is not in the - // store yet. + // Plan may not be fetched yet. const management_plan_id = plantTask.planting_management_plan?.management_plan_id; if (management_plan_id !== undefined) { plantTasksByManagementPlanIdEntities[management_plan_id] = plantTask; diff --git a/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js b/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js index b8d8aa596c..4c69811c3b 100644 --- a/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js +++ b/packages/webapp/src/containers/slice/taskSlice/transplantTaskSlice.js @@ -95,8 +95,7 @@ export const transplantTasksByManagementPlanIdEntitiesSelector = createSelector( (transplantTaskEntities) => { return Object.values(transplantTaskEntities).reduce( (transplantTasksByManagementPlanIdEntities, transplantTask) => { - // Plan entities arrive from a separate fetch; skip transplant tasks whose plan is not in - // the store yet. + // Plan may not be fetched yet. const management_plan_id = transplantTask.planting_management_plan?.management_plan_id; if (management_plan_id === undefined) { return transplantTasksByManagementPlanIdEntities; diff --git a/packages/webapp/src/containers/taskSlice.js b/packages/webapp/src/containers/taskSlice.js index 332d6317a5..0bbd135579 100644 --- a/packages/webapp/src/containers/taskSlice.js +++ b/packages/webapp/src/containers/taskSlice.js @@ -232,8 +232,8 @@ export const taskEntitiesSelector = createSelector( }) => { const management_plan_id = plantingManagementPlanEntities[planting_management_plan_id]?.management_plan_id; - // Plan entities arrive from a separate fetch and can be absent while tasks are already in - // the store; return undefined so callers can drop the reference until the store catches up. + // Returns undefined when the planting or management plan is not in the store; both call + // sites below (`managementPlans.map` and the plant/transplant branch) `.filter(Boolean)` it out. if (!managementPlanEntities[management_plan_id]) { return undefined; } @@ -257,8 +257,7 @@ export const taskEntitiesSelector = createSelector( ?.map((location_id) => locationEntities[location_id]) .filter(Boolean) || []; const taskType = taskTypeEntities[taskEntities[task_id].task_type_id]; - // Every consumer dereferences taskType unconditionally; a task whose type is not in the - // store cannot be rendered, so drop it until the task types fetch lands. + // Task type may not be loaded yet; a task with no type cannot render. if (!taskType) { delete taskEntities[task_id]; continue; @@ -268,8 +267,6 @@ export const taskEntitiesSelector = createSelector( const subtask = subTaskEntities[task_id]; !farm_id && (taskEntities[task_id][getSubtaskName(task_translation_key)] = subtask); if (!farm_id && ['PLANT_TASK', 'TRANSPLANT_TASK'].includes(task_translation_key)) { - // The subtask, its planting management plan, and the location each come from separate - // fetches; join only what is in the store, so the task holds [] rather than [undefined]. const planting_management_plan = subtask?.planting_management_plan; taskEntities[task_id].locations = planting_management_plan?.location_id ? [locationEntities[planting_management_plan.location_id]].filter(Boolean)