Skip to content

Commit 7e0facc

Browse files
Merge pull request #1156 from oriontech-me/fix/issue-1153-save-changes-modal-deadlock
test(flows): survive the upstream editor-exit deadlock, attributed (#1153)
2 parents b417b97 + 6891fae commit 7e0facc

8 files changed

Lines changed: 557 additions & 18 deletions

File tree

docs/flow-functionality/duplicate-flow.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Flow Functionality — Duplicate Flow
22

3-
**Last validated:** Langflow 1.10.x
3+
**Last validated:** Langflow 1.12.x
44

55
---
66

@@ -28,8 +28,8 @@ API test: `@release` `@workspace` `@api` `@stable`
2828

2929
1. Bootstrap the app
3030
2. Click `side_nav_options_all-templates`, then click the `Basic Prompting` heading to open the template (which creates a new flow)
31-
3. Wait for `sidebar-search-input` to confirm the editor loaded; navigate back via `icon-ChevronLeft`
32-
4. Wait for the first `home-dropdown-menu` to be visible
31+
3. Wait for `sidebar-search-input` to confirm the editor loaded; return to the listing with `leaveFlowEditor(page)` — the `icon-ChevronLeft` click plus its home assertion, wrapped so the exit survives the `SaveChangesModal` deadlock (#1153)
32+
4. `leaveFlowEditor` asserts the first `home-dropdown-menu` is visible before returning
3333
5. Acquire a Bearer token via `getAuthToken(request)`
3434
6. Click the first `home-dropdown-menu`, wait for `btn-duplicate-flow`
3535
7. Register a `page.waitForResponse` listener for `POST /api/v1/flows/` with status `201`, then click `btn-duplicate-flow`
@@ -68,6 +68,7 @@ The API test must assert **all** of:
6868
## External dependencies *(required)*
6969

7070
- `tests/helpers/auth/get-auth-token.ts` — issues the Bearer token
71+
- `tests/helpers/flows/leave-flow-editor.ts` — the editor exit: drains in-flight flow saves, clicks `icon-ChevronLeft`, and distinguishes the #1153 blocker deadlock from a swallowed click. It depends on upstream `src/frontend/src/pages/FlowPage/index.tsx` (`useBlocker` / `handleSave`), `src/frontend/src/modals/saveChangesModal/index.tsx`, and the `flow.unsavedChangesTitle` string in `src/frontend/src/locales/en.json` — if that title is reworded the dialog stops being recognised and every deadlock silently reclassifies as a swallowed click
7172
- `src/frontend/src/pages/MainPage/components/dropdown/index.tsx` — registers `btn-duplicate-flow` testid; the test would need updating if it is renamed or removed
7273
- `src/frontend/src/pages/MainPage/hooks/use-handle-duplicate.ts` — calls `createNewFlow` then `postAddFlow`; the API test mirrors this round-trip directly
7374
- `src/frontend/src/utils/reactflowUtils.ts` (`createNewFlow`) — keeps the original `name` (no client-side suffix); the suffix originates server-side

docs/flow-functionality/export-import-flow.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Flow Functionality — Export and Import Flow
22

3-
**Last validated:** Langflow 1.11.x
3+
**Last validated:** Langflow 1.12.x
44

55
---
66

@@ -32,8 +32,11 @@ If these break, users cannot share flows, back them up, or restore previously ex
3232
proof the node-add autosave persisted, replacing the quiet-window guard
3333
(`waitForFlowSaveSettled` resolves after 700 ms of silence even when the
3434
debounced PATCH hasn't fired yet — the #384 loophole)
35-
3. Return to main page and open the three-dot menu **of the created flow's own
36-
card**: `list-card` filtered by `flow-name-{id}``home-dropdown-menu`
35+
3. Return to the main page with `leaveFlowEditor(page)` — the `icon-ChevronLeft`
36+
click plus its home assertion, wrapped so the exit survives the
37+
`SaveChangesModal` deadlock (#1153) — then open the three-dot menu **of the
38+
created flow's own card**: `list-card` filtered by `flow-name-{id}`
39+
`home-dropdown-menu`
3740
inside it. Never `nth(0)`: the home sorts by `updated_at` DESC, so under
3841
parallel CI the first card is whatever flow a neighbor worker touched last
3942
— exporting it produced the `nodes: []` failures (#518; export serializes
@@ -73,6 +76,7 @@ If these break, users cannot share flows, back them up, or restore previously ex
7376
- `src/frontend/src/components/core/flowEditorComponents/` — flow editor header, export modal
7477
- `src/backend/base/langflow/api/v1/flows.py` — flow export/import endpoints
7578
- `tests/helpers/ui/simulate-drag-and-drop.ts``simulateDragAndDrop` helper
79+
- `tests/helpers/flows/leave-flow-editor.ts` — the editor exit: drains in-flight flow saves, clicks `icon-ChevronLeft`, and distinguishes the #1153 blocker deadlock from a swallowed click. It depends on upstream `src/frontend/src/pages/FlowPage/index.tsx` (`useBlocker` / `handleSave`), `src/frontend/src/modals/saveChangesModal/index.tsx`, and the `flow.unsavedChangesTitle` string in `src/frontend/src/locales/en.json` — if that title is reworded the dialog stops being recognised and every deadlock silently reclassifies as a swallowed click
7680
- `tests/assets/flows/collection.json` — multi-flow JSON used as import fixture
7781
- `data-testid="home-dropdown-menu"` — three-dot menu on flow cards
7882
- `data-testid="btn-download-json"` — download/export menu item

docs/flow-functionality/run-flow.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ If this breaks, users cannot compose flows via the Run Flow component — a core
2626
2. Add ChatOutput and ChatInput components to the canvas
2727
3. Connect: ChatInput → ChatOutput
2828
4. Rename the built flow to a unique name (for deterministic selection later)
29-
5. Return to main page and create a second blank flow
29+
5. Return to main page with `leaveFlowEditor(page)` — the `icon-ChevronLeft`
30+
click, preceded by a save barrier and followed by an attributed throw if the
31+
exit is blocked (#1153) — and create a second blank flow
3032
6. Add the Run Flow component to the second flow
3133
7. Open the flow name dropdown in Run Flow and refresh the list
3234
8. Select the built flow by its unique name from the dropdown
@@ -104,13 +106,44 @@ re-validated there. The helper gate keeps the suite out of the broken window; it
104106
does not fix the product, and the suite must not claim a validated behavior that
105107
upstream still breaks. `#966` stays open tracking that.
106108

109+
### A second product defect on the same back-navigation (issue #1153)
110+
111+
Distinct from LE-2019 above and with a different mechanism: LE-2019 is a click
112+
that lands and does nothing while the list loads; this one is a click that lands,
113+
starts the navigation, and is then held by react-router's `useBlocker` behind
114+
`SaveChangesModal`. In autosave mode that dialog renders **no confirm and no
115+
cancel**, so only `FlowPage.handleSave` can complete the navigation — and it
116+
calls `saveFlow()` with **no `.catch()`**, so a save that fails or never settles
117+
leaves `proceed` false and the dialog up indefinitely. Reproduced
118+
deterministically on 1.12.0.dev10 by aborting every flow-save PATCH: the modal
119+
appears, does not clear in 30 s, and the URL stays on `/flow/{id}`.
120+
121+
Why it matters here specifically: this call site had **nothing** waiting on the
122+
navigation, so a blocked exit surfaced downstream inside
123+
`openNewFlowTemplatesModal` — wearing LE-2019's signature on a run where LE-2019
124+
was not what happened.
125+
126+
Step 5 now goes through `leaveFlowEditor(page)`, which drains in-flight flow
127+
saves before the click (prevention — `changesNotSaved` is exactly what
128+
`useBlocker` gates on) and throws with the mechanism named if the dialog is still
129+
up after 15 s. That 15 s is charged **only** to a dialog that is demonstrably on
130+
screen; an exit where nothing has rendered yet is an ordinary in-flight
131+
navigation and keeps the full 30 s the listing assertion has always had —
132+
otherwise a slow-but-healthy exit would be reported as the swallowed-click class
133+
above, which is the same mis-attribution with a more confident label.
134+
The recovery-by-page-load that helper also offers is deliberately
135+
**not** enabled here: everything the rest of this spec asserts lives in the flow
136+
built on the canvas, so discarding it would trade a clean failure at the exit for
137+
an inscrutable one at the Run Flow dropdown.
138+
107139
---
108140

109141
## External dependencies *(required)*
110142

111143
- `src/frontend/src/components/core/nodeToolbarComponents/` — Run Flow component UI and flow name dropdown
112144
- `src/backend/base/langflow/api/v1/flows.py` — flow listing API used by the dropdown refresh
113145
- `src/backend/base/langflow/processing/` — flow execution chain that runs the pipeline
146+
- `tests/helpers/flows/leave-flow-editor.ts` — the editor exit: drains in-flight flow saves, clicks `icon-ChevronLeft`, and distinguishes the #1153 blocker deadlock from a swallowed click. It depends on upstream `src/frontend/src/pages/FlowPage/index.tsx` (`useBlocker` / `handleSave`), `src/frontend/src/modals/saveChangesModal/index.tsx`, and the `flow.unsavedChangesTitle` string in `src/frontend/src/locales/en.json` — if that title is reworded the dialog stops being recognised and every deadlock silently reclassifies as a swallowed click
114147

115148
---
116149

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Unit tests for the editor-exit classifier (issue #1153).
2+
// Run with: npm run test:units
3+
//
4+
// What rides on this module: when the upstream exit deadlock fires, the line
5+
// this produces is the entire product of the failure in a daily's report. Before
6+
// #1153 the same event surfaced as a bare `home-dropdown-menu` visibility
7+
// timeout, which #1005's triage spent a full 24-run burst re-deriving into "the
8+
// blocker dialog never cleared".
9+
//
10+
// Four distinctions these tests exist to protect:
11+
//
12+
// 1. **`stuck` is not `blocked-deadlocked`.** A swallowed chevron click and a
13+
// blocked navigation both end with the editor still on screen, and they send
14+
// a reader to opposite places (a click that never fired vs. the upstream
15+
// defect). Collapsing them re-creates the unattributed timeout.
16+
// 2. **`pending` is a verdict, not an absence.** The polling loop terminates on
17+
// "anything but pending", so a classifier that returned `left` for "nothing
18+
// has happened yet" would make the loop exit on its first tick and assert
19+
// against a page that has not navigated.
20+
// 3. **Home wins over a painted dialog.** The dialog animates out, so it is
21+
// routinely still in the DOM on the tick where home first renders. Reporting
22+
// that as a deadlock would warn — and force a page load — on healthy exits.
23+
// 4. **The two deadlines are not one deadline.** An empty screen is an ordinary
24+
// in-flight navigation and gets the full home budget; only a dialog already
25+
// on screen is judged on the shorter blocker grace. Charging the grace window
26+
// to a slow-but-healthy exit reports a dead click that never happened — the
27+
// same mis-attribution as a bare timeout, only with a confident label.
28+
import { test } from "node:test";
29+
import assert from "node:assert/strict";
30+
import {
31+
BLOCKER_GRACE_MS,
32+
HOME_TIMEOUT_MS,
33+
classifyEditorExit,
34+
formatEditorExitStuckFailure,
35+
formatEditorExitWarning,
36+
type EditorExitVerdict,
37+
} from "./leave-flow-editor";
38+
39+
/**
40+
* Named rather than positional, and every deadline defaults to NOT expired: a
41+
* test that forgets to expire one gets `pending` — which fails loudly — instead
42+
* of silently agreeing with whichever flag happened to be passed.
43+
*/
44+
const verdict = (observed: {
45+
home?: boolean;
46+
blocker?: boolean;
47+
/** `BLOCKER_GRACE_MS` elapsed. Only ever promotes a dialog already on screen. */
48+
grace?: boolean;
49+
/** `HOME_TIMEOUT_MS` elapsed. The only thing that may call an empty screen stuck. */
50+
homeBudget?: boolean;
51+
}): EditorExitVerdict =>
52+
classifyEditorExit({
53+
homeVisible: observed.home ?? false,
54+
blockerVisible: observed.blocker ?? false,
55+
graceExpired: observed.grace ?? false,
56+
homeBudgetExpired: observed.homeBudget ?? false,
57+
});
58+
59+
test("home rendered with no dialog is a clean exit", () => {
60+
assert.equal(verdict({ home: true }), "left");
61+
assert.equal(verdict({ home: true, grace: true, homeBudget: true }), "left");
62+
});
63+
64+
test("home rendered while the dialog is still painted is NOT a deadlock", () => {
65+
// The dialog animates out; it is routinely still in the DOM on the tick where
66+
// home first renders. Calling that a deadlock would warn and force a page load
67+
// on every healthy exit that happened to pass through the blocker.
68+
assert.equal(verdict({ home: true, blocker: true }), "blocked-settled");
69+
assert.equal(
70+
verdict({ home: true, blocker: true, grace: true, homeBudget: true }),
71+
"blocked-settled",
72+
);
73+
});
74+
75+
test("the dialog inside the grace window is pending, not a verdict", () => {
76+
// `handleSave`'s own timeout is 1200ms, so a dialog that is up for a moment is
77+
// the normal save-then-proceed path. Returning a terminal verdict here would
78+
// make the caller give up before the exit had a chance to complete.
79+
assert.equal(verdict({ blocker: true }), "pending");
80+
});
81+
82+
test("the dialog past the grace window is the #1153 deadlock", () => {
83+
assert.equal(verdict({ blocker: true, grace: true }), "blocked-deadlocked");
84+
});
85+
86+
test("an empty screen past the grace window is NOT yet stuck", () => {
87+
// The regression this pins: `stuck` used to share the blocker's deadline, so a
88+
// navigation that was merely slow — a client-side route change plus the
89+
// listing's own GET, the thing that runs long on a saturated daily — failed at
90+
// 15s claiming the click never registered. The call sites this helper replaced
91+
// allowed 30s for exactly this window.
92+
assert.equal(verdict({ grace: true }), "pending");
93+
});
94+
95+
test("an empty screen past the HOME budget is a swallowed click, not a deadlock", () => {
96+
// Distinct from `blocked-deadlocked` on purpose: this is the editor still on
97+
// screen with nothing blocking it, i.e. the chevron click never registered.
98+
assert.equal(verdict({ grace: true, homeBudget: true }), "stuck");
99+
});
100+
101+
test("neither home nor dialog inside both windows is still pending", () => {
102+
assert.equal(verdict({}), "pending");
103+
});
104+
105+
test("`pending` is never terminal, so the poll loop cannot exit early", () => {
106+
// The loop's only exit condition is `verdict !== "pending"`. Every combination
107+
// that has not resolved yet must therefore classify as pending, or the caller
108+
// asserts against a page that has not navigated.
109+
//
110+
// Includes the deadline states, because they expire at different times: with
111+
// the blocker grace elapsed but the home budget still open, an empty screen is
112+
// the one state where a shared deadline used to produce a wrong verdict.
113+
const unresolved = [
114+
{ blocker: false, grace: false },
115+
{ blocker: true, grace: false },
116+
{ blocker: false, grace: true },
117+
];
118+
for (const { blocker, grace } of unresolved) {
119+
assert.equal(
120+
verdict({ blocker, grace }),
121+
"pending",
122+
`blocker=${blocker} grace=${grace} must stay pending while the home budget is open`,
123+
);
124+
}
125+
});
126+
127+
test("the deadlock message names the upstream defect, not just the symptom", () => {
128+
// Called with NO argument, so this pins the pairing the helper actually ships
129+
// — not just the formatting. Feeding the constant in would have passed a
130+
// regression that quoted a budget this verdict is not gated on.
131+
const message = formatEditorExitWarning();
132+
// A triager reading only this line has to reach the issue and the mechanism
133+
// without opening the screenshot.
134+
assert.match(message, /#1153/);
135+
assert.match(message, /SaveChangesModal/);
136+
assert.match(message, /no \.catch\(\)/);
137+
assert.match(message, new RegExp(`${BLOCKER_GRACE_MS}ms`));
138+
// Must not quote the home budget: this verdict is gated on the grace window,
139+
// and swapping the two is precisely how the deadlines get collapsed again.
140+
assert.doesNotMatch(message, new RegExp(`${HOME_TIMEOUT_MS}ms`));
141+
});
142+
143+
test("the stuck message rules the deadlock OUT rather than staying vague", () => {
144+
// The two failures send a reader to opposite places, so the message for one
145+
// must not read as the other. A triager who sees this line must not go
146+
// looking at SaveChangesModal.
147+
// Also called with no argument — see the deadlock message test above.
148+
const message = formatEditorExitStuckFailure();
149+
assert.match(message, /did not navigate/);
150+
assert.match(message, /NOT the #1153/);
151+
assert.match(message, /LE-2019/);
152+
assert.doesNotMatch(
153+
message,
154+
/deadlocked/,
155+
"the swallowed-click message must not describe itself as the deadlock",
156+
);
157+
// It must quote the window it actually waited out. Quoting the blocker's
158+
// shorter grace would understate the evidence behind "the click never
159+
// registered" — and would be the visible symptom of the two deadlines having
160+
// been collapsed back into one.
161+
assert.match(message, new RegExp(`${HOME_TIMEOUT_MS}ms`));
162+
assert.doesNotMatch(message, new RegExp(`${BLOCKER_GRACE_MS}ms`));
163+
});
164+
165+
test("the grace budget is not below the repo's save budgets", () => {
166+
// `renameFlow` allows 15s per modal step and a single save click has needed
167+
// longer than that under CI saturation (#790). A budget below that would
168+
// classify a slow-but-working save as a deadlock — and, where recovery is
169+
// enabled, discard editor state over it.
170+
assert.ok(
171+
BLOCKER_GRACE_MS >= 15000,
172+
`grace window ${BLOCKER_GRACE_MS}ms is below renameFlow's 15000ms per-step budget`,
173+
);
174+
});
175+
176+
test("the home budget stays above the blocker grace, and above what the call sites had", () => {
177+
// Ordering is the whole point of splitting the deadlines: if the home budget
178+
// ever fell to or below the grace window, `stuck` would start firing on slow
179+
// navigations again and this helper would be back to mis-attributing them.
180+
assert.ok(
181+
HOME_TIMEOUT_MS > BLOCKER_GRACE_MS,
182+
`home budget ${HOME_TIMEOUT_MS}ms must exceed the blocker grace ${BLOCKER_GRACE_MS}ms`,
183+
);
184+
// 30s is inherited, not invented: it is what `duplicate-flow` (`toBeVisible`)
185+
// and `export-import-flow` (`waitForSelector`) allowed this assertion before
186+
// the helper existed. The helper must not silently shorten it.
187+
assert.ok(
188+
HOME_TIMEOUT_MS >= 30000,
189+
`home budget ${HOME_TIMEOUT_MS}ms is below the 30000ms the call sites already allowed`,
190+
);
191+
});

0 commit comments

Comments
 (0)