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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 15:14:26 -07:00
parent 2d28252f1a
commit 8635b54800
2 changed files with 68 additions and 19 deletions

View File

@ -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();
}

View File

@ -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;