Export PES dialog silently submits stitch_objects:[] and swallows API 422 — export is a no-op from the UI #103

Closed
opened 2026-09-18 23:39:33 +00:00 by Mark · 1 comment
Owner

Bug 2: Export PES dialog silently fails — submits empty stitch_objects[], swallows API 422

Severity

High (core export path is a no-op in the most common UI path).

Reproduction

  1. Bring up easystitch-service on :8080 and the ThreadCraft dev server on :3000.
  2. Navigate to /workspace.
  3. Upload tests/fixtures/flower.png.
  4. Click Run Auto-Digitize. Wait for the workspace-canvas testid to appear
    (digitize completes).
  5. Click Export PES.
  6. Dialog opens with heading "Export Design", filename input, format chips
    (PES/DST/SVG/PDF), Hoop Size, and a line: "This design uses 0 Brother PR-10 thread stops".
  7. Click Export in the dialog.
  8. No file downloads. No toast. No UI feedback.

Expected: a .pes (or chosen format) file is downloaded via triggerDownload.
Actual: no download event fires. The browser stays idle.

Evidence

Captured dialog body verbatim from a live probe after a successful digitize:

Export Design
FILE NAME
FORMAT
PES
DST
SVG
PDF
Hoop Size
100×100mm (4×4)
This design uses 0 Brother PR-10 thread stops
Cancel
Export

Two symptoms:

  • The dialog reads 0 thread stops despite the workspace having just emitted
    stitch objects via commitDigitizedResult.
  • Clicking Export fires a POST to /api/easystitch/v1/export with
    stitch_objects: [], which the backend correctly rejects with
    422 {"detail":[{"type":"too_short","loc":["body","stitch_objects"],"msg":"List should have at least 1 item, not 0"}]}.

The frontend swallows the 422. No toast, no inline message, no dialog close,
no retry. The dialog stays open with the user staring at "Export" again.

Backend smoke test (rules out backend)

A direct curl with a hand-crafted payload of 1 stitch_object (8 points) returns:

http=200  size=1153 bytes  first 8 bytes = b'#PES0001\x98...'

Valid PES file. The backend export pipeline works correctly when given real
stitch_objects[]. The bug is entirely on the frontend:
src/components/workspace/ExportDialog.tsx line ~67.

Root cause (best guess from layout)

ExportDialog reads stitch objects via useWorkspace() and passes them to
exportPES(...). Either:

  • (a) The hook is reading from a stale slice of state and seeing 0 objects even
    though state.stitchObjects is populated, OR
  • (b) The serialization path is dropping the array.

Either way, no client-side guard exists ("if stitch_objects.length === 0, show
inline error"), and the catch block around exportPES does not surface
EasystitchError as a toast.

Suggested fix

In ExportDialog.tsx:

const objects = stitchObjects; // whatever the hook currently reads
if (!objects || objects.length === 0) {
  setInlineError('Design has no stitch objects yet — finish digitize or load a saved design first.');
  return;
}
// existing exportPES call...
try {
  const result = await exportPES(...);
  triggerDownload(...);
} catch (err) {
  if (err instanceof EasystitchError) {
    setInlineError(`Export failed (${err.status}): ${err.message}`);
  } else {
    throw err;
  }
}

Plus investigate why the dialog sees 0 thread stops when the Redux state has them.

  • #71 (closed) — Editor Export PES button had no onClick; dialog itself wasn't
    tested end-to-end after that fix.
  • #96 (closed) — fix for digitize payload committing real stitch geometry.
    This bug is the export side of that same pipeline — proving the design
    exists in state isn't proving the dialog can read it back.
# Bug 2: Export PES dialog silently fails — submits empty `stitch_objects[]`, swallows API 422 ## Severity High (core export path is a no-op in the most common UI path). ## Reproduction 1. Bring up easystitch-service on :8080 and the ThreadCraft dev server on :3000. 2. Navigate to `/workspace`. 3. Upload `tests/fixtures/flower.png`. 4. Click **Run Auto-Digitize**. Wait for the workspace-canvas testid to appear (digitize completes). 5. Click **Export PES**. 6. Dialog opens with heading "Export Design", filename input, format chips (PES/DST/SVG/PDF), Hoop Size, and a line: **"This design uses 0 Brother PR-10 thread stops"**. 7. Click **Export** in the dialog. 8. **No file downloads. No toast. No UI feedback.** Expected: a `.pes` (or chosen format) file is downloaded via `triggerDownload`. Actual: no `download` event fires. The browser stays idle. ## Evidence Captured dialog body verbatim from a live probe after a successful digitize: ``` Export Design FILE NAME FORMAT PES DST SVG PDF Hoop Size 100×100mm (4×4) This design uses 0 Brother PR-10 thread stops Cancel Export ``` Two symptoms: - The dialog reads `0 thread stops` despite the workspace having just emitted stitch objects via `commitDigitizedResult`. - Clicking **Export** fires a POST to `/api/easystitch/v1/export` with `stitch_objects: []`, which the backend correctly rejects with `422 {"detail":[{"type":"too_short","loc":["body","stitch_objects"],"msg":"List should have at least 1 item, not 0"}]}`. The frontend swallows the 422. No toast, no inline message, no dialog close, no retry. The dialog stays open with the user staring at "Export" again. ## Backend smoke test (rules out backend) A direct curl with a hand-crafted payload of 1 stitch_object (8 points) returns: ``` http=200 size=1153 bytes first 8 bytes = b'#PES0001\x98...' ``` Valid PES file. The backend export pipeline works correctly when given real `stitch_objects[]`. The bug is **entirely on the frontend**: `src/components/workspace/ExportDialog.tsx` line ~67. ## Root cause (best guess from layout) `ExportDialog` reads stitch objects via `useWorkspace()` and passes them to `exportPES(...)`. Either: - (a) The hook is reading from a stale slice of state and seeing 0 objects even though `state.stitchObjects` is populated, OR - (b) The serialization path is dropping the array. Either way, no client-side guard exists ("if stitch_objects.length === 0, show inline error"), and the catch block around `exportPES` does not surface `EasystitchError` as a toast. ## Suggested fix In `ExportDialog.tsx`: ```ts const objects = stitchObjects; // whatever the hook currently reads if (!objects || objects.length === 0) { setInlineError('Design has no stitch objects yet — finish digitize or load a saved design first.'); return; } // existing exportPES call... try { const result = await exportPES(...); triggerDownload(...); } catch (err) { if (err instanceof EasystitchError) { setInlineError(`Export failed (${err.status}): ${err.message}`); } else { throw err; } } ``` Plus investigate why the dialog sees 0 thread stops when the Redux state has them. ## Related - #71 (closed) — Editor Export PES button had no onClick; dialog itself wasn't tested end-to-end after that fix. - #96 (closed) — fix for digitize payload committing real stitch geometry. This bug is the *export side* of that same pipeline — proving the design exists in state isn't proving the dialog can read it back.
Author
Owner

Closed by ed6b7a4 on main.

Fix:

  • AppHeader: new canExport prop; Export PES button is now disabled when no stitch objects exist (with tooltip explaining why).
  • Workspace: wires canExport = appState === 'digitized' && stitchObjects.length > 0 so the button is only clickable after a successful digitize.
  • ExportDialog: empty-state guard moved to the top of handleExport (no API call when geometry is empty) and an inline error banner (data-testid="export-inline-error") renders inside the dialog so the empty-state is self-explanatory instead of a transient toast.

Tests: 12 ExportDialog + 10 AppHeader pass (3 new tests each); tsc -b + eslint clean.

Closed by ed6b7a4 on main. Fix: - AppHeader: new `canExport` prop; Export PES button is now disabled when no stitch objects exist (with tooltip explaining why). - Workspace: wires `canExport = appState === 'digitized' && stitchObjects.length > 0` so the button is only clickable after a successful digitize. - ExportDialog: empty-state guard moved to the top of handleExport (no API call when geometry is empty) and an inline error banner (`data-testid="export-inline-error"`) renders inside the dialog so the empty-state is self-explanatory instead of a transient toast. Tests: 12 ExportDialog + 10 AppHeader pass (3 new tests each); tsc -b + eslint clean.
Mark closed this issue 2026-09-19 00:07:21 +00:00
Sign in to join this conversation.
No description provided.