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 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 { TourEngine } from "../../../shared/tour-engine";
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, simDemoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; 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; const flow = JSON.parse(raw) as CanvasFlow;
this.currentFlowId = flow.id; this.currentFlowId = flow.id;
this.flowName = flow.name; 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.restoreViewport(flow.id);
this.render(); this.render();
return; return;
@ -299,10 +299,7 @@ class FolkFlowsApp extends HTMLElement {
const flow = doc.canvasFlows?.[this.currentFlowId]; const flow = doc.canvasFlows?.[this.currentFlowId];
if (flow && !this.saveTimer) { if (flow && !this.saveTimer) {
// Only update if we're not in the middle of saving // Only update if we're not in the middle of saving
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ this.nodes = computeInflowRates(flow.nodes.map((n: any) => migrateNodeData(n)));
...n,
data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data },
})));
this.drawCanvasContent(); this.drawCanvasContent();
} }
}); });
@ -352,10 +349,7 @@ class FolkFlowsApp extends HTMLElement {
if (!flow) return; if (!flow) return;
this.currentFlowId = flow.id; this.currentFlowId = flow.id;
this.flowName = flow.name; this.flowName = flow.name;
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ this.nodes = computeInflowRates(flow.nodes.map((n: any) => migrateNodeData(n)));
...n,
data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data },
})));
this.localFirstClient?.setActiveFlow(flowId); this.localFirstClient?.setActiveFlow(flowId);
this.restoreViewport(flowId); this.restoreViewport(flowId);
this.loading = false; this.loading = false;
@ -596,7 +590,7 @@ class FolkFlowsApp extends HTMLElement {
const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`); const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); 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; this.flowName = data.name || data.label || flowId;
} else { } else {
this.error = `Flow not found (${res.status})`; this.error = `Flow not found (${res.status})`;
@ -5096,7 +5090,7 @@ class FolkFlowsApp extends HTMLElement {
if (!json) return; if (!json) return;
const nodes = JSON.parse(json) as FlowNode[]; const nodes = JSON.parse(json) as FlowNode[];
if (Array.isArray(nodes) && nodes.length > 0) { 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.drawCanvasContent();
this.fitView(); this.fitView();
} }

View File

@ -52,21 +52,76 @@ export interface FunnelNodeData {
[key: string]: unknown; [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. */ /** Migrate legacy FunnelNodeData (4-tier thresholds) to new 2-tier format. */
export function migrateFunnelNodeData(d: any): FunnelNodeData { export function migrateFunnelNodeData(d: any): FunnelNodeData {
return { return {
label: d.label ?? "Funnel", label: d.label ?? "Funnel",
currentValue: d.currentValue ?? 0, currentValue: safeNum(d.currentValue),
overflowThreshold: d.overflowThreshold ?? d.maxThreshold ?? 0, overflowThreshold: safeNum(d.overflowThreshold ?? d.maxThreshold),
capacity: d.capacity ?? d.maxCapacity ?? 0, capacity: safeNum(d.capacity ?? d.maxCapacity),
inflowRate: d.inflowRate ?? 0, inflowRate: safeNum(d.inflowRate),
drainRate: d.drainRate ?? d.desiredOutflow ?? d.inflowRate ?? 0, drainRate: safeNum(d.drainRate ?? d.desiredOutflow ?? d.inflowRate),
overflowAllocations: d.overflowAllocations ?? [], overflowAllocations: (d.overflowAllocations ?? []).map((a: any) => ({ ...a, percentage: safeNum(a.percentage) })),
spendingAllocations: d.spendingAllocations ?? [], spendingAllocations: (d.spendingAllocations ?? []).map((a: any) => ({ ...a, percentage: safeNum(a.percentage) })),
source: d.source, 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 { export interface PhaseTask {
label: string; label: string;
completed: boolean; completed: boolean;