Skip to content

feat(flow): add managed state and v19.1 runtime improvements#320

Merged
siarheihuzarevich merged 28 commits into
mainfrom
feature/shuzarevich/v19.1.0
Jul 13, 2026
Merged

feat(flow): add managed state and v19.1 runtime improvements#320
siarheihuzarevich merged 28 commits into
mainfrom
feature/shuzarevich/v19.1.0

Conversation

@siarheihuzarevich

@siarheihuzarevich siarheihuzarevich commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

  • add opt-in Managed Flow State through provideFFlow(withFlowState())
  • batch supported gestures into stable undo/redo actions with typed records, snapshots, viewport history, and overridable state behavior
  • reduce runtime work in connection redraw, minimap, registries, connector geometry, and drag hot paths
  • support pointer targeting and coordinate hit-testing inside Angular Elements and open Shadow DOM
  • fix grouped connection redraws, undo render lifecycle, and settled group auto-size updates
  • publish the v19.1 release article, Managed State example, roadmap updates, redirects from retired Undo/Redo examples, and the 19.1.0 package versions

Managed Flow State

Foblex Flow remains stateless by default. Applications can opt into a generic state and history layer while keeping ownership of business meaning, validation, persistence, and collaboration.

injectFlowState<TNode, TConnection, TGroup>() exposes flat, application-shaped node, group, and connection records without a required data wrapper. Supported pointer and programmatic changes update those records immutably, while public interaction outputs continue to fire for existing integrations.

The history layer follows user actions rather than individual runtime events. Selection, movement, descendant transforms, auto-fit updates, and optional reparenting produced by one drag settle into one undo step. state.batch(...) provides the same behavior for programmatic changes, and state.changes() increments only after the outer batch settles so persistence receives one stable notification.

Configuration covers history limits, selection and viewport participation, viewport debounce, drop-to-group, node and connection factories, and a custom stateClass. Initial centering can stay out of history through the new emitCanvasChange argument on canvas positioning methods.

Managed State v1 does not automatically capture rotation, connection waypoint editing, or user resize. Their existing outputs remain available for explicit application updates. Connection cascade before connectors render requires removing known attached connection ids in the same batch.

Runtime And Correctness

Connection redraw requests now carry the affected geometry instead of invalidating every connection. Group changes include connections owned by descendant nodes, fixing stale paths after group movement and state restoration without returning to a full redraw.

The minimap uses cached model-space rectangles, registry changes are coalesced, connector border radii are cached, connectable-side recalculation shares one scheduler, and drag targeting follows DOM ancestry instead of scanning all nodes.

State restoration now explicitly completes the full-render lifecycle. Group auto-fit waits for settled parent ids, emits the final size through public outputs, clears stale bound dimensions, and avoids transient soft-expanded sizes.

Shadow DOM

Normal DOM interactions remain target-first. When a document-level event is retargeted to an Angular Element host, the runtime falls back to the composed path to recover the internal flow element. Coordinate hit-testing follows elementsFromPoint() through nested open shadow roots while preserving browser hit order. Closed shadow roots remain unsupported.

This addresses discussion #315.

Compatibility

  • no breaking changes
  • Managed Flow State is opt-in
  • existing event-driven integrations continue to work
  • light-DOM targeting behavior remains unchanged
  • open Shadow DOM support requires no provider
  • layout package peer ranges remain compatible with supported v18 applications

Validation

  • NX_DAEMON=false npm run test:ci - 288 tests passed in ChromeHeadless
  • NX_DAEMON=false npm run build:packages
  • NX_DAEMON=false npm run build:portal:dev
  • npm run seo:check
  • npm run lint:portal
  • LLM docs version/content validation
  • commit hooks: ESLint, Prettier, and staged diff checks

Release Material

siarheihuzarevich and others added 28 commits July 13, 2026 12:38
…g the dom every frame

The minimap re-measured every node (2x getBoundingClientRect) and re-created
every SVG rect on each animation frame of every pan/zoom - the reason the
fNodeRenderLimit=1500 bailout existed. Node rects now come from node position
and a size cache invalidated by the existing resize signal, live in flow
coordinates, and are reused between frames: panning updates one group
transform attribute and zero per-node DOM.

The viewport calculation drops its per-frame bounding-box measurement the same
way. fNodeRenderLimit default raises 1500 -> 10000.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
…ning every node

Every pointerdown ran hostElement.contains against ALL registered nodes in up
to six preparations (drag, resize, rotate, select, canvas-drag, drop-to-group,
create-connection). Node and group hosts already carry data-f-node-id /
data-f-group-id, so one closest() walk plus a registry lookup resolves the
owner in O(DOM depth); a host-instance check keeps it correct when several
flows share the page.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
…per measurement

Every connector rect resolution paid a getComputedStyle call just to read
border radii that practically never change. Radii are now cached per element
(unscaled) and re-read only when the node resize signal fires — the same
invalidation the rest of the measurement pipeline already relies on.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
…emovals

Mounting a node with K connectors fired K+1 synchronous nodesChanges
notifications, each running every raw listener (a11y semantics rebuild over
all nodes, layout, minimap hub) — O(N^2) listener work across an initial
render. Notifications now coalesce to one per microtask while revisions keep
incrementing synchronously.

Registry removals are collected and compacted out of the ordered list in one
pass on the next read, so a large teardown is O(n) total instead of
per-removal indexOf+splice O(n^2). Lookup maps reflect removals immediately
and the getAll() array keeps its identity.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
A single node resize/state change replayed the redraw pipeline over every
connection in the graph. The store now tracks which nodes went dirty; the
resize path narrows the pass to connections touching those nodes, while every
other emitter keeps full-redraw semantics. Scoped passes skip the global
connected-state reset (setConnected is idempotent now) and escalate back to a
full pass whenever a previous sliced/worker pass is still in flight, so an
aborted session can never leave connections undrawn.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
… across handlers

Every node redraw re-armed its own clearTimeout+setTimeout pair for the
connectable-sides recalculation — a multi-node drag churned N timers per
pointer event. Nodes now register with one shared debounced scheduler that
flushes them together and skips nodes destroyed while queued.

OnPointerMove also stops spreading a fresh copy of the pointer difference per
handler (hundreds of allocations per event on multi-select drags); handlers
treat the point as read-only, so one object per event is enough.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
onPointerDown/prepareDragSequence/onPointerUp were flat walls of mediator
calls whose ORDER was the whole priority contract — invisible and easy to
break when adding a gesture. Each phase is now a declared claimant list with
the contract documented in one place: the first preparation that claims the
pointer wins, and finalization order is intentionally independent of
preparation order. Behavior is unchanged.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
…State()

provideFFlow(withFlowState()) gives applications a data-first workflow: load
plain node/connection records once, render state.nodes()/state.connections()
with @for, and take the graph back with snapshot() - no gesture handlers.
Finished gestures (create/reassign connection, node moves incl. multi-select,
drops into groups, external-item drops, delete requests with connection
cascade) are forwarded into the store automatically, each as ONE undoable
step.

The history engine is self-contained (snapshot stacks over immutably updated
records - no extra dependency), with undo/redo and canUndo/canRedo signals.
EVERY behavior is overridable: subclass FFlowState - any CRUD method, any
apply* gesture handler, any protected building block - and install it via
withFlowState({ stateClass }); the auto-wiring dispatches through the
subclass. injectFlowState<TNode, TConnection>() returns the typed store;
connectionFactory/nodeFactory config hooks cover light-touch veto/shaping.
The classic event-driven API is untouched; the undo-redo-v2 example is
rewritten onto the plugin and its docs page updated.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
… the manual pattern

The state plugin gets its own example and page (/examples/state): the same
minimal editor, now documented in depth - auto-applied gestures, built-in
undo/redo, load()/snapshot() data flow, programmatic editing sharing the same
history, factory hooks, and full behavior overriding through an FFlowState
subclass. undo-redo-v2 is restored to its original @foblex/mutator version, so
both patterns stay demonstrated side by side.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
- Add a first-class groups collection (IFStateGroup, groups() signal,
  addGroups/updateGroup/removeGroups/getGroup); moves and drop-to-group
  handle groups, group removal cascades connections and un-parents children.
- Add changes() version signal that ticks once per history step, so a
  single effect can drive autosave/persistence.
- Add selectionInHistory config: selection() is always a live signal, and
  opting in makes a selection change its own undoable step, restored on
  undo/redo via the flow's select().
- Flatten the record models: drop the data/type wrapper. Records hold only
  framework fields and the store is generic over your own shape
  (FFlowState<TNode extends IFStateNode>); external-item drops spread fData
  onto the node as its own fields.
- Expose FFlowBase.select so the controller restores selection through the
  public contract.
- Expand the flow-state example (external palette, group) and rewrite its docs.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
…itignore

- Added image support for the Angular Diagram Example.
- Included light and dark mode images for better visual representation.
- Updated .gitignore to exclude design documentation directory.
- Changed isSelfConnectable property to false for f-connector directive.

Signed-off-by: Siarhei Huzarevich <shuzarevich@gmail.com>
- Added `dropToGroup` configuration option to control grouping behavior.
- Updated `applyDropToGroup` method to respect the `dropToGroup` setting.
- Modified `createNodeRecord` to handle parentId based on `dropToGroup`.
- Enhanced documentation to clarify the new drop behavior.
- Added tests for drop-to-group scenarios, including disabled grouping.

Signed-off-by: Siarhei Huzarevich <shuzarevich@gmail.com>
- Added `dropToGroup` configuration option to control grouping behavior.
- Updated `applyDropToGroup` method to respect the `dropToGroup` setting.
- Modified `createNodeRecord` to handle parentId based on `dropToGroup`.
- Enhanced documentation to clarify the new drop behavior.
- Added tests for drop-to-group scenarios, including disabled grouping.

Signed-off-by: Siarhei Huzarevich <shuzarevich@gmail.com>
A group/node with fAutoSizeToFitChildren resized to fit its children
without emitting fNodeSizeChange/fGroupSizeChange, so listeners could
not track the auto-fitted size. Emit the measured rect after the fit,
guarded against the node's own stored rect so a settled fit stays
silent and can't feed a resize loop.

Closes #316

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
The fit ran synchronously in the container's size-change effect, so on
a state-driven change (undo re-parenting a child out) it executed
before Angular propagated [fNodeParentId] to the child directive,
counted the removed child and re-shrank the container - undo could not
restore its size. Defer only the fit dispatch to afterNextRender (same
change-detection cycle) for fAutoSizeToFitChildren nodes; the rest stays sync.

Closes #317

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
redraw() set width/height only when _size was defined; clearing the
size (input back to undefined, e.g. undo restoring an auto-fit node to
its content size) left the stale dimensions on the element. Remove
them when _size is falsy so the element sizes to content again.

Closes #318

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
With fAutoExpandOnChildHit + fAutoSizeToFitChildren a drop emitted the
transient soft-expand rect and then the settled auto-fit rect - two
events per gesture. Skip the soft-expand emit when the target also
auto-fits; groups that only auto-expand keep emitting it.

Closes #319

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
The controller subscribes to every node's and group's sizeChange and
forwards it to applyResize, which merges the new geometry into the
current shape WITHOUT its own history step (amendCurrent). So a group
auto-fitting after a child was added rides along with the action that
triggered it: one undo reverts both, and snapshot() carries the
settled size.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
Selection is now an undoable step out of the box (Figma/Photoshop
style), and a drag's leading selection folds into the same step as the
move. Pass withFlowState({ selectionInHistory: false }) to keep
selection out of the history (xyflow/tldraw style).

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
Bind [fGroupSize]/[fNodeSize] to the state so undo restores container
geometry, and refresh the managed-state docs for the on-by-default
selection history and size capture.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
Add an `fDropToGroup` signal input on fDraggable (default true) that
enables/disables the drop-to-group gesture at the library level.

- When on, the flow carries a reactive `f-drop-to-group` host class and
  the grouping() SCSS highlight is scoped under it, so disabling the
  feature also removes its styling.
- When off, DropToGroupPreparation registers an inert (empty-target)
  handler so the external-item finalize still finds one (it throws
  otherwise) but nothing reparents, nests, highlights, or emits
  fDropToGroup.

Also adds a live 'Drop to Group' toggle to the drag-to-group example and
documents the input across the guide, example, styling docs, STYLING.md
and llms-full.txt. Note: `fDropToGroup` is intentionally both an input
(toggle) and the existing output (event) — [fDropToGroup] binds the
input, (fDropToGroup) binds the event.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
The state plugin no longer reparents on a drop-to-group gesture unless
you opt in with `withFlowState({ dropToGroup: true })` — the default is
now false. Out of the box the gesture still fires (library-level
`fDropToGroup` stays on) but withFlowState ignores group membership: a
drop is a no-op and an external item lands at the top level.

- Flip the mergeFlowStateConfig default and update the field JSDoc +
  applyDropToGroup/createNodeRecord comments.
- The flow-state example opts in via withFlowState({ dropToGroup: true }).
- Controller-spec reparenting tests now pass { dropToGroup: true }; the
  plain setup() test asserts the new off-by-default behavior.
- Update the state example doc.

Co-authored-by: Siarhei Khudzinski <158575591+siarheikhudzinski@users.noreply.github.com>
- Introduced `getEventTargetElement` utility to resolve event targets across shadow boundaries.
- Implemented `getDeepElementsFromPoint` for hit-testing in nested shadow roots.
- Updated `isDragBlocker` to accept SVG elements.
- Enhanced `isOnFlowBackground` to utilize the new event target resolution method.
- Added badge to the state example in the documentation.
- Improved connection redraw logic to optimize performance in large flows.

Signed-off-by: Siarhei Huzarevich <shuzarevich@gmail.com>
@siarheihuzarevich
siarheihuzarevich force-pushed the feature/shuzarevich/v19.1.0 branch from db5d950 to a1c6c82 Compare July 13, 2026 10:39
@siarheihuzarevich
siarheihuzarevich marked this pull request as ready for review July 13, 2026 10:48
@siarheihuzarevich
siarheihuzarevich merged commit 1c882b1 into main Jul 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant