-
Notifications
You must be signed in to change notification settings - Fork 6
fix(apollo-react): compute node height from handle count to stop flicker [MST-11677] #867
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,11 +63,11 @@ const getContainerWidth = (shape: NodeShape | undefined, width: number | undefin | |
| return defaultWidth; | ||
| }; | ||
|
|
||
| const getContainerHeight = ( | ||
| height: number | undefined, | ||
| // Intrinsic height: the fixed footer height when a footer is present, else the default. | ||
| const getIntrinsicHeight = ( | ||
| hasFooter: boolean, | ||
| footerVariant: FooterVariant | undefined | ||
| ): number | 'auto' => { | ||
| ): number => { | ||
| if (hasFooter) { | ||
| switch (footerVariant) { | ||
| case 'button': | ||
|
|
@@ -76,13 +76,9 @@ const getContainerHeight = ( | |
| return NODE_HEIGHT_FOOTER_SINGLE; | ||
| case 'double': | ||
| return NODE_HEIGHT_FOOTER_DOUBLE; | ||
| default: | ||
| return 'auto'; | ||
| } | ||
| } | ||
| // Use `||` rather than `??` — React Flow can pass `height: 0` before measurement, | ||
| // and we want that to fall through to the default rather than collapse the node. | ||
| return height || NODE_HEIGHT_DEFAULT; | ||
| return NODE_HEIGHT_DEFAULT; | ||
| }; | ||
|
|
||
| const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => { | ||
|
|
@@ -111,22 +107,13 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => { | |
| } = useBaseNodeOverrideConfig(); | ||
|
|
||
| const updateNodeInternals = useUpdateNodeInternals(); | ||
| const { updateNodeData, updateNode } = useReactFlow(); | ||
| const { updateNodeData, updateNode, getNode } = useReactFlow(); | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const [isHovered, setIsHovered] = useState(false); | ||
| const [isFocused, setIsFocused] = useState(false); | ||
|
|
||
| // Tracks the height we last synced to React Flow via updateNode, so we can | ||
| // distinguish our own handle-based writes from external changes (user-specified | ||
| // height, React Flow measurement, resize). | ||
| const syncedHeightRef = useRef<number | undefined>(undefined); | ||
| // The "base" height — what the node height would be without handle inflation. | ||
| // Updated when height changes from an external source (not our sync). | ||
| const baseHeightRef = useRef<number>(DEFAULT_NODE_SIZE); | ||
|
|
||
| if (height && height !== syncedHeightRef.current) { | ||
| baseHeightRef.current = height; | ||
| } | ||
| // Height is driven by computedHeight (below), a pure function of handle count/footer. | ||
| // It never reads the measured height, so writing it back can't loop. | ||
|
|
||
| // Get execution status from external source | ||
| const executionState = useNodeExecutionState(id); | ||
|
|
@@ -260,10 +247,9 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => { | |
| }; | ||
| }, [adornmentsProp, statusContext]); | ||
|
|
||
| // Compute height: max of base height (user-specified or measured) and handle minimum. | ||
| // baseHeightRef is updated above from external height changes; handle inflation | ||
| // is computed from the current handleConfigurations. | ||
| // biome-ignore lint/correctness/useExhaustiveDependencies: height updates baseHeightRef above and intentionally retriggers this memo. | ||
| // Computed node height: max of the handle-count floor and the intrinsic default/footer | ||
| // height. Pure (never reads the measured `height`); written to node.height below as | ||
| // the authoritative size, so node content is expected to fit within it. | ||
| const computedHeight = useMemo(() => { | ||
| const leftHandles = handleConfigurations | ||
| .filter((config) => config.position === Position.Left && config.visible !== false) | ||
|
|
@@ -282,31 +268,21 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => { | |
| const leftRightHandles = Math.max(leftHandles, rightHandles); | ||
|
|
||
| // Each handle gets a 2-grid-space lane (32px), plus 2-grid-space padding at top + bottom of node. | ||
| const minNodeHeight = (leftRightHandles * 2 + 2) * GRID_SPACING; | ||
| return Math.max(baseHeightRef.current, minNodeHeight); | ||
| }, [handleConfigurations, height]); | ||
| const handleFloor = (leftRightHandles * 2 + 2) * GRID_SPACING; | ||
|
|
||
| // biome-ignore lint/correctness/useExhaustiveDependencies: handle configuration changes require React Flow handle recalculation. | ||
| useEffect(() => { | ||
| updateNodeInternals(id); | ||
| }, [handleConfigurations, id, updateNodeInternals]); | ||
| return Math.max(getIntrinsicHeight(!!footerComponent, footerVariant), handleFloor); | ||
| }, [handleConfigurations, footerComponent, footerVariant]); | ||
|
|
||
| // Sync computed height to node when it differs from React Flow's current value | ||
| // Write computedHeight to node.height and recalculate handle positions. Compare | ||
| // against node.height (not the measured `height` prop) so a lagging measurement | ||
| // can't retrigger the write. | ||
| // biome-ignore lint/correctness/useExhaustiveDependencies: handleConfigurations triggers handle recalculation. | ||
| useEffect(() => { | ||
| if (computedHeight !== undefined && computedHeight !== height) { | ||
| syncedHeightRef.current = computedHeight; | ||
| const frameId = requestAnimationFrame(() => { | ||
| updateNode(id, { height: computedHeight }); | ||
| updateNodeInternals(id); | ||
| }); | ||
|
|
||
| return () => { | ||
| cancelAnimationFrame(frameId); | ||
| }; | ||
| if (getNode(id)?.height !== computedHeight) { | ||
| updateNode(id, { height: computedHeight }); | ||
| } | ||
|
|
||
| return undefined; | ||
| }, [computedHeight, height, id, updateNode, updateNodeInternals]); | ||
| updateNodeInternals(id); | ||
| }, [handleConfigurations, computedHeight, id, getNode, updateNode, updateNodeInternals]); | ||
|
|
||
| const displayLabel = display.label; | ||
| const displayShape = display.shape ?? 'square'; | ||
|
|
@@ -320,7 +296,6 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => { | |
| // Display customization from props (not data) | ||
| const displayLabelTooltip = labelTooltip; | ||
| const displayLabelBackgroundColor = labelBackgroundColor; | ||
| const displayFooterVariant = footerVariant; | ||
|
|
||
| // SubLabel: Component prop takes precedence, then plain string from display. | ||
| const displaySubLabel = useMemo(() => { | ||
|
|
@@ -343,10 +318,10 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => { | |
| // --------------------------------------------------------------------------- | ||
| const hasFooter = !!displayFooter; | ||
| const containerWidth = getContainerWidth(displayShape, width); | ||
| const containerHeight = getContainerHeight(height, hasFooter, displayFooterVariant); | ||
| const containerHeight = computedHeight; | ||
|
|
||
| const nodeVars = useMemo((): React.CSSProperties => { | ||
| const numH = typeof containerHeight === 'number' ? containerHeight : DEFAULT_NODE_SIZE; | ||
| const numH = containerHeight; | ||
|
|
||
| const getRadius = (basis: number, ratio: number) => { | ||
| return displayShape === 'circle' | ||
|
|
@@ -378,7 +353,7 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => { | |
|
|
||
| return { | ||
| '--node-w': `${containerWidth}px`, | ||
| '--node-h': typeof containerHeight === 'number' ? `${containerHeight}px` : 'auto', | ||
| '--node-h': `${containerHeight}px`, | ||
| '--node-radius': nodeRadius, | ||
|
Comment on lines
319
to
357
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, and the description is updated to match. Height is deliberately handle/footer-driven, not content-driven: |
||
| '--node-gap': `${(gap - NODE_BORDER_SIZE * 2) / 2}px`, | ||
| '--inner-w': `${innerW}px`, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — the PR description was stale and is now updated. The write-back is intentional and required: React Flow's node model (measured height, edge anchoring, fit-view, selection bounds, and handle spacing) all read
node.height, so we do need to keep it in sync.The key change from the previous flickering version is that
computedHeightis a pure function of handle count + footer and never reads the measuredheightprop — so writing it back cannot feed into its own input. That's what breaks the old feedback loop, not the removal of the write. The guard compares against the explicitnode.height(viagetNode), not the measured prop, so a lagging ResizeObserver measurement can't retrigger the write.