diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts
index d655f04..6b214cf 100644
--- a/apps/web/cypress/e2e/recipe-form.ts
+++ b/apps/web/cypress/e2e/recipe-form.ts
@@ -108,7 +108,11 @@ Given("recipe 7 exists with an egg omelette", () => {
diets: [],
isFavorite: false,
ingredients: [{ ingredient: egg, quantity: 3, unit: pieceUnit }],
- steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }],
+ // `techSteps: []` matches the real `StepView` shape (packages/shared) —
+ // every other recipe-step mock in the suite sends it, and its absence
+ // here let `/recettes/7` (rendered after this recipe saves) hit
+ // `StepDescription` with `techSteps === undefined`.
+ steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0, techSteps: [] }],
};
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
});
diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts
index 137dcd9..c6eebf6 100644
--- a/apps/web/cypress/e2e/recipes.cy.ts
+++ b/apps/web/cypress/e2e/recipes.cy.ts
@@ -166,6 +166,29 @@ const brownStepRecipe = {
],
};
+/**
+ * A recipe whose step object is missing `techSteps` entirely (not `[]`) —
+ * a partial/older payload shape. `StepDescription` must render its
+ * description as plain text rather than crashing on `liveTechSteps.flatMap`.
+ * Regression: an intermittent `recipe-form.feature` failure where saving an
+ * edited recipe navigates to `/recettes/:id`, whose detail loaded before
+ * `techSteps` was populated.
+ */
+const techStepsMissingDetail = {
+ id: 7,
+ name: "Omelette",
+ description: null,
+ picture: null,
+ portions: 2,
+ authorId: 1,
+ visibility: "PERSONAL",
+ allergens: [],
+ diets: [],
+ isFavorite: false,
+ ingredients: [],
+ steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 1 }],
+};
+
function interceptAuth() {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
@@ -284,6 +307,27 @@ describe("Recipe catalog", () => {
});
});
+ // Regression: a step payload missing `techSteps` entirely (see
+ // `techStepsMissingDetail`) made `StepDescription` throw
+ // `Cannot read properties of undefined (reading 'flatMap')`, surfacing as
+ // an uncaught application error. An uncaught exception fails the spec on
+ // its own, so simply asserting the description renders is enough — no
+ // extra `cy.on("uncaught:exception")` needed.
+ it("renders a step whose techSteps field is absent from the payload, as plain text", () => {
+ cy.viewport(1600, 900);
+ cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
+ cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: techStepsMissingDetail }).as(
+ "getRecipe",
+ );
+
+ cy.visit("/recettes/7");
+ cy.wait("@getRecipe");
+
+ cy.get(".recipe-detail-panel__steps").within(() => {
+ cy.contains("Battre les œufs.").should("be.visible");
+ });
+ });
+
// Regression: `.recipe-detail-panel__steps`'s CSS step counter
// (recipes.scss) used a bare descendant `li` selector, matching *any*
// `
` nested inside a step — including ones several levels down
diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx
index 5fb0e4a..eb79903 100644
--- a/apps/web/src/features/recipes/steps/StepDescription.tsx
+++ b/apps/web/src/features/recipes/steps/StepDescription.tsx
@@ -71,14 +71,15 @@ function findTechStepForTemperature(
* no click handlers) — this mode is purely additive, not a rewrite of the
* read-only rendering.
*
- * Maintains its own local copy of `techSteps` (seeded from the prop, then
- * replaced with whatever `POST .../corrections` returns on a successful
- * submit — see `SubmitTechStepCorrectionResult`'s doc comment,
- * `packages/shared`) so a correction's effect (a new/relabeled/removed
- * highlight) appears immediately, without needing the parent to re-fetch
- * the whole recipe. Resynced whenever the `techSteps` prop itself changes
- * (e.g. the parent reloaded the recipe for an unrelated reason) so this
- * never keeps showing stale local state past that.
+ * Maintains its own local copy of `techSteps` (seeded from the prop — or
+ * `[]` if it's momentarily absent, see the prop's own note — then replaced
+ * with whatever `POST .../corrections` returns on a successful submit — see
+ * `SubmitTechStepCorrectionResult`'s doc comment, `packages/shared`) so a
+ * correction's effect (a new/relabeled/removed highlight) appears
+ * immediately, without needing the parent to re-fetch the whole recipe.
+ * Resynced whenever the `techSteps` prop itself changes (e.g. the parent
+ * reloaded the recipe for an unrelated reason) so this never keeps showing
+ * stale local state past that.
*/
export function StepDescription({
description,
@@ -88,15 +89,32 @@ export function StepDescription({
stepId,
}: {
description: string;
- techSteps: StepTechStepView[];
+ /**
+ * The step's detected technique sequence. `StepView.techSteps`
+ * (`packages/shared`) always sends an array — `[]` when nothing matched —
+ * but this is optional so a partial/not-yet-resolved payload degrades to
+ * "render the description as plain text" instead of crashing on
+ * `.flatMap`, the same "tolerate missing data, don't crash" posture this
+ * component already takes for a match with no `contextStart`/`contextEnd`
+ * or a temperature with no owning occurrence. Seen `undefined` in
+ * practice on the post-save navigation of the "edit an existing recipe"
+ * flow, before the reloaded recipe's detail had resolved
+ * (`recipe-form.feature`).
+ */
+ techSteps?: StepTechStepView[];
/** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
editable?: boolean;
recipeId?: number;
stepId?: number;
}) {
const { t } = useTranslation();
- const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
- useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
+ // `?? []` here, not just at each read site, so every consumer below
+ // (`.flatMap` for `temperatures`, `splitDescriptionSegments`,
+ // `findTechStepForTemperature`, the `activeStepTechStep` lookup) always
+ // has a real array — see the `techSteps` prop's own note for when it
+ // arrives absent.
+ const [liveTechSteps, setLiveTechSteps] = useState(techSteps ?? []);
+ useEffect(() => setLiveTechSteps(techSteps ?? []), [techSteps]);
// Temperatures are attached per technique-clause server-side; for the
// read-only highlight we only care *where* in the description they are,