From 8635b5480091ef7b775f59537100e53b9dafc8d9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 15:14:26 -0700 Subject: [PATCH] fix(rflows): eliminate SVG NaN errors via comprehensive data sanitization Added safeNum() + migrateNodeData() to sanitize all numeric fields (including positions and allocation percentages) at every data loading boundary. The nullish coalescing operator (??) doesn't catch NaN, so corrupted Automerge/localStorage data cascaded NaN through pipe positions, port coordinates, and edge paths. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flows-app.ts | 18 ++---- modules/rflows/lib/types.ts | 69 ++++++++++++++++++--- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 2071c61..88489ac 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -12,7 +12,7 @@ */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation, MortgagePosition, ReinvestmentPosition, BudgetSegment } from "../lib/types"; -import { PORT_DEFS, migrateFunnelNodeData } from "../lib/types"; +import { PORT_DEFS, migrateFunnelNodeData, migrateNodeData } from "../lib/types"; import { TourEngine } from "../../../shared/tour-engine"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { demoNodes, simDemoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; @@ -269,7 +269,7 @@ class FolkFlowsApp extends HTMLElement { const flow = JSON.parse(raw) as CanvasFlow; this.currentFlowId = flow.id; this.flowName = flow.name; - this.nodes = flow.nodes.map((n: FlowNode) => ({ ...n, data: { ...n.data } })); + this.nodes = flow.nodes.map((n: any) => migrateNodeData(n)); this.restoreViewport(flow.id); this.render(); return; @@ -299,10 +299,7 @@ class FolkFlowsApp extends HTMLElement { const flow = doc.canvasFlows?.[this.currentFlowId]; if (flow && !this.saveTimer) { // Only update if we're not in the middle of saving - this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ - ...n, - data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data }, - }))); + this.nodes = computeInflowRates(flow.nodes.map((n: any) => migrateNodeData(n))); this.drawCanvasContent(); } }); @@ -352,10 +349,7 @@ class FolkFlowsApp extends HTMLElement { if (!flow) return; this.currentFlowId = flow.id; this.flowName = flow.name; - this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ - ...n, - data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data }, - }))); + this.nodes = computeInflowRates(flow.nodes.map((n: any) => migrateNodeData(n))); this.localFirstClient?.setActiveFlow(flowId); this.restoreViewport(flowId); this.loading = false; @@ -596,7 +590,7 @@ class FolkFlowsApp extends HTMLElement { const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`); if (res.ok) { const data = await res.json(); - this.nodes = mapFlowToNodes(data); + this.nodes = mapFlowToNodes(data).map((n: any) => migrateNodeData(n)); this.flowName = data.name || data.label || flowId; } else { this.error = `Flow not found (${res.status})`; @@ -5096,7 +5090,7 @@ class FolkFlowsApp extends HTMLElement { if (!json) return; const nodes = JSON.parse(json) as FlowNode[]; if (Array.isArray(nodes) && nodes.length > 0) { - this.nodes = nodes.map((n: any) => n.type === "funnel" ? { ...n, data: migrateFunnelNodeData(n.data) } : n); + this.nodes = nodes.map((n: any) => migrateNodeData(n)); this.drawCanvasContent(); this.fitView(); } diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 94de185..0462185 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -52,21 +52,76 @@ export interface FunnelNodeData { [key: string]: unknown; } +/** Coerce a value to a finite number, defaulting to `fallback` for NaN/undefined/null/Infinity. */ +function safeNum(v: unknown, fallback = 0): number { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + /** Migrate legacy FunnelNodeData (4-tier thresholds) to new 2-tier format. */ export function migrateFunnelNodeData(d: any): FunnelNodeData { return { label: d.label ?? "Funnel", - currentValue: d.currentValue ?? 0, - overflowThreshold: d.overflowThreshold ?? d.maxThreshold ?? 0, - capacity: d.capacity ?? d.maxCapacity ?? 0, - inflowRate: d.inflowRate ?? 0, - drainRate: d.drainRate ?? d.desiredOutflow ?? d.inflowRate ?? 0, - overflowAllocations: d.overflowAllocations ?? [], - spendingAllocations: d.spendingAllocations ?? [], + currentValue: safeNum(d.currentValue), + overflowThreshold: safeNum(d.overflowThreshold ?? d.maxThreshold), + capacity: safeNum(d.capacity ?? d.maxCapacity), + inflowRate: safeNum(d.inflowRate), + drainRate: safeNum(d.drainRate ?? d.desiredOutflow ?? d.inflowRate), + overflowAllocations: (d.overflowAllocations ?? []).map((a: any) => ({ ...a, percentage: safeNum(a.percentage) })), + spendingAllocations: (d.spendingAllocations ?? []).map((a: any) => ({ ...a, percentage: safeNum(a.percentage) })), source: d.source, }; } +/** Sanitize OutcomeNodeData, ensuring all numeric fields are finite. */ +export function migrateOutcomeNodeData(d: any): OutcomeNodeData { + return { + label: d.label ?? "Outcome", + description: d.description ?? "", + fundingReceived: safeNum(d.fundingReceived), + fundingTarget: safeNum(d.fundingTarget), + status: d.status || "not-started", + phases: d.phases, + overflowAllocations: d.overflowAllocations?.map((a: any) => ({ ...a, percentage: safeNum(a.percentage) })), + source: d.source, + }; +} + +/** Sanitize SourceNodeData, ensuring all numeric fields are finite. */ +export function migrateSourceNodeData(d: any): SourceNodeData { + return { + label: d.label ?? "Source", + flowRate: safeNum(d.flowRate), + sourceType: d.sourceType || "unconfigured", + targetAllocations: (d.targetAllocations ?? []).map((a: any) => ({ + targetId: a.targetId, + percentage: safeNum(a.percentage), + color: a.color ?? "#10b981", + })), + walletAddress: d.walletAddress, + chainId: d.chainId, + safeAddress: d.safeAddress, + transakOrderId: d.transakOrderId, + effectiveDate: d.effectiveDate, + }; +} + +/** Sanitize any FlowNode's data based on its type. */ +export function migrateNodeData(n: any): FlowNode { + const type = n.type; + let data; + if (type === "funnel") data = migrateFunnelNodeData(n.data); + else if (type === "outcome") data = migrateOutcomeNodeData(n.data); + else if (type === "source") data = migrateSourceNodeData(n.data); + else data = { ...n.data }; + return { + id: n.id, + type, + position: { x: safeNum(n.position?.x), y: safeNum(n.position?.y) }, + data, + }; +} + export interface PhaseTask { label: string; completed: boolean;