Repository navigation
Keep workflow Flow canvas nodes stable across renders (#1573) - #1578
Merged
Paul Lizer (paullizer) merged 3 commits intoSep 30, 2026
Conversation
The V2 workflow Flow canvas rebuilt every React Flow node on every render of the editor, including renders that don't change the diagram. React Flow drops the handle bounds of a node object it hasn't seen, so every control edge left the page until the node was measured again, and a measurement that landed between two rebuilds could leave the edges missing. That made the Flow save-failure UI test flaky. - Nodes call the latest onSelect, onCollapse, onFocus and onNavigate through stable wrappers. - reuseUnchangedFlowNodes keeps the previous object for every unchanged node, and the previous array when nothing changed. - Rebuilt nodes carry the size React Flow last reported as measured, so real changes keep their edges drawn. - A node asks React Flow to measure it again when its selection changes, because the wider selected border moves its handles. - onInit, onError and fitViewOptions are stable. Adds a Playwright regression spec, node:test coverage for the helper, a fix doc and release notes, and sets VERSION to 0.261.207. Fixes microsoft#1573 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…le-rerenders Brings in microsoft#1575 (v0.261.204). Keeps VERSION 0.261.207 and places the v0.261.207 release notes above the v0.261.204 section. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Version follows the order PRs go ready; this one is ready first. Version strings only: config.py, the release-notes heading, the fix doc and the two test headers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Paul Lizer (paullizer)
marked this pull request as ready for review
September 29, 2026 23:57
Paul Lizer (paullizer)
merged commit Sep 30, 2026
18dac67
into
microsoft:paullizer-react-v2-ui
11 checks passed
Paul Lizer (paullizer)
added a commit
to paullizer/simplechat
that referenced
this pull request
Sep 30, 2026
Brings in microsoft#1578, which took 0.261.205. Keeps VERSION 0.261.206 and puts the v0.261.206 release-notes section above microsoft#1578's v0.261.205 section, which is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Paul Lizer (paullizer)
added a commit
that referenced
this pull request
Sep 30, 2026
Versions now follow the order PRs go ready: #1578 takes 0.261.205 and 3b (#1577) takes 0.261.206, so the coordinator moved Phase 4 to 0.261.207. Move VERSION and the headers, docs, version floors and the stubbed bootstrap version this branch set to 0.261.206. Only files this branch changed since V2 2f8dab4 are touched. Refs #1547 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Paul Lizer (paullizer)
added a commit
that referenced
this pull request
Sep 30, 2026
Brings #1578 (flow canvas stable re-renders, 0.261.205), #1579 and #1576 (pytest return-False guard, XSS guardrail updates). Conflicts: config.py keeps Phase 4's 0.261.207; release_notes.md puts the v0.261.207 section above v0.261.205 and restores the unindented v0.261.204 heading. Refs #1547 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Paul Lizer (paullizer)
added a commit
to paullizer/simplechat
that referenced
this pull request
Sep 30, 2026
Phase 4 (microsoft#1580) landed first and took 0.261.207, and microsoft#1578 took 0.261.205, so 0.261.206 is skipped. This is a byte-level replace of 0.261.206 with 0.261.208 in every file of this PR, with CRLF preserved; nothing else changes. Refs microsoft#1548 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Paul Lizer (paullizer)
added a commit
to paullizer/simplechat
that referenced
this pull request
Sep 30, 2026
Record 3b (microsoft#1577, v0.261.208) and Phase 4 (microsoft#1580, v0.261.207) as shipped and mark 3c (the Ask AI tab) in progress. Add what later phases build on, their follow-ups, the side fixes from microsoft#1576, microsoft#1578 and microsoft#1579, and gotcha 61 (Microsoft 365 pending-action run links). Docs only; no version bump. Refs microsoft#1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Flow canvas, shared by the V2 workflow editor and the read-only Flow view, rebuilt every React Flow node on renders that don't change the diagram. In the editor that meant the saving state, a failed save and typing outside the diagram; in the read-only view, every runtime poll that returned the same run. React Flow drops the measured handle bounds of a node object it hasn't seen, so every control edge left the page until React Flow measured the nodes again. A measurement that landed between two rebuilds could leave the edges missing for good, which is the
test_save_failures_retain_flow_fields_and_never_replace_the_original_cas_revision[400|409|503]flake.The canvas inputs are now stable across renders. All changes are in
WorkflowFlowCanvas.tsxplus a small pure helper, soWorkflowEditorDialog,WorkflowFlowAuthoringandWorkflowFlowVieware untouched and both surfaces are covered:onSelect,onCollapse,onFocusandonNavigatethrough stable wrappers backed by a ref that a layout effect updates after every commit. A new callback identity alone never rebuilds a node, and a click or key still reaches the current editor state.reuseUnchangedFlowNodes(lib/workflowFlowNodeReuse.ts) keeps the previous object for every node whose fields are unchanged, comparing plain-object fields such asdata,styleandpositionone level down. When no node changed it returns the previous array, so React Flow has nothing to adopt again.dimensionsReact Flow reports throughonNodesChangeand passes them back asmeasuredon rebuilt nodes. React Flow then keeps the handle bounds, so real changes (typing in a task, a new compiler status, collapse) no longer remove edges either.measuredis ignored when comparing nodes, and sizes of removed nodes are pruned.useUpdateNodeInternalswhen itschosenflag flips; the old rebuild used to force that re-measure by accident.<ReactFlow>props.onInit,onErrorandfitViewOptionsno longer change identity on every render.No other behavior changes. Selection, collapse, keyboard navigation, focus requests, dragged positions and change tracking use the same callbacks and state as before.
Root cause
nodesmemo depended ononSelect,onCollapse,onFocusandonNavigate.WorkflowEditorDialogpassesonSelectas an inline arrow, andWorkflowFlowAuthoringpassescollapse, a plain function, asonCollapse. The canvas's ownonNavigatedepends ononCollapse. Every editor render therefore produced a new object for every node.selectNodeandtoggleCollapseare alreadyuseCallback. However,statusesdepends on the polledruntimeobject, so every poll built a new map with the same values, and the canvas rebuilt every node.@xyflow/react12.11.6,adoptUserNodeskeeps the internal node only for the identical user object. For a new object withoutmeasured,parseHandlesreturnsundefinedhandle bounds, andEdgeWrapperrenders nothing without them. Edges come back after the ResizeObserver measures the node again.useNodeObserverre-observes a node only whenisInitializedchanges. The flaky sequence:fillrenders the editor synchronously and rebuilds every node. That drops the handle bounds, so React Flow re-observes each node.setLoaded(null)rendersWorkflowFlowAuthoringagain at default priority with a newcollapse, so every node is rebuilt a second time. That render commits, and its passivesetNodesis still pending.setNodes, which drops the handle bounds again.isInitializedwent false→false, so there is no re-observe. The edges stay missing, andexpect_compiledtimes out attest_v2_workflow_flow_authoring.py:282.WorkflowFlowAuthoring.tsxL286–291 andWorkflowFlowView.tsxL382–387):compiled ?? model.structure,statuses(memoized on the draft's targets and status text),bindings,positionsandfocusRequestchange only on real changes, andnoExecutionsis a module constant.inspectfeeds only the Configure button, never node data.Validation
Runner for the Python and Playwright tests: the pinned venv (
aa1bf1e0…\venv: pytest, pytest-playwright, Chromium) on this Windows machine, against the offline harness (PLAYWRIGHT_SERVICE_URL=''), one spec file at a time. "Base" is the V2 bundle built atc33ad4d16(index-B2zJGAwc.js), pinned withSIMPLECHAT_UI_EXPECTED_JS/CSS. "Fix" is this branch before the V2 merge (index-iSQsBt1R.js). "Merged" is after merging V22f8dab475(index-xkm43UeJ.js).Build and logic (Node on PATH, venv python)
npm run typecheckandnpm run build(application/v2_ui): pass, both before and after the V2 merge.node --test functional_tests/test_v2_workflow_flow_node_reuse_logic.mjs: 10/10.functional_tests/test_v2_workflow_change_tracking.py, which bundles and runstest_v2_workflow_change_tracking_logic.ts: 4/4.python functional_tests/test_docs_app_surface_coverage.py: 7/7.python functional_tests/test_docs_site_quality.py: 6/6.test_docs_link_integrity.py2/5 (none of the broken links are in this PR's files) andtest_docs_release_notes_integrity.py0/1. The generated release-notes pages are stale for 143 releases;v0.261.205adds one entry to that list. Regenerating them is out of scope here.New regression spec
ui_tests/test_v2_workflow_flow_canvas_stability.pyFlake reproduction (session-only stress plugin; 20 repeats per test; Chromium 4× CPU throttle through CDP
Emulation.setCPUThrottlingRate):282via:843):282via:843:282while opening the FlowPlain 4× throttling reproduces the original flake at the same line as the full-suite failures. The race amplifier makes that failure deterministic on the base.
Existing specs (venv pytest + Chromium)
test_v2_workflow_flow_authoring.pytest_v2_workflow_flow_inspection.py(read-only Flow view)test_v2_workflow_authoring_history.pytest_v2_workflow_change_tracking.pytest_v2_workflow_editor.pytest_v2_workflow_calendar_schedules.pytest_v2_workflow_control_flow.pytest_v2_workflow_m365_runtime.pyThe V2 merge brought in only #1575 (chat
ThoughtsList.tsxandactivityLanes.ts), so after it I re-ran the three Flow specs.The renumber to v0.261.205 (
78a2ba465) changes only version strings (9 lines in 5 files). After it I re-ran:Local guardrails (
run_v2_guardrails.py, venv python), all on the same 7 files:c33ad4d16before the merge2f8dab475after the merge2f8dab475again on the final head78a2ba465Every run:
get_by_role(...)locators and one Pythonpass.Decisions made autonomously
Fixed in the canvas instead of in each caller (as in the approved plan). Instead of
useCallbackinWorkflowEditorDialogandWorkflowFlowAuthoring, the canvas calls the latest handlers through stable wrappers, so no caller can reintroduce the churn. This covers the editor and the read-only view, and it stays out ofWorkflowEditorDialog.tsx, which Phase 4 edits.Node reuse was needed. Stable handlers don't cover the read-only view: each runtime poll builds an equal but new
statusesmap. They also don't cover focus and selection changes, which recomputed every node when only one or two changed. Reusing equal nodes in the canvas covers both without adding content-keyed memos to each caller.Rebuilt nodes carry
measured. A real draft edit, such as the flaky test's secondfill, changes every record and must rebuild every node. Carrying the last reported size keeps React Flow's handle bounds through those rebuilds, instead of relying on the re-measure landing in time.useUpdateNodeInternals), because the rebuild no longer forces one.Selection and focus changes are covered too, although the kickoff put real diagram changes out of scope. Reuse and
measuredkeep the arrows drawn through selection changes at no extra cost, so the focus/selection test asserts that no control edge is removed. A geometry check asserts that every control edge starts and ends on its node's handles.Typing trigger. "Typing outside the diagram" uses the decision field builder with
page.keyboard. It re-renders the dialog without changing the draft, and the test asserts that typing doesn't revalidate.The rapid test fits the view first. Besides the node observers, the amplifier also delays React Flow's pane-size observer. Under it, the initial fit ran at full size with only the root block's button clickable, on the base and the fix alike. That is an amplifier artifact, not V2 Flow canvas rebuilds every node on unrelated re-renders, so edges drop and a Playwright test flakes #1573, so the test clicks Fit Flow before clicking nodes. The rapid test passes on the base when its setup succeeds; it guards the fix's reuse and re-measure rather than reproducing the bug.
Stress tooling isn't committed. The session-only pytest plugin has three switches:
FLOW_REPEATrepeats a test.FLOW_CPU_THROTTLEthrottles through CDP.FLOW_RO_RACEinstalls the race amplifier. It's an init script that holds each ResizeObserver notification until a microtask after the next React commit, or at most one more frame: a rare but legal delivery order.The fix doc describes it. I can commit it if you want it in the repo.
Version 0.261.205. The version follows the order PRs go ready, and the coordinator numbered this one first, so it went from .207 to .205 in
78a2ba465.2f8dab475(Remove redundant orchestration progress card from V2 chat #1575, v0.261.204) is merged in. I resolved theconfig.pyandrelease_notes.mdconflicts by hand with rerere disabled.Guardrail base. After merging V2, the guardrails used
2f8dab475as the base so the three-dot diff stays on this PR's 7 files; before the merge they usedc33ad4d16, per the kickoff command.Push.
git push originreturned 403, so the branch is on theforkremote and this is a cross-repo PR.Fixes #1573
Found during #1543