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


Reply via email to