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 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue