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:
parent
2d28252f1a
commit
8635b54800
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue