Plugin API autoLayout() deletes nodes added in the same tick
## Problem/Motivation
The plugin API's `autoLayout()` destroys every node a plugin added earlier in the
same synchronous handler. The nodes disappear from the canvas and are never
persisted, while `hasUnsavedChanges` is left set to `TRUE` — so the user sees a
dirty model that does not contain their new components.
Every mutation in `pluginApi.ts` reads the live graph through
`useGraphStore.getState()` — `addNode` (:505, :538), `updateNode` (:546, :578),
`addEdge` (:606, :643, :656), `setCondition` (:720, :774-776). `autoLayout()`
does not, and that inconsistency is the bug.
### The causal chain
1. `src/plugins/pluginApi.ts:862` — `autoLayout()` delegates to `autoLayoutHook()`.
2. `src/components/Flow.tsx:484` — the hook is registered inside a `useEffect` as
`autoLayout: () => handleAutoLayout()`, so the plugin API holds the closure
from the **last committed render**.
3. `src/hooks/useConfiguration.ts:20-21` — `handleAutoLayout` closes over
`const nodes = useGraphStore(state => state.nodes)`, a **render-time snapshot**.
4. `src/hooks/useConfiguration.ts:123-130` — the body is `autoLayout(nodes, edges)`
followed by `setNodes(layoutedNodes)`, passing a **plain array**.
5. `src/store/useGraphStore.ts:24-26` — `setNodes` with a non-function value
**replaces the entire node array**.
Inside one synchronous handler no re-render occurs, so the array written back is
the pre-mutation snapshot. Added nodes are erased. Edges added in the same batch
survive, leaving orphaned edges that reference nodes which no longer exist.
`simulateIncrementalBuild` is **not** at fault — `src/utils/incrementalLayout.ts:901-912`
explicitly places every node the graph walk did not reach, so the layout function
itself is exhaustive and drops nothing.
## Steps to reproduce
From any Drupal module that registers a modeler plugin panel, in a single click
handler:
```js
const id = api.addNode({ plugin: 'some_valid_plugin', componentType: 1, label: 'New event' });
// id is a real node id, and the node is in the store at this point.
api.autoLayout();
// The node is now gone. api.getNodeById(id) returns null.
```
Verified against the real store and layout modules, bundled with esbuild:
```
canvas before the plan => ["Event_1"]
canvas after the plan => ["Event_1","Event_2","Action_1"]
canvas after autoLayout => ["Event_1"]
orphaned edge left behind => ["Edge_1"]
```
## Proposed resolution
Read the live graph at call time instead of relying on the captured snapshot,
so `autoLayout()` matches the store-fresh behavior of every other mutation:
```js
setNodes(prev => autoLayout(prev, useGraphStore.getState().edges) ?? prev);
```
or have `handleAutoLayout` read both lists from `useGraphStore.getState()`
directly.
## Related, non-destructive
The same render-time staleness affects the viewport helpers:
- `src/hooks/useViewportActions.ts:250` — `focusNode` does `nodes.find()` on the
captured list and returns early when the node is absent.
- `src/hooks/useViewportActions.ts:204` — `fitToNodes` frames the captured list.
So a viewport call for a node added in the same tick silently does nothing. This
does not lose data, but it means a plugin cannot focus a node it just created.
## Possibly worth a separate issue
`pluginApi.ts` `addNode` positions new nodes with a generic
`maxX + LAYOUT.NODE_SPACING_X` rule (:509-522), whereas the UI's own paths use the
shared placement primitives — `computeNewEventPosition` in
`src/hooks/useNodeEdgeActions.ts:123-127` and `computeSuccessorPosition` in
`src/hooks/useQuickAdd.ts:53-57`. A plugin-added node therefore does not land
where a manually added one would. Once `autoLayout()` is safe to call again this
matters much less, since `simulateIncrementalBuild` produces the manual-equivalent
layout, but the inconsistency is still there.
## Impact
Found while building an AI assistant panel for
[AI Integration ECA](https://www.drupal.org/project/ai_integration_eca)
([#3576158](https://git.drupalcode.org/project/ai_integration_eca/-/issues/3576158)),
where the assistant proposes graph operations that the user applies in one click.
That module currently works around it by not calling `api.autoLayout()` at all,
which leaves plugin-added components unlaid-out. Deferring the call was rejected
as a workaround because whether it lands before or after React's passive-effect
flush is a race, and losing that race destroys the user's work.
---
[x] AI Generated Code
This code was mainly generated by an AI with human guidance, and reviewed,
tested, and refined by a human.
issue
GitLab AI Context
Project: project/modeler
Instance: https://git.drupalcode.org
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://git.drupalcode.org/project/modeler/-/raw/1.0.x/README.md — project overview and setup
Repository: https://git.drupalcode.org/project/modeler
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD