This is an automated email from the ASF dual-hosted git repository.
scottyaslan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 48b2987d710 NIFI-16025 Canvas SVG transform overflow guards (#11352)
48b2987d710 is described below
commit 48b2987d7109c8736833fac5771abcd1e1f815be
Author: Rob Fellows <[email protected]>
AuthorDate: Thu Jul 2 13:31:10 2026 -0400
NIFI-16025 Canvas SVG transform overflow guards (#11352)
Prevent browser SVG errors and frozen navigation caused by extreme finite
floating-point coordinate values (e.g. 7.49e+307) stored in NiFi flow
definitions. The root cause is that Number.isFinite() accepts arbitrarily
large finite numbers that overflow float32 rendering and D3 arithmetic;
this change adds a layered defence of magnitude- and range-bounded guards
throughout the frontend canvas pipeline.
## Shared helpers (libs/shared)
Add canvas-bounds.util.ts with:
- MAX_ABS_COORD (1e9) and MAX_ABS_TRANSLATE (1e12) magnitude bounds
- MIN_SCALE (0.2) / MAX_SCALE (8) scale range, centralising the values
previously hardcoded in canvas-view.service.ts
- isFiniteInBound(value, bound) — type guard combining Number.isFinite
and Math.abs check
- isScaleInBound(value) — rejects near-zero and out-of-range scales
- clampScale(scale, fallback?) — NaN-safe clamp
- sanitizePosition(position, opts) — clamps bad coordinates to (0, 0)
with a warn-once, per-component-id console warning
Re-export from services/index.ts. Full spec coverage (30 tests) including
the ~7.49e+307 fingerprint and the warn-once dedupe.
## Data-ingestion sanitization
flow.effects.ts (loadProcessGroup$): walk all positioned entity collections
(processors, process groups, remote process groups, input/output ports,
labels, funnels) and connection bends through sanitizePosition before the
NgRx action is dispatched. A session-scoped warnedPositionIds Set prevents
console spam during polling refreshes.
connector-canvas.effects.ts (loadConnectorFlow$): same sanitizer applied to
all collections. Even the read-only connector view uses the reusable canvas,
so its inputs must be clean.
## Flow-designer transform guards
canvas-view.service.ts:
- Zoom handler: replace !isNaN checks with isFiniteInBound + isScaleInBound
- transform(): early-return guard before behavior.transform() call
- getSelectionBoundingClientRect(): return null on empty selection; swap
Number.MAX_VALUE / MIN_VALUE sentinel seeds for POSITIVE/NEGATIVE_INFINITY
so negative-space selections aggregate correctly
- getCanvasPosition(): reject results outside MAX_ABS_COORD so bad
coordinates cannot be written back to the server
- fit(): guard zero-dimension container; use clampScale helper
birdseye-view.service.ts (refresh()): bail early before divide-by-scale
arithmetic when this.x / this.y / this.k are out of bounds.
transform.effects.ts (restoreViewport$): upgrade isFinite checks to
isFiniteInBound + isScaleInBound; call storage.removeItem(name) when the
persisted entry is invalid, breaking the page-refresh replay loop.
## Reusable canvas / birdseye guards
ui/common/canvas/canvas.component.ts:
- readViewportTransform: upgrade isFinite to magnitude-bounded checks
- Zoom handler: all-or-nothing guard before SVG attribute write and emit
- applyTransform: magnitude+scale guard at top of method
- restoreViewportFromStorage: evict bad localStorage entries on load
- fitContent: clampScale instead of raw Math.min/max
- getCanvasPosition: reject out-of-bounds output
ui/common/birdseye/birdseye.component.ts: add isTransformSafe predicate;
consult it in updateBrush and renderComponents before any divide-by-scale
render path.
## Tests
- canvas-bounds.util.spec.ts: 30 new tests
- canvas-view.service.spec.ts: 12 new tests (mountCanvasDom fixture)
- transform.effects.spec.ts: 5 new tests (new file)
- birdseye-view.service.spec.ts: 3 new tests
- birdseye.component.spec.ts: 4 new tests; update scale-0.1 test to use
valid MAX_SCALE to keep the minimum-brush-size assertion accurate
- canvas.component.spec.ts: 10 new tests
- flow.effects.spec.ts: 4 new tests
- connector-canvas.effects.spec.ts: 3 new tests; update bare label
fixture to carry a position so it survives sanitization unchanged
All 2700 tests pass. nx lint nifi and npm run prettier clean.
---
.../connector-canvas.effects.spec.ts | 85 +++++++-
.../connector-canvas/connector-canvas.effects.ts | 90 +++++++-
.../service/birdseye-view.service.spec.ts | 29 +++
.../flow-designer/service/birdseye-view.service.ts | 13 +-
.../service/canvas-view.service.spec.ts | 176 ++++++++++++++++
.../flow-designer/service/canvas-view.service.ts | 87 ++++++--
.../flow-designer/state/flow/flow.effects.spec.ts | 106 ++++++++++
.../pages/flow-designer/state/flow/flow.effects.ts | 70 ++++++-
.../state/transform/transform.effects.spec.ts | 138 +++++++++++++
.../state/transform/transform.effects.ts | 13 +-
.../ui/common/birdseye/birdseye.component.spec.ts | 41 +++-
.../app/ui/common/birdseye/birdseye.component.ts | 20 +-
.../app/ui/common/canvas/canvas.component.spec.ts | 88 ++++++++
.../src/app/ui/common/canvas/canvas.component.ts | 68 ++++++-
.../shared/src/services/canvas-bounds.util.spec.ts | 226 +++++++++++++++++++++
.../libs/shared/src/services/canvas-bounds.util.ts | 147 ++++++++++++++
.../frontend/libs/shared/src/services/index.ts | 1 +
17 files changed, 1357 insertions(+), 41 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
index 67c59d342f0..671c2dcd008 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
@@ -197,7 +197,7 @@ describe('ConnectorCanvasEffects', () => {
parentGroupId: 'parent-456',
breadcrumb: null,
flow: {
- labels: [{ id: 'l1' }],
+ labels: [{ id: 'l1', position: { x: 10, y: 20 } }],
funnels: [],
inputPorts: [],
outputPorts: [],
@@ -226,7 +226,7 @@ describe('ConnectorCanvasEffects', () => {
processGroupId: 'pg-123',
parentProcessGroupId: 'parent-456',
breadcrumb: null,
- labels: [{ id: 'l1' }],
+ labels: [{ id: 'l1', position: { x: 10, y: 20 } }],
funnels: [],
inputPorts: [],
outputPorts: [],
@@ -1450,4 +1450,85 @@ describe('ConnectorCanvasEffects', () => {
expect(result).toEqual(ErrorActions.addBannerError({ errorContext
}));
});
});
+
+ describe('loadConnectorFlow$ — position sanitization (NIFI-16025)', () => {
+ function makeEntity(id: string, x: number, y: number): any {
+ return {
+ id,
+ permissions: { canRead: true, canWrite: true },
+ revision: { version: 0 },
+ position: { x, y },
+ component: {}
+ };
+ }
+
+ function makeFlowResponse(overrides: { processors?: any[];
connections?: any[] } = {}): any {
+ return {
+ processGroupFlow: {
+ id: 'pg-123',
+ parentGroupId: null,
+ breadcrumb: null,
+ flow: {
+ labels: [],
+ funnels: [],
+ inputPorts: [],
+ outputPorts: [],
+ remoteProcessGroups: [],
+ processGroups: [],
+ processors: overrides.processors ?? [],
+ connections: overrides.connections ?? []
+ }
+ }
+ };
+ }
+
+ it('passes in-bounds positions through unchanged', async () => {
+ const { effects, actions$, mockConnectorService } = await setup();
+ const entity = makeEntity('p-1', 100, 200);
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of(makeFlowResponse({ processors: [entity] }))
+ );
+ actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
+
+ const action: any = await
firstValueFrom(effects.loadConnectorFlow$);
+ expect(action.processors[0].position).toEqual({ x: 100, y: 200 });
+ });
+
+ it('clamps catastrophic-finite processor positions to (0, 0)', async
() => {
+ const { effects, actions$, mockConnectorService } = await setup();
+ const entity = makeEntity('p-corrupt', 7.490388061926315e307, 0);
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of(makeFlowResponse({ processors: [entity] }))
+ );
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
+ actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
+
+ const action: any = await
firstValueFrom(effects.loadConnectorFlow$);
+ expect(action.processors[0].position).toEqual({ x: 0, y: 0 });
+ expect(warnSpy).toHaveBeenCalled();
+ warnSpy.mockRestore();
+ });
+
+ it('clamps catastrophic-finite connection bends to (0, 0)', async ()
=> {
+ const { effects, actions$, mockConnectorService } = await setup();
+ const connection = {
+ id: 'conn-bad',
+ permissions: { canRead: true, canWrite: true },
+ revision: { version: 0 },
+ position: { x: 0, y: 0 },
+ component: { bends: [{ x: 9e307, y: 0 }] }
+ };
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of(makeFlowResponse({ connections: [connection] }))
+ );
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
+ actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
+
+ const action: any = await
firstValueFrom(effects.loadConnectorFlow$);
+ expect(action.connections[0].component.bends[0]).toEqual({ x: 0,
y: 0 });
+ warnSpy.mockRestore();
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
index 82361e1e0b5..c8cec7a5804 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
@@ -34,7 +34,15 @@ import {
tap,
throttleTime
} from 'rxjs/operators';
-import { ComponentType, ComponentTypeNamePipe, LARGE_DIALOG, MEDIUM_DIALOG,
NiFiCommon, XL_DIALOG } from '@nifi/shared';
+import {
+ ComponentType,
+ ComponentTypeNamePipe,
+ LARGE_DIALOG,
+ MEDIUM_DIALOG,
+ NiFiCommon,
+ sanitizePosition,
+ XL_DIALOG
+} from '@nifi/shared';
import { selectCurrentUser } from
'../../../../state/current-user/current-user.selectors';
import { selectPrioritizerTypes } from
'../../../../state/extension-types/extension-types.selectors';
import {
@@ -94,6 +102,12 @@ export class ConnectorCanvasEffects {
*/
private lastReload = 0;
+ /**
+ * Warn-once dedupe state for position sanitization: emits at most one
+ * console.warn per component id for the lifetime of this effects instance.
+ */
+ private readonly warnedPositionIds = new Set<string>();
+
constructor() {
// When the document becomes visible after having been hidden long
enough
// for the polling interval to be skipped, trigger a reload so the
canvas
@@ -123,14 +137,16 @@ export class ConnectorCanvasEffects {
map((flowResponse) => {
const processGroupFlow = flowResponse.processGroupFlow;
const flow = processGroupFlow?.flow;
- const labels = flow?.labels || [];
- const funnels = flow?.funnels || [];
- const inputPorts = flow?.inputPorts || [];
- const outputPorts = flow?.outputPorts || [];
- const remoteProcessGroups = flow?.remoteProcessGroups
|| [];
- const processGroups = flow?.processGroups || [];
- const processors = flow?.processors || [];
- const connections = flow?.connections || [];
+ const {
+ labels,
+ funnels,
+ inputPorts,
+ outputPorts,
+ remoteProcessGroups,
+ processGroups,
+ processors,
+ connections
+ } = this.sanitizeConnectorFlowPositions(flow);
// Extract process group IDs for navigation
const processGroupId = processGroupFlow?.id ?? null;
@@ -781,4 +797,60 @@ export class ConnectorCanvasEffects {
});
dialogRef.componentInstance.saving$ = of(false);
}
+
+ /**
+ * Sanitize all positions in a connector flow snapshot before they enter
NgRx state.
+ * Even though the connector canvas is read-only, it renders via the same
reusable canvas
+ * component, so out-of-range coordinates must be clamped here to prevent
SVG overflow.
+ */
+ private sanitizeConnectorFlowPositions(flow: any): {
+ labels: any[];
+ funnels: any[];
+ inputPorts: any[];
+ outputPorts: any[];
+ remoteProcessGroups: any[];
+ processGroups: any[];
+ processors: any[];
+ connections: any[];
+ } {
+ const sanitize = (entity: any, kind: string) => ({
+ ...entity,
+ position: sanitizePosition(entity.position, {
+ componentId: entity.id,
+ componentKind: kind,
+ warnedIds: this.warnedPositionIds
+ })
+ });
+ const sanitizeConnection = (entity: any) => ({
+ ...entity,
+ position: sanitizePosition(entity.position, {
+ componentId: entity.id,
+ componentKind: 'Connection',
+ warnedIds: this.warnedPositionIds
+ }),
+ component: entity.component
+ ? {
+ ...entity.component,
+ bends: entity.component.bends?.map((bend: any, index:
number) =>
+ sanitizePosition(bend, {
+ componentId: `${entity.id}:bend:${index}`,
+ componentKind: 'Connection bend',
+ warnedIds: this.warnedPositionIds
+ })
+ )
+ }
+ : entity.component
+ });
+
+ return {
+ labels: (flow?.labels || []).map((e: any) => sanitize(e, 'Label')),
+ funnels: (flow?.funnels || []).map((e: any) => sanitize(e,
'Funnel')),
+ inputPorts: (flow?.inputPorts || []).map((e: any) => sanitize(e,
'Input Port')),
+ outputPorts: (flow?.outputPorts || []).map((e: any) => sanitize(e,
'Output Port')),
+ remoteProcessGroups: (flow?.remoteProcessGroups || []).map((e:
any) => sanitize(e, 'Remote Process Group')),
+ processGroups: (flow?.processGroups || []).map((e: any) =>
sanitize(e, 'Process Group')),
+ processors: (flow?.processors || []).map((e: any) => sanitize(e,
'Processor')),
+ connections: (flow?.connections || []).map(sanitizeConnection)
+ };
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.spec.ts
index aae67733339..ba4f41bfa46 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.spec.ts
@@ -80,4 +80,33 @@ describe('BirdseyeView', () => {
it('should be created', () => {
expect(service).toBeTruthy();
});
+
+ describe('refresh() — magnitude guard', () => {
+ it('returns early without throwing when translateX is
catastrophic-finite', () => {
+ service['x'] = 7.490388061926315e307;
+ service['y'] = 0;
+ service['k'] = 1;
+ service['initialized'] = true;
+ service['navigationCollapsed'] = false;
+ expect(() => service.refresh()).not.toThrow();
+ });
+
+ it('returns early without throwing when k is near-zero', () => {
+ service['x'] = 0;
+ service['y'] = 0;
+ service['k'] = 0.0001;
+ service['initialized'] = true;
+ service['navigationCollapsed'] = false;
+ expect(() => service.refresh()).not.toThrow();
+ });
+
+ it('returns early when k is Infinity', () => {
+ service['x'] = 0;
+ service['y'] = 0;
+ service['k'] = Infinity;
+ service['initialized'] = true;
+ service['navigationCollapsed'] = false;
+ expect(() => service.refresh()).not.toThrow();
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.ts
index 0b121294329..55624b8bbdf 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/birdseye-view.service.ts
@@ -32,7 +32,7 @@ import { LabelManager } from
'./manager/label-manager.service';
import { selectNavigationCollapsed } from '../state/flow/flow.selectors';
import { initialState } from '../state/flow/flow.reducer';
import { CanvasUtils } from './canvas-utils.service';
-import { NiFiCommon } from '@nifi/shared';
+import { isFiniteInBound, isScaleInBound, MAX_ABS_TRANSLATE, NiFiCommon } from
'@nifi/shared';
import { ComponentEntityWithDimensions } from '../state/flow';
@Injectable({
@@ -157,6 +157,17 @@ export class BirdseyeView {
return;
}
+ // Bail early when the current transform is degenerate: dividing by an
out-of-range or
+ // near-zero k would produce Infinity in the downstream arithmetic and
corrupt every
+ // birdseye coordinate. The next valid transformComplete dispatch will
redraw.
+ if (
+ !isFiniteInBound(this.x, MAX_ABS_TRANSLATE) ||
+ !isFiniteInBound(this.y, MAX_ABS_TRANSLATE) ||
+ !isScaleInBound(this.k)
+ ) {
+ return;
+ }
+
// scale the translation
const translate: [number, number] = [this.x / this.k, this.y / this.k];
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.spec.ts
index 524fcfd98f9..7f67d99fe48 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.spec.ts
@@ -16,6 +16,7 @@
*/
import { TestBed } from '@angular/core/testing';
+import * as d3 from 'd3';
import { CanvasView } from './canvas-view.service';
import { CanvasState } from '../state';
@@ -36,6 +37,47 @@ import { selectFlowConfiguration } from
'../../../state/flow-configuration/flow-
import * as fromFlowConfiguration from
'../../../state/flow-configuration/flow-configuration.reducer';
import { flowAnalysisFeatureKey } from '../state/flow-analysis';
import * as fromFlowAnalysis from
'../state/flow-analysis/flow-analysis.reducer';
+import { MAX_ABS_TRANSLATE } from '@nifi/shared';
+
+/**
+ * Attach a real DOM structure that CanvasView.init() and its guard paths
+ * depend on. Returns cleanup/spy handles.
+ */
+function mountCanvasDom(service: CanvasView) {
+ const container = document.createElement('div');
+ container.id = 'canvas-container';
+ document.body.appendChild(container);
+
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg',
'svg');
+ svgEl.id = 'canvas';
+ container.appendChild(svgEl);
+
+ const svg = d3.select(svgEl);
+ const canvasGroupEl =
document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ svgEl.appendChild(canvasGroupEl);
+ const canvas = d3.select(canvasGroupEl);
+
+ // Mock behavior.transform so tests can spy on what value is applied
+ const transformSpy = vi.fn();
+ service['behavior'] = {
+ transform: transformSpy,
+ translateBy: vi.fn(),
+ scaleBy: vi.fn(),
+ scaleExtent: () => service['behavior'],
+ on: () => service['behavior']
+ };
+ service['svg'] = svg;
+ service['canvas'] = canvas;
+
+ return {
+ container,
+ svgEl,
+ transformSpy,
+ cleanup() {
+ document.body.removeChild(container);
+ }
+ };
+}
describe('CanvasView', () => {
let service: CanvasView;
@@ -80,4 +122,138 @@ describe('CanvasView', () => {
it('should be created', () => {
expect(service).toBeTruthy();
});
+
+ describe('CanvasView transform safety', () => {
+ describe('transform() — early-return guard', () => {
+ it('calls behavior.transform for a valid translate and scale', ()
=> {
+ const { cleanup, transformSpy } = mountCanvasDom(service);
+ service.transform([100, 200], 1);
+ expect(transformSpy).toHaveBeenCalledTimes(1);
+ cleanup();
+ });
+
+ it('rejects a catastrophic-finite translateX (~9e307)', () => {
+ const { cleanup, transformSpy } = mountCanvasDom(service);
+ service.transform([9e307, 0], 1);
+ expect(transformSpy).not.toHaveBeenCalled();
+ cleanup();
+ });
+
+ it('rejects a catastrophic-finite translateY', () => {
+ const { cleanup, transformSpy } = mountCanvasDom(service);
+ service.transform([0, MAX_ABS_TRANSLATE + 1], 1);
+ expect(transformSpy).not.toHaveBeenCalled();
+ cleanup();
+ });
+
+ it('rejects Infinity in translate', () => {
+ const { cleanup, transformSpy } = mountCanvasDom(service);
+ service.transform([Number.POSITIVE_INFINITY, 0], 1);
+ expect(transformSpy).not.toHaveBeenCalled();
+ cleanup();
+ });
+
+ it('rejects NaN translate', () => {
+ const { cleanup, transformSpy } = mountCanvasDom(service);
+ service.transform([NaN, 0], 1);
+ expect(transformSpy).not.toHaveBeenCalled();
+ cleanup();
+ });
+
+ it('rejects a scale above MAX_SCALE', () => {
+ const { cleanup, transformSpy } = mountCanvasDom(service);
+ service.transform([0, 0], 100);
+ expect(transformSpy).not.toHaveBeenCalled();
+ cleanup();
+ });
+
+ it('rejects a near-zero scale (division-by-zero hazard)', () => {
+ const { cleanup, transformSpy } = mountCanvasDom(service);
+ service.transform([0, 0], 0.0001);
+ expect(transformSpy).not.toHaveBeenCalled();
+ cleanup();
+ });
+ });
+
+ describe('getSelectionBoundingClientRect()', () => {
+ it('returns null for an empty d3 selection', () => {
+ const { cleanup } = mountCanvasDom(service);
+ const emptySelection =
d3.selectAll('.nonexistent-element-12345');
+ const result =
service.getSelectionBoundingClientRect(emptySelection);
+ expect(result).toBeNull();
+ cleanup();
+ });
+
+ it('returns a bbox object for a non-empty selection', () => {
+ const { container, cleanup } = mountCanvasDom(service);
+ const node =
document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ container.appendChild(node);
+ // Stub getBoundingClientRect for the test environment
+ node.getBoundingClientRect = () => ({ x: 10, y: 20, right:
110, bottom: 120 }) as DOMRect;
+ const sel = d3.select(node);
+ service['x'] = 0;
+ service['y'] = 0;
+ service['k'] = 1;
+ const result = service.getSelectionBoundingClientRect(sel);
+ expect(result).not.toBeNull();
+ expect(result?.width).toBeGreaterThan(0);
+ cleanup();
+ });
+ });
+
+ describe('getCanvasPosition()', () => {
+ it('returns a position when transform is valid', () => {
+ const { container, cleanup } = mountCanvasDom(service);
+ container.getBoundingClientRect = () => ({ left: 0, top: 0,
width: 800, height: 600 }) as DOMRect;
+ service['k'] = 1;
+ service['x'] = 0;
+ service['y'] = 0;
+ const result = service.getCanvasPosition({ x: 400, y: 300 });
+ expect(result).not.toBeNull();
+ expect(Number.isFinite(result!.x)).toBe(true);
+ cleanup();
+ });
+
+ it('returns null when k is near-zero (overflow result exceeds
MAX_ABS_COORD)', () => {
+ const { container, cleanup } = mountCanvasDom(service);
+ container.getBoundingClientRect = () => ({ left: 0, top: 0,
width: 800, height: 600 }) as DOMRect;
+ // k = 1e-15 produces x = 400 / 1e-15 = 4e17, way above
MAX_ABS_COORD
+ service['k'] = 1e-15;
+ service['x'] = 0;
+ service['y'] = 0;
+ const result = service.getCanvasPosition({ x: 400, y: 300 });
+ expect(result).toBeNull();
+ cleanup();
+ });
+
+ it('returns null when k is NaN', () => {
+ const { container, cleanup } = mountCanvasDom(service);
+ container.getBoundingClientRect = () => ({ left: 0, top: 0,
width: 800, height: 600 }) as DOMRect;
+ service['k'] = NaN;
+ service['x'] = 0;
+ service['y'] = 0;
+ const result = service.getCanvasPosition({ x: 400, y: 300 });
+ expect(result).toBeNull();
+ cleanup();
+ });
+ });
+
+ describe('fit() — zero-dimension container guard', () => {
+ it('returns early without throwing when container has zero width',
() => {
+ const { container, cleanup } = mountCanvasDom(service);
+ container.getBoundingClientRect = () => ({ width: 0, height:
600 }) as DOMRect;
+ service['k'] = 1;
+ expect(() => service.fit(false)).not.toThrow();
+ cleanup();
+ });
+
+ it('returns early without throwing when container has zero
height', () => {
+ const { container, cleanup } = mountCanvasDom(service);
+ container.getBoundingClientRect = () => ({ width: 800, height:
0 }) as DOMRect;
+ service['k'] = 1;
+ expect(() => service.fit(false)).not.toThrow();
+ cleanup();
+ });
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
index a5584e4b81d..55ed5af55d3 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
@@ -31,6 +31,15 @@ import { ConnectionManager } from
'./manager/connection-manager.service';
import { deselectAllComponents } from '../state/flow/flow.actions';
import { CanvasUtils } from './canvas-utils.service';
import { Position } from '../state/shared';
+import {
+ clampScale,
+ isFiniteInBound,
+ isScaleInBound,
+ MAX_ABS_COORD,
+ MAX_ABS_TRANSLATE,
+ MAX_SCALE,
+ MIN_SCALE
+} from '@nifi/shared';
@Injectable({
providedIn: 'root'
@@ -47,8 +56,6 @@ export class CanvasView {
private connectionManager = inject(ConnectionManager);
private static readonly INCREMENT: number = 1.2;
- private static readonly MAX_SCALE: number = 8;
- private static readonly MIN_SCALE: number = 0.2;
private static readonly MIN_SCALE_TO_RENDER: number = 0.4;
private svg: any;
@@ -72,16 +79,19 @@ export class CanvasView {
// define the behavior
this.behavior = d3
.zoom()
- .scaleExtent([CanvasView.MIN_SCALE, CanvasView.MAX_SCALE])
+ .scaleExtent([MIN_SCALE, MAX_SCALE])
.on('zoom', (event: any) => {
- // update the local translation and scale
- if (!isNaN(event.transform.x)) {
+ // Reject any translate component that is non-finite or
catastrophically large, and
+ // reject scales outside [MIN_SCALE, MAX_SCALE] (also guards
near-zero k that causes
+ // division-by-zero in birdseye arithmetic). Per-axis
partial-commit so a bad x
+ // does not discard a valid y or k.
+ if (isFiniteInBound(event.transform.x, MAX_ABS_TRANSLATE)) {
this.x = event.transform.x;
}
- if (!isNaN(event.transform.y)) {
+ if (isFiniteInBound(event.transform.y, MAX_ABS_TRANSLATE)) {
this.y = event.transform.y;
}
- if (!isNaN(event.transform.k)) {
+ if (isScaleInBound(event.transform.k)) {
this.k = event.transform.k;
}
@@ -412,6 +422,10 @@ export class CanvasView {
bbox = this.getSelectionBoundingClientRect(selection);
}
+ if (!bbox) {
+ return;
+ }
+
this.allowTransition = allowTransition;
this.centerBoundingBox(bbox);
this.allowTransition = false;
@@ -448,8 +462,13 @@ export class CanvasView {
/**
* Get a BoundingClientRect, normalized to the canvas, that encompasses
all nodes in a given selection.
+ * Returns null when the selection is empty.
*/
- public getSelectionBoundingClientRect(selection: any): any {
+ public getSelectionBoundingClientRect(selection: any): any | null {
+ if (selection.empty()) {
+ return null;
+ }
+
let yOffset = 0;
const canvasContainer: any =
document.getElementById('canvas-container');
@@ -457,11 +476,15 @@ export class CanvasView {
yOffset = canvasContainer.getBoundingClientRect().top;
}
+ // Use POSITIVE/NEGATIVE_INFINITY as sentinel seeds so that selections
+ // that live entirely in negative canvas space are aggregated
correctly.
+ // Number.MAX_VALUE / Number.MIN_VALUE seeds would clamp the aggregate
+ // to positive space and produce an incorrect bounding box.
const initialBBox: any = {
- x: Number.MAX_VALUE,
- y: Number.MAX_VALUE,
- right: Number.MIN_VALUE,
- bottom: Number.MIN_VALUE
+ x: Number.POSITIVE_INFINITY,
+ y: Number.POSITIVE_INFINITY,
+ right: Number.NEGATIVE_INFINITY,
+ bottom: Number.NEGATIVE_INFINITY
};
const bbox = selection.nodes().reduce((aggregateBBox: any, node: any)
=> {
@@ -520,6 +543,13 @@ export class CanvasView {
const x = canvasDropPoint.x / this.k - this.x / this.k;
const y = canvasDropPoint.y / this.k - this.y / this.k;
+ // Reject any result that is outside the canvas-coordinate bound
before it leaves
+ // this function: this is the write-side path that originally
persists bad coordinates,
+ // so a corrupted transform must not produce a coordinate that
re-poisons the backend.
+ if (!isFiniteInBound(x, MAX_ABS_COORD) || !isFiniteInBound(y,
MAX_ABS_COORD)) {
+ return null;
+ }
+
return { x, y };
}
@@ -611,6 +641,15 @@ export class CanvasView {
* @param scale
*/
public transform(translate: any, scale: any): void {
+ // Reject degenerate transforms before they can overwrite d3's
internal zoom state.
+ if (
+ !translate ||
+ !isFiniteInBound(translate[0], MAX_ABS_TRANSLATE) ||
+ !isFiniteInBound(translate[1], MAX_ABS_TRANSLATE) ||
+ !isScaleInBound(scale)
+ ) {
+ return;
+ }
this.behavior.transform(this.svg,
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale));
}
@@ -642,10 +681,21 @@ export class CanvasView {
// get the canvas normalized width and height
const canvasContainer: any =
document.getElementById('canvas-container');
+ if (canvasContainer == null) {
+ // DOM not yet mounted (e.g. called from the zoomFit fallback in
restoreViewport$ before
+ // the canvas element exists). Nothing to fit — skip rather than
throw.
+ return;
+ }
const canvasBoundingBox: any = canvasContainer.getBoundingClientRect();
const canvasWidth = canvasBoundingBox.width - 50;
const canvasHeight = canvasBoundingBox.height - 50;
+ // Guard against a zero-size container (e.g. during component init
before layout completes).
+ // Dividing by a zero dimension would produce Infinity, corrupting the
d3 zoom transform.
+ if (canvasWidth <= 0 || canvasHeight <= 0) {
+ return;
+ }
+
// get the bounding box for the graph
const graph: any = d3.select('#canvas');
const graphBox = graph.node().getBoundingClientRect();
@@ -656,12 +706,21 @@ export class CanvasView {
const x = translate[0] / scale;
const y = translate[1] / scale;
+ // On an empty process group graphBox is 0×0. Reset to identity rather
than letting
+ // 0/0 propagate as NaN through the transform computation.
+ if (graphBox.width === 0 && graphBox.height === 0) {
+ this.allowTransition = allowTransition;
+ this.transform([0, 0], 1);
+ this.allowTransition = false;
+ return;
+ }
+
// adjust the scale to ensure the entire graph is visible
if (graphWidth > canvasWidth || graphHeight > canvasHeight) {
newScale = Math.min(canvasWidth / graphWidth, canvasHeight /
graphHeight);
- // ensure the scale is within bounds
- newScale = Math.min(Math.max(newScale, CanvasView.MIN_SCALE),
CanvasView.MAX_SCALE);
+ // clampScale handles NaN from 0/0 and out-of-range values in one
step
+ newScale = clampScale(newScale);
} else {
newScale = 1;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
index 359c9be143e..c8eac1a8130 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
@@ -1289,6 +1289,112 @@ describe('FlowEffects', () => {
});
});
+ describe('loadProcessGroup$ — position sanitization (NIFI-16025)', () => {
+ function makeEntity(id: string, x: number, y: number): any {
+ return {
+ id,
+ permissions: { canRead: true, canWrite: true },
+ revision: { version: 0 },
+ position: { x, y },
+ component: {}
+ };
+ }
+
+ function makeConnection(id: string, bendX: number, bendY: number): any
{
+ return {
+ id,
+ permissions: { canRead: true, canWrite: true },
+ revision: { version: 0 },
+ position: { x: 0, y: 0 },
+ component: { bends: [{ x: bendX, y: bendY }] }
+ };
+ }
+
+ function buildFlowResponse(overrides: Partial<any> = {}): any {
+ return {
+ processGroupFlow: {
+ id: 'pg-1',
+ parentGroupId: null,
+ breadcrumb: {},
+ flow: {
+ processors: overrides.processors ?? [],
+ processGroups: [],
+ remoteProcessGroups: [],
+ inputPorts: [],
+ outputPorts: [],
+ labels: [],
+ funnels: [],
+ connections: overrides.connections ?? []
+ }
+ }
+ };
+ }
+
+ beforeEach(() => {
+ store.overrideSelector(flowSelectors.selectHasFlowData, false);
+ store.overrideSelector(selectConnectedStateChanged, false);
+ (flowService as any).getFlowStatus = vi.fn(() => of({}));
+ (flowService as any).getControllerBulletins = vi.fn(() => of({}));
+ vi.spyOn(TestBed.inject(RegistryService),
'getRegistryClients').mockReturnValue(
+ of({ registries: [] }) as any
+ );
+ });
+
+ it('passes in-bounds positions through unchanged', async () => {
+ const entity = makeEntity('p-1', 100, 200);
+ (flowService as any).getFlow = vi.fn(() => of(buildFlowResponse({
processors: [entity] })));
+
+ action$.next(FlowActions.loadProcessGroup({ request: { id: 'pg-1',
transitionRequired: false } }));
+
+ const result: any = await
firstValueFrom(effects.loadProcessGroup$.pipe(take(1)));
+ const processor =
result.response.flow.processGroupFlow.flow.processors[0];
+ expect(processor.position).toEqual({ x: 100, y: 200 });
+ });
+
+ it('clamps catastrophic-finite coordinates to (0, 0)', async () => {
+ const entity = makeEntity('p-2', 7.490388061926315e307, 0);
+ (flowService as any).getFlow = vi.fn(() => of(buildFlowResponse({
processors: [entity] })));
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
+ action$.next(FlowActions.loadProcessGroup({ request: { id: 'pg-1',
transitionRequired: false } }));
+
+ const result: any = await
firstValueFrom(effects.loadProcessGroup$.pipe(take(1)));
+ const processor =
result.response.flow.processGroupFlow.flow.processors[0];
+ expect(processor.position).toEqual({ x: 0, y: 0 });
+ expect(warnSpy).toHaveBeenCalled();
+ warnSpy.mockRestore();
+ });
+
+ it('clamps catastrophic-finite connection bends to (0, 0)', async ()
=> {
+ const connection = makeConnection('conn-1', 9e307, 0);
+ (flowService as any).getFlow = vi.fn(() => of(buildFlowResponse({
connections: [connection] })));
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
+ action$.next(FlowActions.loadProcessGroup({ request: { id: 'pg-1',
transitionRequired: false } }));
+
+ const result: any = await
firstValueFrom(effects.loadProcessGroup$.pipe(take(1)));
+ const bend =
result.response.flow.processGroupFlow.flow.connections[0].component.bends[0];
+ expect(bend).toEqual({ x: 0, y: 0 });
+ warnSpy.mockRestore();
+ });
+
+ it('warns at most once per component id across repeated
loadProcessGroup calls', async () => {
+ const entity = makeEntity('p-dup', 7.490388061926315e307, 0);
+ (flowService as any).getFlow = vi.fn(() => of(buildFlowResponse({
processors: [entity] })));
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
+
+ action$.next(FlowActions.loadProcessGroup({ request: { id: 'pg-1',
transitionRequired: false } }));
+ await firstValueFrom(effects.loadProcessGroup$.pipe(take(1)));
+
+ action$.next(FlowActions.loadProcessGroup({ request: { id: 'pg-1',
transitionRequired: false } }));
+ await firstValueFrom(effects.loadProcessGroup$.pipe(take(1)));
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ warnSpy.mockRestore();
+ });
+ });
+
describe('navigateToProvenanceForComponent$', () => {
let router: Router;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index 41552d5dbef..f888de8579f 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -51,6 +51,7 @@ import {
throttleTime
} from 'rxjs';
import {
+ ComponentEntity,
CreateConnectionDialogRequest,
CreateProcessGroupDialogRequest,
DeleteComponentResponse,
@@ -61,6 +62,7 @@ import {
PasteRequest,
PasteRequestContext,
PasteRequestEntity,
+ ProcessGroupFlowEntity,
SaveVersionDialogRequest,
SaveVersionRequest,
SelectedComponent,
@@ -69,6 +71,7 @@ import {
StopVersionControlResponse,
VersionControlInformationEntity
} from './index';
+import { Position } from '../shared';
import { Action, Store } from '@ngrx/store';
import {
selectAnySelectedComponentIds,
@@ -136,6 +139,7 @@ import {
LARGE_DIALOG,
MEDIUM_DIALOG,
NiFiCommon,
+ sanitizePosition,
SMALL_DIALOG,
Storage,
XL_DIALOG,
@@ -213,6 +217,13 @@ export class FlowEffects {
private destroyRef = inject(DestroyRef);
private lastReload = 0;
+ /**
+ * Warn-once dedupe state for position sanitization shared across every
+ * server-flow ingestion in this effect's lifetime, so a poisoned component
+ * logs at most one warning per id rather than one per poll.
+ */
+ private readonly warnedPositionIds = new Set<string>();
+
constructor() {
this.store
.select(selectDocumentVisibilityState)
@@ -271,7 +282,7 @@ export class FlowEffects {
return FlowActions.loadProcessGroupSuccess({
response: {
id: request.id,
- flow: flow,
+ flow: this.sanitizeFlowPositions(flow),
flowStatus: flowStatus,
controllerBulletins: controllerBulletins,
connectedStateChanged,
@@ -4804,4 +4815,61 @@ export class FlowEffects {
})
)
);
+
+ /**
+ * Sanitize all positioned entities in a server-provided flow before they
+ * enter NgRx state. Catastrophic-but-finite coordinates (e.g. the
+ * ~7.49e+307 values persisted when the backend lacked validation) are
+ * clamped to (0, 0) so the canvas pipeline never has to cope with values
+ * that overflow d3 transform arithmetic. A deduped console.warn names the
+ * affected component id so users know which entities to drag-and-save.
+ */
+ private sanitizeFlowPositions(flow: ProcessGroupFlowEntity):
ProcessGroupFlowEntity {
+ const f = flow.processGroupFlow.flow;
+ const sanitize = (entity: ComponentEntity, kind: string):
ComponentEntity => ({
+ ...entity,
+ position: sanitizePosition(entity.position, {
+ componentId: entity.id,
+ componentKind: kind,
+ warnedIds: this.warnedPositionIds
+ })
+ });
+ const sanitizeConnection = (entity: ComponentEntity): ComponentEntity
=> ({
+ ...entity,
+ position: sanitizePosition(entity.position, {
+ componentId: entity.id,
+ componentKind: 'Connection',
+ warnedIds: this.warnedPositionIds
+ }),
+ component: entity.component
+ ? {
+ ...entity.component,
+ bends: entity.component.bends?.map((bend: Position,
index: number) =>
+ sanitizePosition(bend, {
+ componentId: `${entity.id}:bend:${index}`,
+ componentKind: 'Connection bend',
+ warnedIds: this.warnedPositionIds
+ })
+ )
+ }
+ : entity.component
+ });
+
+ return {
+ ...flow,
+ processGroupFlow: {
+ ...flow.processGroupFlow,
+ flow: {
+ processors: f.processors.map((e) => sanitize(e,
'Processor')),
+ processGroups: f.processGroups.map((e) => sanitize(e,
'Process Group')),
+ remoteProcessGroups: f.remoteProcessGroups.map((e) =>
sanitize(e, 'Remote Process Group')),
+ inputPorts: f.inputPorts.map((e) => sanitize(e, 'Input
Port')),
+ outputPorts: f.outputPorts.map((e) => sanitize(e, 'Output
Port')),
+ labels: f.labels.map((e) => sanitize(e, 'Label')),
+ funnels: f.funnels.map((e) => sanitize(e, 'Funnel')),
+ connections: f.connections.map(sanitizeConnection)
+ }
+ }
+ };
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/transform/transform.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/transform/transform.effects.spec.ts
new file mode 100644
index 00000000000..e5731dba886
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/transform/transform.effects.spec.ts
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { ReplaySubject } from 'rxjs';
+import { Action } from '@ngrx/store';
+
+import { TransformEffects } from './transform.effects';
+import * as TransformActions from './transform.actions';
+import { selectCurrentProcessGroupId } from '../flow/flow.selectors';
+import { Storage } from '@nifi/shared';
+import { CanvasView } from '../../service/canvas-view.service';
+import { BirdseyeView } from '../../service/birdseye-view.service';
+import { LabelManager } from '../../service/manager/label-manager.service';
+
+describe('TransformEffects — restoreViewport$', () => {
+ let actions$: ReplaySubject<Action>;
+ let effects: TransformEffects;
+ let store: MockStore;
+ let storageSpy: {
+ getItem: ReturnType<typeof vi.fn>;
+ removeItem: ReturnType<typeof vi.fn>;
+ setItem: ReturnType<typeof vi.fn>;
+ };
+ let canvasViewSpy: { transform: ReturnType<typeof vi.fn> };
+ let dispatchSpy: ReturnType<typeof vi.spyOn>;
+
+ const PROCESS_GROUP_ID = 'root-pg';
+ const VIEW_KEY = `nifi-view-${PROCESS_GROUP_ID}`;
+
+ beforeEach(() => {
+ actions$ = new ReplaySubject<Action>(1);
+
+ storageSpy = {
+ getItem: vi.fn(),
+ removeItem: vi.fn(),
+ setItem: vi.fn()
+ };
+
+ canvasViewSpy = { transform: vi.fn() };
+
+ TestBed.configureTestingModule({
+ providers: [
+ TransformEffects,
+ provideMockActions(() => actions$),
+ provideMockStore({
+ selectors: [{ selector: selectCurrentProcessGroupId,
value: PROCESS_GROUP_ID }]
+ }),
+ { provide: Storage, useValue: storageSpy },
+ { provide: CanvasView, useValue: canvasViewSpy },
+ { provide: BirdseyeView, useValue: { refresh: vi.fn() } },
+ { provide: LabelManager, useValue: { render: vi.fn() } }
+ ]
+ });
+
+ effects = TestBed.inject(TransformEffects);
+ store = TestBed.inject(MockStore);
+ dispatchSpy = vi.spyOn(store, 'dispatch');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('restores a valid viewport transform from storage', () => {
+ const validEntry = { scale: 1, translateX: 100, translateY: 200 };
+ storageSpy.getItem.mockReturnValue(validEntry);
+
+ effects.restoreViewport$.subscribe();
+ actions$.next(TransformActions.restoreViewport());
+
+ expect(canvasViewSpy.transform).toHaveBeenCalledWith([100, 200], 1);
+ expect(storageSpy.removeItem).not.toHaveBeenCalled();
+ expect(dispatchSpy).not.toHaveBeenCalledWith(expect.objectContaining({
type: TransformActions.zoomFit.type }));
+ });
+
+ it('evicts a catastrophic-finite storage entry and dispatches zoomFit
(NIFI-16025 fingerprint)', () => {
+ const corruptEntry = { scale: 1, translateX: 7.490388061926315e307,
translateY: 0 };
+ storageSpy.getItem.mockReturnValue(corruptEntry);
+
+ effects.restoreViewport$.subscribe();
+ actions$.next(TransformActions.restoreViewport());
+
+ expect(storageSpy.removeItem).toHaveBeenCalledWith(VIEW_KEY);
+ expect(canvasViewSpy.transform).not.toHaveBeenCalled();
+ expect(dispatchSpy).toHaveBeenCalledWith(TransformActions.zoomFit({
transition: false }));
+ });
+
+ it('evicts an entry with an out-of-range scale (near-zero) and dispatches
zoomFit', () => {
+ const corruptEntry = { scale: 0.0001, translateX: 0, translateY: 0 };
+ storageSpy.getItem.mockReturnValue(corruptEntry);
+
+ effects.restoreViewport$.subscribe();
+ actions$.next(TransformActions.restoreViewport());
+
+ expect(storageSpy.removeItem).toHaveBeenCalledWith(VIEW_KEY);
+ expect(canvasViewSpy.transform).not.toHaveBeenCalled();
+ expect(dispatchSpy).toHaveBeenCalledWith(TransformActions.zoomFit({
transition: false }));
+ });
+
+ it('evicts an entry with Infinity and dispatches zoomFit', () => {
+ const corruptEntry = { scale: 1, translateX: Infinity, translateY: 0 };
+ storageSpy.getItem.mockReturnValue(corruptEntry);
+
+ effects.restoreViewport$.subscribe();
+ actions$.next(TransformActions.restoreViewport());
+
+ expect(storageSpy.removeItem).toHaveBeenCalledWith(VIEW_KEY);
+ expect(canvasViewSpy.transform).not.toHaveBeenCalled();
+ });
+
+ it('dispatches zoomFit (without eviction) when no storage entry exists',
() => {
+ storageSpy.getItem.mockReturnValue(null);
+
+ effects.restoreViewport$.subscribe();
+ actions$.next(TransformActions.restoreViewport());
+
+ expect(storageSpy.removeItem).not.toHaveBeenCalled();
+ expect(canvasViewSpy.transform).not.toHaveBeenCalled();
+ expect(dispatchSpy).toHaveBeenCalledWith(TransformActions.zoomFit({
transition: false }));
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/transform/transform.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/transform/transform.effects.ts
index 93f52e5b965..50a57ebf3a5 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/transform/transform.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/transform/transform.effects.ts
@@ -20,7 +20,7 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
import { concatLatestFrom } from '@ngrx/operators';
import * as TransformActions from './transform.actions';
import { map, of, switchMap, tap } from 'rxjs';
-import { Storage } from '@nifi/shared';
+import { isFiniteInBound, isScaleInBound, MAX_ABS_TRANSLATE, Storage } from
'@nifi/shared';
import { selectCurrentProcessGroupId } from '../flow/flow.selectors';
import { Store } from '@ngrx/store';
import { CanvasState } from '../index';
@@ -89,10 +89,19 @@ export class TransformEffects {
// ensure the item is valid
if (item) {
- if (isFinite(item.scale) &&
isFinite(item.translateX) && isFinite(item.translateY)) {
+ if (
+ isFiniteInBound(item.translateX,
MAX_ABS_TRANSLATE) &&
+ isFiniteInBound(item.translateY,
MAX_ABS_TRANSLATE) &&
+ isScaleInBound(item.scale)
+ ) {
// restore previous view
this.canvasView.transform([item.translateX,
item.translateY], item.scale);
} else {
+ // The persisted entry contains an
out-of-range value (e.g. a
+ // catastrophic-finite ~9e307 that was written
before this guard
+ // existed). Evict it now so the page-refresh
loop cannot replay
+ // the corrupted transform on the next load.
+ this.storage.removeItem(name);
this.store.dispatch(TransformActions.zoomFit({
transition: false }));
}
} else {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts
index ac7a04e20df..b5f8a1a6c47 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts
@@ -217,7 +217,9 @@ describe('CanvasBirdseyeComponent', () => {
it('should enforce minimum brush size of 10', async () => {
const components = [createMockComponent({ x: 0, y: 0, width:
10000, height: 10000 })];
- const transform = createMockTransform({ scale: 0.1 });
+ // MAX_SCALE (8x zoom) gives a tiny viewport relative to the
10000-unit component space,
+ // exercising the Math.max(brushWidth, 10) clamp path.
+ const transform = createMockTransform({ scale: 8 });
const canvasDimensions = createMockDimensions({ width: 100,
height: 80 });
const { brushElement } = await setup({ components, transform,
canvasDimensions });
@@ -417,4 +419,41 @@ describe('CanvasBirdseyeComponent', () => {
expect(component).toBeTruthy();
});
});
+
+ describe('isTransformSafe() — overflow guard', () => {
+ it('does not throw when transform has a catastrophic-finite translateX
(~9e307)', async () => {
+ const components = [createMockComponent()];
+ const transform = createMockTransform({ translateX:
7.490388061926315e307 });
+ const { component } = await setup({ components, transform });
+ expect(component).toBeTruthy();
+ });
+
+ it('does not throw when scale is near-zero (below MIN_SCALE)', async
() => {
+ const components = [createMockComponent()];
+ const transform = createMockTransform({ scale: 0.0001 });
+ const { component } = await setup({ components, transform });
+ expect(component).toBeTruthy();
+ });
+
+ it('does not throw when scale is Infinity', async () => {
+ const components = [createMockComponent()];
+ const transform = createMockTransform({ scale: Infinity });
+ const { component } = await setup({ components, transform });
+ expect(component).toBeTruthy();
+ });
+
+ it('does not throw when scale is NaN', async () => {
+ const components = [createMockComponent()];
+ const transform = createMockTransform({ scale: NaN });
+ const { component } = await setup({ components, transform });
+ expect(component).toBeTruthy();
+ });
+
+ it('renders normally when transform is valid', async () => {
+ const components = [createMockComponent()];
+ const transform = createMockTransform({ scale: 1, translateX: 0,
translateY: 0 });
+ const { component } = await setup({ components, transform });
+ expect(component).toBeTruthy();
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts
index 9867f7ac8b8..40227eb91fa 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts
@@ -27,7 +27,7 @@ import {
viewChild
} from '@angular/core';
import * as d3 from 'd3';
-import { ComponentType } from '@nifi/shared';
+import { ComponentType, isFiniteInBound, isScaleInBound, MAX_ABS_TRANSLATE }
from '@nifi/shared';
import { Dimension, Position } from '../canvas/canvas.types';
import { BirdseyeBounds, BirdseyeComponentData, BirdseyeTransform } from
'./birdseye.types';
@@ -327,6 +327,9 @@ export class CanvasBirdseyeComponent implements
AfterViewInit, OnDestroy {
// Calculate viewport bounds in canvas coordinates.
const transform = this.transform();
+ if (!this.isTransformSafe(transform)) {
+ return;
+ }
const canvasDims = this.canvasDimensions();
const viewportLeft = -transform.translate.x / transform.scale;
const viewportTop = -transform.translate.y / transform.scale;
@@ -413,11 +416,26 @@ export class CanvasBirdseyeComponent implements
AfterViewInit, OnDestroy {
return '#ffffff';
}
+ /**
+ * Returns true when the transform is safe to use in division-by-scale
arithmetic.
+ * A near-zero or out-of-range scale causes the viewport brush math to
overflow into
+ * Infinity and corrupts the birdseye display; bail early so the next
valid transform
+ * dispatch can redraw cleanly.
+ */
+ private isTransformSafe(transform: BirdseyeTransform): boolean {
+ return (
+ isFiniteInBound(transform.translate.x, MAX_ABS_TRANSLATE) &&
+ isFiniteInBound(transform.translate.y, MAX_ABS_TRANSLATE) &&
+ isScaleInBound(transform.scale)
+ );
+ }
+
/**
* Update the viewport brush position and size.
*/
private updateBrush(transform: BirdseyeTransform, canvasDimensions:
Dimension): void {
if (!this.brush) return;
+ if (!this.isTransformSafe(transform)) return;
const components = this.components();
if (!components || components.length === 0) return;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
index 8ccb9219400..ecc5c3b6de1 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
@@ -1517,4 +1517,92 @@ describe('CanvasComponent', () => {
expect(emittedValues).toHaveLength(0);
});
});
+
+ describe('Transform safety guards (NIFI-16025)', () => {
+ describe('applyTransform() — magnitude guard', () => {
+ it('does not throw when called with valid translate and scale',
async () => {
+ const { component } = await setup();
+ expect(() => (component as any).applyTransform(100, 200, 1,
false)).not.toThrow();
+ });
+
+ it('does not throw when translate is catastrophic-finite
(~9e307)', async () => {
+ const { component } = await setup();
+ expect(() => (component as any).applyTransform(9e307, 0, 1,
false)).not.toThrow();
+ });
+
+ it('does not throw when scale is Infinity', async () => {
+ const { component } = await setup();
+ expect(() => (component as any).applyTransform(0, 0, Infinity,
false)).not.toThrow();
+ });
+
+ it('does not throw when scale is near-zero (0.0001)', async () => {
+ const { component } = await setup();
+ expect(() => (component as any).applyTransform(0, 0, 0.0001,
false)).not.toThrow();
+ });
+ });
+
+ describe('restoreViewportFromStorage()', () => {
+ it('evicts a catastrophic-finite localStorage entry and does not
throw', async () => {
+ const { component } = await setup();
+
+ const key = 'nifi-view-test-pg-id';
+ const corruptEntry = JSON.stringify({
+ expires: Date.now() + 86400000,
+ item: { scale: 1, translateX: 7.490388061926315e307,
translateY: 0 }
+ });
+ localStorage.setItem(key, corruptEntry);
+
+ expect(() =>
component.restoreViewportFromStorage()).not.toThrow();
+ expect(localStorage.getItem(key)).toBeNull();
+
+ localStorage.removeItem(key);
+ });
+
+ it('does not throw when no localStorage entry exists', async () =>
{
+ const { component } = await setup();
+ localStorage.removeItem('nifi-view-test-pg-id');
+ expect(() =>
component.restoreViewportFromStorage()).not.toThrow();
+ });
+ });
+
+ describe('getCanvasPosition()', () => {
+ it('returns null when scale is near-zero causing overflow', async
() => {
+ const { component } = await setup();
+ // Force a near-zero scale; result x = (400 - 0 - 0) / 1e-15
>> MAX_ABS_COORD
+ (component as any).currentScale = 1e-15;
+ (component as any).x = 0;
+ (component as any).y = 0;
+
+ const nativeEl = (component as any).elementRef.nativeElement;
+ vi.spyOn(nativeEl, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 800,
+ height: 600
+ } as DOMRect);
+
+ const result = component.getCanvasPosition({ x: 400, y: 300 });
+ expect(result).toBeNull();
+ });
+
+ it('returns a valid position for in-bounds inputs', async () => {
+ const { component } = await setup();
+ (component as any).currentScale = 1;
+ (component as any).x = 0;
+ (component as any).y = 0;
+
+ const nativeEl = (component as any).elementRef.nativeElement;
+ vi.spyOn(nativeEl, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 800,
+ height: 600
+ } as DOMRect);
+
+ const result = component.getCanvasPosition({ x: 400, y: 300 });
+ expect(result).not.toBeNull();
+ expect(Number.isFinite(result!.x)).toBe(true);
+ });
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
index c903ab3f7b6..8b9db9b9b01 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
@@ -36,7 +36,15 @@ import { Store } from '@ngrx/store';
import { Observable, Subject, fromEvent } from 'rxjs';
import { map, takeUntil, take, debounceTime } from 'rxjs/operators';
import * as d3 from 'd3';
-import { ComponentType, NiFiCommon } from '@nifi/shared';
+import {
+ clampScale,
+ ComponentType,
+ isFiniteInBound,
+ isScaleInBound,
+ MAX_ABS_COORD,
+ MAX_ABS_TRANSLATE,
+ NiFiCommon
+} from '@nifi/shared';
import { DocumentedType, RegistryClientEntity } from '../../../state/shared';
import { NiFiState } from '../../../state';
import {
@@ -160,7 +168,12 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
'translateY' in parsed
? (parsed as StorageTransform)
: null;
- if (!item || !isFinite(item.scale) || !isFinite(item.translateX)
|| !isFinite(item.translateY)) {
+ if (
+ !item ||
+ !isFiniteInBound(item.translateX, MAX_ABS_TRANSLATE) ||
+ !isFiniteInBound(item.translateY, MAX_ABS_TRANSLATE) ||
+ !isScaleInBound(item.scale)
+ ) {
return null;
}
return item;
@@ -931,10 +944,17 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
}
const transform = this.getTransform();
- return {
- x: (screenPosition.x - rect.left - transform.translate.x) /
transform.scale,
- y: (screenPosition.y - rect.top - transform.translate.y) /
transform.scale
- };
+ const x = (screenPosition.x - rect.left - transform.translate.x) /
transform.scale;
+ const y = (screenPosition.y - rect.top - transform.translate.y) /
transform.scale;
+
+ // Reject coordinates produced by a corrupted transform (e.g.
near-zero scale that
+ // causes division overflow) before they can be used to create a POST
request that
+ // re-poisons the backend with an out-of-range position.
+ if (!isFiniteInBound(x, MAX_ABS_COORD) || !isFiniteInBound(y,
MAX_ABS_COORD)) {
+ return null;
+ }
+
+ return { x, y };
}
/**
@@ -1205,6 +1225,19 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
return;
}
+ // All-or-nothing magnitude+scale guard: reject the entire
event when any
+ // component is degenerate. Using all-or-nothing here (unlike
the per-axis
+ // partial-commit in flow-designer's CanvasView) because the
reusable canvas
+ // emits a single {translate, scale} event and partial-commit
semantics would
+ // silently desync the emitted state from the rendered SVG
transform.
+ if (
+ !isFiniteInBound(event.transform.x, MAX_ABS_TRANSLATE) ||
+ !isFiniteInBound(event.transform.y, MAX_ABS_TRANSLATE) ||
+ !isScaleInBound(event.transform.k)
+ ) {
+ return;
+ }
+
// Apply transform to canvas group
this.canvasGroup.attr('transform', event.transform);
@@ -2437,11 +2470,14 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
const name: string = CanvasComponent.VIEW_PREFIX + processGroupId;
const item: StorageTransform | null =
CanvasComponent.readViewportTransform(name);
- if (item && isFinite(item.scale) && isFinite(item.translateX) &&
isFinite(item.translateY)) {
+ if (item) {
// Restore previous viewport position
this.applyTransform(item.translateX, item.translateY,
item.scale, false);
} else {
- // No valid stored viewport - zoom to fit
+ // Either no entry exists, or it failed magnitude/range/expiry
validation (e.g. a
+ // catastrophic-finite ~9e307 written before this guard
existed). Evict it so the
+ // page-refresh loop cannot replay corrupted data (removeItem
is a no-op if absent).
+ localStorage.removeItem(name);
this.fitContent(false);
}
} catch (_e) {
@@ -2536,8 +2572,8 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
// Content is larger than viewport - scale down to fit
newScale = Math.min(canvasWidth / graphWidth, canvasHeight /
graphHeight);
- // Clamp scale within D3 zoom scaleExtent [0.2, 8]
- newScale = Math.min(Math.max(newScale, 0.2), 8);
+ // clampScale handles NaN (from 0/0 when graph is zero-size) and
out-of-range values
+ newScale = clampScale(newScale);
} else {
// Content fits within viewport at 1:1 - use scale 1
newScale = 1;
@@ -2559,6 +2595,18 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
if (!this.zoom || !this.svg) {
return;
}
+
+ // Last-line-of-defense magnitude guard: reject degenerate inputs from
any of the
+ // many internal callers (fitContent, restoreViewportFromStorage,
centerOnComponent,
+ // etc.) before they can write a corrupted value into d3's internal
zoom state.
+ if (
+ !isFiniteInBound(x, MAX_ABS_TRANSLATE) ||
+ !isFiniteInBound(y, MAX_ABS_TRANSLATE) ||
+ !isScaleInBound(scale)
+ ) {
+ return;
+ }
+
// Set flag to allow zoom.end to process and persist viewport
// (We want to persist programmatic transforms like centerOnComponent)
this.isSettingInitialTransform = false;
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/services/canvas-bounds.util.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/services/canvas-bounds.util.spec.ts
new file mode 100644
index 00000000000..1154ebb3365
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/services/canvas-bounds.util.spec.ts
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ clampScale,
+ isFiniteInBound,
+ isScaleInBound,
+ MAX_ABS_COORD,
+ MAX_ABS_TRANSLATE,
+ MAX_SCALE,
+ MIN_SCALE,
+ sanitizePosition
+} from './canvas-bounds.util';
+
+describe('canvas-bounds.util', () => {
+ describe('constants', () => {
+ it('exposes MAX_ABS_COORD = 1e9', () => {
+ expect(MAX_ABS_COORD).toBe(1_000_000_000);
+ });
+
+ it('exposes the SVG translate bound with three orders of magnitude of
headroom over MAX_ABS_COORD * MAX_SCALE', () => {
+ expect(MAX_ABS_TRANSLATE).toBe(1_000_000_000_000);
+ expect(MAX_ABS_TRANSLATE).toBeGreaterThan(MAX_ABS_COORD *
MAX_SCALE);
+ });
+
+ it('exposes MIN_SCALE = 0.2 and MAX_SCALE = 8', () => {
+ expect(MIN_SCALE).toBe(0.2);
+ expect(MAX_SCALE).toBe(8);
+ });
+ });
+
+ describe('isFiniteInBound', () => {
+ it('accepts a finite value within bound', () => {
+ expect(isFiniteInBound(0, MAX_ABS_COORD)).toBe(true);
+ expect(isFiniteInBound(1e8, MAX_ABS_COORD)).toBe(true);
+ expect(isFiniteInBound(-1e8, MAX_ABS_COORD)).toBe(true);
+ });
+
+ it('accepts a value exactly at the bound', () => {
+ expect(isFiniteInBound(MAX_ABS_COORD, MAX_ABS_COORD)).toBe(true);
+ expect(isFiniteInBound(-MAX_ABS_COORD, MAX_ABS_COORD)).toBe(true);
+ });
+
+ it('rejects a value just over the bound', () => {
+ expect(isFiniteInBound(MAX_ABS_COORD + 1,
MAX_ABS_COORD)).toBe(false);
+ });
+
+ it('rejects catastrophic-but-finite values (the ~7.49e+307
fingerprint)', () => {
+ expect(isFiniteInBound(7.490388061926315e307,
MAX_ABS_COORD)).toBe(false);
+ expect(isFiniteInBound(Number.MAX_VALUE / 2,
MAX_ABS_TRANSLATE)).toBe(false);
+ });
+
+ it('rejects Infinity and -Infinity', () => {
+ expect(isFiniteInBound(Number.POSITIVE_INFINITY,
MAX_ABS_COORD)).toBe(false);
+ expect(isFiniteInBound(Number.NEGATIVE_INFINITY,
MAX_ABS_COORD)).toBe(false);
+ });
+
+ it('rejects NaN', () => {
+ expect(isFiniteInBound(NaN, MAX_ABS_COORD)).toBe(false);
+ });
+
+ it('rejects non-number types', () => {
+ expect(isFiniteInBound('42', MAX_ABS_COORD)).toBe(false);
+ expect(isFiniteInBound(null, MAX_ABS_COORD)).toBe(false);
+ expect(isFiniteInBound(undefined, MAX_ABS_COORD)).toBe(false);
+ });
+ });
+
+ describe('isScaleInBound', () => {
+ it('accepts values within [MIN_SCALE, MAX_SCALE]', () => {
+ expect(isScaleInBound(1)).toBe(true);
+ expect(isScaleInBound(MIN_SCALE)).toBe(true);
+ expect(isScaleInBound(MAX_SCALE)).toBe(true);
+ expect(isScaleInBound(0.5)).toBe(true);
+ });
+
+ it('rejects values below MIN_SCALE', () => {
+ expect(isScaleInBound(0.1)).toBe(false);
+ expect(isScaleInBound(0.0001)).toBe(false);
+ expect(isScaleInBound(0)).toBe(false);
+ });
+
+ it('rejects values above MAX_SCALE', () => {
+ expect(isScaleInBound(8.0001)).toBe(false);
+ expect(isScaleInBound(1e6)).toBe(false);
+ });
+
+ it('rejects Infinity, -Infinity, and NaN', () => {
+ expect(isScaleInBound(Number.POSITIVE_INFINITY)).toBe(false);
+ expect(isScaleInBound(Number.NEGATIVE_INFINITY)).toBe(false);
+ expect(isScaleInBound(NaN)).toBe(false);
+ });
+
+ it('rejects non-number types', () => {
+ expect(isScaleInBound('1')).toBe(false);
+ expect(isScaleInBound(null)).toBe(false);
+ expect(isScaleInBound(undefined)).toBe(false);
+ });
+ });
+
+ describe('clampScale', () => {
+ it('clamps a value below MIN_SCALE to MIN_SCALE', () => {
+ expect(clampScale(0)).toBe(MIN_SCALE);
+ expect(clampScale(0.1)).toBe(MIN_SCALE);
+ });
+
+ it('clamps a value above MAX_SCALE to MAX_SCALE', () => {
+ expect(clampScale(100)).toBe(MAX_SCALE);
+ });
+
+ it('passes through an in-bounds value unchanged', () => {
+ expect(clampScale(1)).toBe(1);
+ expect(clampScale(MIN_SCALE)).toBe(MIN_SCALE);
+ expect(clampScale(MAX_SCALE)).toBe(MAX_SCALE);
+ });
+
+ it('returns the default fallback (1) for NaN', () => {
+ expect(clampScale(NaN)).toBe(1);
+ });
+
+ it('returns the default fallback (1) for Infinity', () => {
+ expect(clampScale(Number.POSITIVE_INFINITY)).toBe(1);
+ expect(clampScale(Number.NEGATIVE_INFINITY)).toBe(1);
+ });
+
+ it('returns a custom fallback for non-finite inputs', () => {
+ expect(clampScale(NaN, 2)).toBe(2);
+ });
+ });
+
+ describe('sanitizePosition', () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it('returns the original coordinate when in bounds', () => {
+ const result = sanitizePosition({ x: 100, y: 200 }, { componentId:
'id1', componentKind: 'Processor' });
+ expect(result).toEqual({ x: 100, y: 200 });
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('returns (0, 0) and warns for catastrophic-but-finite coordinates
(the NIFI-16025 fingerprint)', () => {
+ const result = sanitizePosition(
+ { x: 7.490388061926315e307, y: 0 },
+ { componentId: 'id2', componentKind: 'Processor' }
+ );
+ expect(result).toEqual({ x: 0, y: 0 });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns (0, 0) and warns for Infinity', () => {
+ const result = sanitizePosition(
+ { x: Number.POSITIVE_INFINITY, y: 0 },
+ { componentId: 'id3', componentKind: 'Funnel' }
+ );
+ expect(result).toEqual({ x: 0, y: 0 });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns (0, 0) and warns for null position', () => {
+ const result = sanitizePosition(null, { componentId: 'id4',
componentKind: 'Label' });
+ expect(result).toEqual({ x: 0, y: 0 });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('accepts a coordinate exactly at MAX_ABS_COORD', () => {
+ const result = sanitizePosition(
+ { x: MAX_ABS_COORD, y: -MAX_ABS_COORD },
+ { componentId: 'id5', componentKind: 'Processor' }
+ );
+ expect(result).toEqual({ x: MAX_ABS_COORD, y: -MAX_ABS_COORD });
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('rejects a coordinate just over MAX_ABS_COORD', () => {
+ const result = sanitizePosition(
+ { x: MAX_ABS_COORD + 1, y: 0 },
+ { componentId: 'id6', componentKind: 'Processor' }
+ );
+ expect(result).toEqual({ x: 0, y: 0 });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns a new object (does not mutate the source)', () => {
+ const original = { x: 100, y: 200 };
+ const result = sanitizePosition(original, { componentId: 'id7',
componentKind: 'Processor' });
+ expect(result).not.toBe(original);
+ });
+
+ describe('warn-once dedupe via warnedIds', () => {
+ it('emits at most one warn per componentId when a Set is
provided', () => {
+ const warnedIds = new Set<string>();
+ sanitizePosition({ x: 7.49e307, y: 0 }, { componentId: 'dup',
componentKind: 'Processor', warnedIds });
+ sanitizePosition({ x: 7.49e307, y: 0 }, { componentId: 'dup',
componentKind: 'Processor', warnedIds });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('warns for distinct component ids independently', () => {
+ const warnedIds = new Set<string>();
+ sanitizePosition({ x: 7.49e307, y: 0 }, { componentId: 'a',
componentKind: 'Processor', warnedIds });
+ sanitizePosition({ x: 7.49e307, y: 0 }, { componentId: 'b',
componentKind: 'Processor', warnedIds });
+ expect(warnSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/services/canvas-bounds.util.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/services/canvas-bounds.util.ts
new file mode 100644
index 00000000000..f1ee4bdcdc3
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/services/canvas-bounds.util.ts
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Numeric safety helpers for the NiFi canvas surfaces (flow-designer's
+ * CanvasView, the reusable canvas component, the connector-canvas, etc.).
+ *
+ * Number.isFinite(value) is necessary but not sufficient to guarantee that
+ * a number can be written into an SVG attribute or used as a divisor: the
+ * browser's SVG parser treats coordinates as float32 internally (max ~3.4e38),
+ * and downstream multiplication/division in canvas math can turn a ~9e307
+ * finite double into Infinity on the first operation. We apply an explicit
+ * magnitude bound everywhere these values cross a trust boundary -- component
+ * DTOs from the server, viewport state from localStorage, d3 zoom events,
+ * internal transform arithmetic.
+ */
+
+/**
+ * Upper bound for any canvas-space coordinate value (a component's position.x
+ * or position.y). 1e9 is roughly 10,000x larger than any realistic flow
+ * extent, and nowhere near the float32 overflow boundary, so even chained
+ * zoom math has ~29 orders of magnitude of headroom before re-overflowing.
+ */
+export const MAX_ABS_COORD = 1_000_000_000;
+
+/**
+ * Upper bound for any SVG transform translate component (the tx or ty of a
+ * translate(...)). Legitimate translates compose roughly as MAX_ABS_COORD *
+ * MAX_SCALE (a few times 1e9); we pick 1e12 to leave three orders of
+ * magnitude of headroom while still rejecting catastrophic overflow values
+ * (the incident driver was ~9e307 persisted in localStorage).
+ */
+export const MAX_ABS_TRANSLATE = 1_000_000_000_000;
+
+/**
+ * Minimum d3-zoom scale extent shared across every canvas in the workspace.
+ */
+export const MIN_SCALE = 0.2;
+
+/**
+ * Maximum d3-zoom scale extent shared across every canvas in the workspace.
+ */
+export const MAX_SCALE = 8;
+
+/**
+ * Number.isFinite AND magnitude-bound check. Use this everywhere we
+ * previously used Number.isFinite(value) alone: a catastrophic-but-finite
+ * value like Number.MAX_VALUE / 2 ~= 8.988e+307 is finite, but the browser
+ * still rejects it as an SVG attribute and any downstream arithmetic overflows
+ * to Infinity.
+ *
+ * Returns a type guard so call-sites that pass unknown (e.g. JSON parsed from
+ * localStorage) get a typed number in the truthy branch.
+ */
+export function isFiniteInBound(value: unknown, bound: number): value is
number {
+ return typeof value === 'number' && Number.isFinite(value) &&
Math.abs(value) <= bound;
+}
+
+/**
+ * Range check for d3 zoom scale. Returns true iff value is a finite number
+ * within [MIN_SCALE, MAX_SCALE].
+ *
+ * Use this instead of isFiniteInBound(value, MAX_SCALE) at every
+ * scale-validation site -- the latter only bounds the magnitude and silently
+ * accepts values like k = 0.0001, which would cause division-by-zero in
+ * birdseye math and any other code that divides by scale.
+ */
+export function isScaleInBound(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value) && value >=
MIN_SCALE && value <= MAX_SCALE;
+}
+
+/**
+ * Clamp scale into [MIN_SCALE, MAX_SCALE], returning fallback (default 1)
+ * when the input is non-finite. Math.max / Math.min are NaN-poisoned so they
+ * cannot be the sole line of defense -- if any upstream arithmetic produces
+ * 0 / 0 = NaN, a raw clamp would let NaN escape and corrupt the d3 zoom state.
+ */
+export function clampScale(scale: number, fallback = 1): number {
+ if (!Number.isFinite(scale)) {
+ return fallback;
+ }
+ return Math.min(Math.max(scale, MIN_SCALE), MAX_SCALE);
+}
+
+export interface SanitizePositionOptions {
+ /** Component id used for the warn message and warn-once dedupe. */
+ componentId: string;
+ /** Component kind/label used for the warn message (e.g. 'Processor'). */
+ componentKind: string;
+ /**
+ * Optional warn-once dedupe state. When provided, the helper emits at most
+ * one console.warn per componentId across the lifetime of the Set. Callers
+ * that hold the Set on a long-lived service get one warn per id; callers
+ * that omit it get one warn per call.
+ */
+ warnedIds?: Set<string>;
+}
+
+/**
+ * Sanitize a position arriving from server data (or any other untrusted
+ * source) before it is bound to a d3 datum.
+ *
+ * The canvas pipeline reads d.position.{x,y} from many call sites (rendering,
+ * viewport math, bbox math). If a corrupted finite-but-extreme coordinate
+ * (Number.MAX_VALUE / 2.4) reaches any of those, downstream
+ * multiplication-heavy math can overflow into Infinity and corrupt the d3 zoom
+ * state for the whole session.
+ *
+ * Returns a fresh object so the caller never mutates the position living in
+ * the source DTO. When the value is out of range, the fallback is (0, 0) and
+ * a (deduped) console.warn is emitted so support can identify which entities
+ * need to be dragged-and-saved (or repaired via the REST API) to flush the
+ * bad value out of the persisted flow.
+ */
+export function sanitizePosition(
+ position: { x: number | null | undefined; y: number | null | undefined } |
null | undefined,
+ { componentId, componentKind, warnedIds }: SanitizePositionOptions
+): { x: number; y: number } {
+ const x = position?.x;
+ const y = position?.y;
+ if (!isFiniteInBound(x, MAX_ABS_COORD) || !isFiniteInBound(y,
MAX_ABS_COORD)) {
+ if (!warnedIds || !warnedIds.has(componentId)) {
+ warnedIds?.add(componentId);
+ console.warn(
+ `Component ${componentKind} ${componentId} has an out-of-range
position`,
+ position,
+ '\u2014 falling back to (0, 0). Drag the component to a new
location and save to repair the persisted value.'
+ );
+ }
+ return { x: 0, y: 0 };
+ }
+ return { x, y };
+}
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts
index 468ffe5c3ea..25ff388da58 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts
@@ -27,3 +27,4 @@ export * from './connector-configuration.service';
export * from './active-step.service';
export * from './connector-message-client.service';
export * from './upload.service';
+export * from './canvas-bounds.util';