From 8e55f3d3f93fa67d1a2755ed23c45339dc99da02 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 09:04:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(recipes):=20rend=20StepDescription=20tol?= =?UTF-8?q?=C3=A9rant=20=C3=A0=20un=20techSteps=20absent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StepDescription faisait `useState(techSteps)` puis `liveTechSteps.flatMap(...)` : quand la prop `techSteps` arrivait `undefined`, `.flatMap` levait `TypeError: Cannot read properties of undefined (reading 'flatMap')`, remontée par Cypress comme une erreur applicative non capturée. Observé de façon intermittente sur le parcours « modifier une recette » : après enregistrement, `RecipeFormPage` navigue vers `/recettes/:id`, dont le détail se rendait parfois avant que `techSteps` ne soit peuplé. - `StepDescription` : `techSteps` devient optionnel et `liveTechSteps` retombe sur `[]` (dans le `useState` et le `useEffect` de resync), pour que tous les consommateurs (`.flatMap`, `splitDescriptionSegments`, `findTechStepForTemperature`, lookup `activeStepTechStep`) aient toujours un vrai tableau — même posture « tolérer la donnée manquante, ne pas planter » que le reste du composant. - `recipe-form.ts` : le mock de la recette 7 envoyait une étape sans `techSteps`, contrairement au vrai `StepView` et à tous les autres mocks de la suite — corrigé en `techSteps: []`. - `recipes.cy.ts` : nouveau test de non-régression déterministe — un payload d'étape sans `techSteps` doit rendre la description en texte brut. Vérifié qu'il échoue bien (`flatMap` undefined) sans le correctif du composant. Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/recipe-form.ts | 6 ++- apps/web/cypress/e2e/recipes.cy.ts | 44 +++++++++++++++++++ .../recipes/steps/StepDescription.tsx | 40 ++++++++++++----- 3 files changed, 78 insertions(+), 12 deletions(-) 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,