Rob Fellows created NIFI-16025:
----------------------------------
Summary: UI - Canvas SVG transform produces scale(Infinity) and
components overlap at origin when component positions or persisted viewport
exceed safe rendering bounds
Key: NIFI-16025
URL: https://issues.apache.org/jira/browse/NIFI-16025
Project: Apache NiFi
Issue Type: Task
Components: Core UI
Reporter: Rob Fellows
h2. Symptom
Under certain conditions, the NiFi flow canvas becomes visually unusable in the
browser: every component is rendered stacked at the canvas origin, the birdseye
(minimap) is blank or shows {{scale(Infinity)}} errors in the console, drag /
zoom / pan stops working, and dragging a component to a new location persists
it at {{(0, 0)}}. The flow itself continues to run correctly -- this is a
UI-only failure mode -- but the canvas is unrecoverable for the rest of the
session, and refreshing the page does not fix it (the bad state is replayed
from {{localStorage}}).
In the browser console, the failure surfaces as either:
* {{Error: <g> attribute transform: Expected number, "translate(-8.98...e+307,
...)"}}
* {{Error: <g> attribute transform: Expected number, "scale(Infinity)"}}
h2. Root cause
{{Number.isFinite(value)}} (and the weaker {{!isNaN(value)}}) is a *necessary
but not sufficient* check for any value used in an SVG attribute or in
multiplication-heavy canvas math. A double like {{Number.MAX_VALUE / 2}}
(~{{8.99e307}}) is finite in JavaScript but:
* The browser's SVG parser stores transform coordinates as {{float32}}
internally (max {{~3.4e38}}). Anything beyond that is silently rejected --
the attribute write fails and the {{<g>}} sticks at the previous transform
(typically the identity), so every component visually collapses to the
origin.
* Downstream arithmetic in the canvas math
({{(rect.x - this.x) / this.k}}, bbox normalization, birdseye brush math)
overflows to {{Infinity}} on the first multiplication, which then poisons
d3's internal {{__zoom}} datum for the remainder of the session.
The canvas pipeline relies on {{isFinite}} / {{!isNaN}} at every transform
mutation site, so any catastrophic-but-finite value -- whether produced by
canvas math itself, replayed from {{localStorage}}, or returned from the REST
API -- flows straight through.
A few specific failure modes contribute:
* The zoom handler ({{canvas-view.service.ts}}) gates each axis with
{{!isNaN(...)}}, which is {{true}} for {{Infinity}}.
* {{CanvasView.transform()}} has no validation at all, so a corrupted caller
pushes the bad value straight into d3's {{__zoom}} datum.
* {{CanvasView.getSelectionBoundingClientRect()}} seeds its reduce sentinel
with {{Number.MAX_VALUE}} / {{Number.MIN_VALUE}}; on an empty selection
those seeds leak through unchanged and the normalized result is the
{{~7.49e307}} fingerprint that surfaces in corrupted flows. (As a separate
correctness bug, {{Number.MIN_VALUE}} is the smallest *positive* double
({{~5e-324}}), not the most-negative value, so non-empty selections in
negative canvas space aggregate {{right}} / {{bottom}} incorrectly.)
* {{CanvasView.getCanvasPosition()}} divides by the current scale with no
output bound, so a corrupted upstream transform produces drop coordinates
that the next component-create POST persists into the flow.
* {{TransformEffects.restoreViewport$}} reads the last-known viewport from
{{localStorage}} with an {{isFinite}}-only check; a once-corrupted entry
re-poisons the canvas on every subsequent load and is never evicted. This
is what makes the failure mode survive a hard refresh.
* The reusable {{<canvas>}} component in {{ui/common/canvas/}} has the same
{{isFinite}}-only gates, so any host that mounts it (flow-designer, the
read-only connector-canvas troubleshooting view) is equally exposed.
h2. Scope
The fix touches the flow-designer canvas surface and the shared reusable
{{<canvas>}} / {{<birdseye>}} components. Both currently-known hosts of the
reusable canvas (flow-designer and the read-only connector-canvas
troubleshooting view) must be hardened: the read-only host cannot produce a
bad coordinate, but it displays them through the same components and triggers
the same overflow.
h2. Proposed solution (summary)
In broad strokes:
# Introduce shared magnitude-bounded numeric-safety helpers in
{{@nifi/shared}} (replacing ad-hoc {{isFinite}} / {{!isNaN}} checks with
a finite-AND-magnitude-bounded type guard, plus a scale-range check and a
NaN-safe scale clamp). Pair the helpers with a small set of named
constants for the coordinate / translate / scale bounds.
# Sanitize positions at the data-ingestion boundary, before they are bound
to a d3 datum or stored in NgRx state. Apply this in flow-designer's
per-entity-type mappers and in the connector-canvas load effect. The
reusable {{<canvas>}} itself remains a trusted consumer; the obligation
to clean data lives with each host page. Emit a deduped console warning
that names the affected component id so users can drag-and-save to
repair the persisted value.
# Harden every transform mutation site (zoom handler, programmatic
{{transform}}, {{fit}} / {{actualSize}}, drop-coordinate emission,
bounding-box aggregation, birdseye refresh) with the new
magnitude-bounded guards. Where the existing code uses an in-band
sentinel value (the {{MAX_VALUE}} / {{MIN_VALUE}} seeds in the reduce),
prefer guarding at the source -- e.g. return {{null}} on an empty
selection -- rather than patching every caller.
# Evict corrupted {{localStorage}} viewport entries on read and fall back
to a fit-to-content reset, so the failure mode cannot survive a refresh.
# Mirror the same guards in the reusable {{<canvas>}} / {{<birdseye>}}
components so every host page is protected without per-host effort.
The detailed call sites, constants, and helper signatures are best decided
during implementation review; the above outlines the approach rather than
dictating the exact shape.
h2. Reproduction (manual)
The fastest path that does not require a corrupted flow on disk:
# Open any flow.
# In DevTools, Application -> Local Storage -> find a {{nifi-view-<pg-id>}}
entry (one is written on the first pan / zoom). Edit its JSON in place,
setting {{translateX}} to {{8.99e307}}. Reload.
# Observe: every component renders stacked at the origin, the minimap is
blank, pan / zoom / drag does nothing, and the console shows the
{{translate(-X, -Y)}} / {{scale(Infinity)}} errors above. The corruption
replays on every reload because the viewport-restore effect re-applies
the {{localStorage}} entry unchanged.
To reproduce the write-side leak (without preconfiguring localStorage):
trigger {{actualSize()}} on a process group with no rendered components yet
(initial-load race) and then drag-and-drop a new component. The
{{getSelectionBoundingClientRect}} sentinel leaks into the zoom math, and the
resulting POST carries the {{~7.49e307}} fingerprint.
h2. Acceptance criteria
# Under no input -- corrupted {{localStorage}}, corrupted server data, a
forged zoom event, a fuzzed drop coordinate, an empty / racy selection,
or division by a near-zero scale -- can the canvas state reach a
configuration that produces {{translate(<finite-but-huge>, ...)}} or
{{scale(Infinity)}} on a {{<g>}} attribute.
# The drop-coordinate emission path returns {{null}} rather than emit a
coordinate that the rest of the UI would later reject.
# A corrupted {{localStorage}} viewport entry is evicted on read; the user
does not need to manually clear browser storage to recover the canvas.
# The connector-canvas (read-only display) is equally protected against
bad server data, even though it cannot produce it.
# A flow containing pre-existing bad positions renders with the affected
components at {{(0, 0)}} (not at the corrupted coordinate, and not
collapsing the entire canvas to the origin) and emits a deduped console
warning identifying each affected component id, so users can drag-and-save
to repair the persisted value.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)