From 69a038dfcba0b8a614327062232dec6a19d9c2f3 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Sun, 17 May 2026 08:40:18 +0700 Subject: [PATCH] fix: normalize viewport shape on FlowState.load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy/hand-written .drawd files may persist viewport as `{x,y,scale}` or omit pan/zoom entirely. The previous `data.viewport || default` only caught the missing-key branch, so files with the wrong shape passed through and crashed the next autosave with "Cannot read properties of undefined (reading 'x')" inside buildPayload. Add a normalizer at the read boundary that accepts canonical `{pan,zoom}`, legacy `{x,y,scale}`, partial, and missing inputs and always returns the canonical shape — keeping buildPayload as the single source of truth for the output shape. Add 5 regression tests including a load → save round-trip that rewrites a legacy-shape file in canonical form. --- mcp-server/src/state.js | 14 +++++++- mcp-server/src/state.test.js | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/mcp-server/src/state.js b/mcp-server/src/state.js index d55e404..5afafee 100644 --- a/mcp-server/src/state.js +++ b/mcp-server/src/state.js @@ -10,6 +10,18 @@ import { } from "../../src/constants.js"; import { gridPosition } from "./utils/grid-layout.js"; +// Normalize the viewport block to the canonical `{ pan: {x,y}, zoom }` shape +// regardless of whether the file used the canonical shape, the legacy +// `{ x, y, scale }` shape, or omitted any subset of those keys. +function normalizeViewport(v) { + if (!v || typeof v !== "object") return { pan: { x: 0, y: 0 }, zoom: 1 }; + const pan = v.pan && typeof v.pan === "object" ? v.pan : v; + return { + pan: { x: Number(pan.x) || 0, y: Number(pan.y) || 0 }, + zoom: Number(v.zoom ?? v.scale) || 1, + }; +} + export class FlowState { constructor() { this.screens = []; @@ -44,7 +56,7 @@ export class FlowState { this.screenGroups = data.screenGroups || []; this.comments = data.comments || []; this.metadata = data.metadata || {}; - this.viewport = data.viewport || { pan: { x: 0, y: 0 }, zoom: 1 }; + this.viewport = normalizeViewport(data.viewport); this.filePath = filePath; this._screenCounter = this.screens.length + 1; } diff --git a/mcp-server/src/state.test.js b/mcp-server/src/state.test.js index e913645..a6afecd 100644 --- a/mcp-server/src/state.test.js +++ b/mcp-server/src/state.test.js @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { FlowState } from "./state.js"; // Prevent filesystem writes in all tests by mocking _autoSave on each instance. @@ -248,3 +251,64 @@ describe("FlowState.createNew", () => { expect(state.comments).toEqual([]); }); }); + +// ── load: viewport shape normalization ──────────────────────────────────────── +// Regression: hand-written or legacy .drawd files may carry viewport as +// `{x,y,scale}` (or omit pan/zoom entirely). load() must normalize to +// `{pan:{x,y}, zoom}` so the subsequent auto-save through buildPayload (which +// reads `viewport.pan.x`) doesn't crash with "Cannot read properties of +// undefined (reading 'x')". + +describe("FlowState.load viewport normalization", () => { + const writeTmpFlow = (viewport) => { + const file = path.join(os.tmpdir(), `flowstate-load-${Date.now()}-${Math.random().toString(36).slice(2)}.drawd`); + const payload = { + version: 10, + metadata: { name: "t", featureBrief: "", taskLink: "", techStack: {} }, + screens: [], + connections: [], + documents: [], + dataModels: [], + stickyNotes: [], + screenGroups: [], + comments: [], + }; + if (viewport !== undefined) payload.viewport = viewport; + fs.writeFileSync(file, JSON.stringify(payload)); + return file; + }; + + const load = (viewport) => { + const file = writeTmpFlow(viewport); + const state = new FlowState(); + state.load(file); + fs.unlinkSync(file); + return state.viewport; + }; + + it("passes through the canonical {pan,zoom} shape", () => { + expect(load({ pan: { x: 12, y: 34 }, zoom: 0.75 })).toEqual({ pan: { x: 12, y: 34 }, zoom: 0.75 }); + }); + + it("normalizes legacy {x,y,scale} shape into {pan,zoom}", () => { + expect(load({ x: 100, y: 200, scale: 1.5 })).toEqual({ pan: { x: 100, y: 200 }, zoom: 1.5 }); + }); + + it("defaults missing viewport to origin/1x", () => { + expect(load(undefined)).toEqual({ pan: { x: 0, y: 0 }, zoom: 1 }); + }); + + it("defaults missing pan keys to 0 and missing zoom to 1", () => { + expect(load({ pan: {} })).toEqual({ pan: { x: 0, y: 0 }, zoom: 1 }); + }); + + it("does not crash on subsequent save after loading a legacy-shape viewport", () => { + const file = writeTmpFlow({ x: 5, y: 6, scale: 2 }); + const state = new FlowState(); + state.load(file); + expect(() => state.save()).not.toThrow(); + const reloaded = JSON.parse(fs.readFileSync(file, "utf-8")); + expect(reloaded.viewport).toEqual({ pan: { x: 5, y: 6 }, zoom: 2 }); + fs.unlinkSync(file); + }); +});