Back to Nocobase

Workflow canvas migrates as parallel worlds, with v2-owned contracts imported back by the legacy canvas

docs/adr/0003-workflow-canvas-progressive-migration.md

2.2.011.5 KB
Original Source

Workflow canvas migrates as parallel worlds, with v2-owned contracts imported back by the legacy canvas

The v1 workflow canvas (node-graph editor) and the new client-v2 canvas coexist as parallel worlds over the same workflows + flow_nodes data: distinguished only by URL/entry list (/admin/settings/workflow/workflows/:id reached from the legacy settings list vs /admin/workflow/workflows/:id reached from the WorkflowPane list), with no per-workflow flag and no runtime flip. The shared substrate the canvas depends on (Instruction, Trigger, dependency-free canvas contexts, render-dispatch helpers, linkNodes, the getCollectionFieldOptions field-tree builder, nodeVariableUtils, drag/clipboard pure logic, and the stylesheet) is owned from src/client-v2/ and imported back by the legacy canvas via the allowed v1 → v2 direction — one canonical source where possible, not two. The legacy canvas retires by deleting its settings list + route once the modern canvas reaches parity.

Considered Options

  • (A, chosen) Parallel worlds + v2-owned contracts and shared substrate. Two canvases over one dataset, switched by URL only. Formily-free shared substrate lives once in client-v2 and v1 imports it back. This now includes pure functions, dependency-free contexts, render-dispatch helpers, and the Instruction / Trigger contracts. Hook-ful provider shells that own runtime-specific side effects may still stay per-canvas, but once a surface has a v2 loader the legacy surface can call that v2 implementation instead of keeping a second copy.
  • (B) One canvas URL, runtime flip by flag. A single route renders v1 or v2 based on a per-workflow column / feature toggle. Rejected: forces the two canvases to share a mount point (dragging the settings/ProLayout shell back in) and creates a "half-migrated workflow" state to reason about, for no benefit over (A).
  • (C) Rewrite the canvas pure logic independently in v2. Rejected: the field-tree builder (~250 lines: relation lazy-load, type filtering, foreign keys) and the drag calculations (drop-impact, upstream/downstream collection) are the most bug-prone parts of the system; maintaining two copies of those during the dual-canvas period is exactly the risk (A) avoids — so they are shared as pure functions. (The drag/clipboard Provider shells, being hook-ful and side-effectful, are genuinely written twice — but they are thin wiring around the shared pure core, not the bug-prone logic.) v2 reuses the shared field-tree code, only adapting VariableOption → MetaTreeNode at the very end.

Consequences

  • Import direction inverts for relocated shared code. After relocation, src/client/ imports v2-owned contracts, dispatch helpers, contexts, field-tree logic, node-tree logic, and selected UI openers from the workflow plugin's src/client-v2/. A future reader seeing the legacy canvas import workflow logic from v2 should expect this — it is the deliberate mechanism that lets one implementation serve both canvases.
  • The build boundary allows v2-owned code to run in the legacy bundle, but v2 still cannot import v1. The relocation is build-time source sharing: v1's src/client/ imports the moved client-v2 modules and they are bundled into v1's own output. Runtime separation still holds (see below). Code shared this way must be either dependency-free or depend only on APIs valid in both runtimes (@nocobase/client-v2, @nocobase/flow-engine, antd, framework-neutral utilities). It must never import @nocobase/client from src/client-v2/. When shared code needs runtime state, v1 opens it through the current FlowEngine context or passes dependencies explicitly.
  • The two clients are separate runtimes, so the instruction registry is per-runtime self-populated — not shared, not cross-read. The v1 client runs at / and loads each plugin's client entry; the v2 client runs at /v/ and loads each plugin's client-v2 entry. They never coexist in one browser runtime. Consequently each has its own app/PluginManager and its own 'workflow' plugin instance with its own instruction registry: v1's registry is filled by node plugins' client entries (pm.get('workflow').registerInstruction(...)), v2's by their client-v2 entries — exactly as the v2 trigger registry already works (PluginWorkflowClientV2.triggers, self-populated; downstream client-v2 plugins call pm.get('workflow').registerTrigger(...) and resolve to the v2 instance). The modern canvas therefore reads its own v2 registry (plugin.getInstruction(type)), never v1's. There is no cross-runtime reference and no iron-rule hazard. A node type registered only in v1 simply isn't in the v2 registry: it is omitted from the v2 add-node menu, and an existing node of that type renders a placeholder card (topology intact), mirroring v1's "unknown node" branch.
  • Provider sharing is decided by dependencies, not by the file being a Provider. Dependency-free contexts such as FlowContext / NodeContext are shared from client-v2. Provider shells with runtime-specific hooks or pointer side effects may stay per-canvas while sharing their pure core. When a v2 loader owns a whole surface, the legacy canvas can open that v2 surface directly.
  • compile equivalence is a load-bearing, tested constraint. The relocated field-tree builder uses compile only to expand field titles (plain strings or {{t("…")}} i18n templates). Within that scope, v1's useCompile (Formily Schema.compile) and v2's useT() (flowEngine.translate, which natively expands {{t(…)}}) behave identically. They are not equivalent for arbitrary scope expressions ({{fn(arg)}}, {{$deps[0]}}) — but field titles never carry those. A characterization test pins this with an assertion that both expand the same {{t(…)}} title to the same translation; if a non-i18n expression ever reaches a field title, that test fails and surfaces the drift.
  • Render-extension points become loaders, distinguished by field name. A node's in-canvas render and config UI are independent migration points. The modern canvas reads loader fields on the Instruction — ComponentLoader, FieldsetLoader, PresetFieldsetLoader. Triggers follow the same naming: PresetFieldsetLoader, FieldsetLoader, TriggerFieldsetLoader. These are () => Promise<{ default: ComponentType }> loaders rendered with Suspense. The legacy lowercase data fields (fieldset, presetFieldset, triggerFieldset, view, scope, components) remain pass-through until the legacy surface drops them, at which point it falls through to the matching v2 loader.
  • Branch/CanvasContent/BranchContext are relocated as a second copy, not shared. Unlike the pure logic, these couple to <Node> (whose card differs between v1 and v2), so v2 gets its own Branch and v1 keeps its own until retirement. The shared-one-copy rule applies only to Formily-free, Node-independent logic.
  • useVariables stays untouched; the adapter never reaches v1. Each Instruction's useVariables keeps returning the legacy VariableOption. Only the v2 aggregator wraps it in the VariableOption → MetaTreeNode adapter; the v1 aggregator (client/variable.tsx) consumes VariableOption directly and has no code path to the adapter. The adapter is a v2-only consumer of a shared data source, provably isolated.
  • Test strategy: characterization baseline before the move. Before relocating, golden characterization tests are written on the v1 side (injected mock compile/collectionManager) covering the full pure-logic surface; after relocation the same v1 tests re-run green (proving v1's behavior is unchanged through the back-import), and equivalent v2 tests run the same mock inputs. DOM/pointer side effects of drag/clipboard stay covered by the existing e2e; only their pure functions (getDropImpact math, collectUpstreams/collectDownstreams) get unit baselines. The v1 characterization tests are deleted with v1 at retirement; the v2 tests persist.

Addendum: the condition node sets the per-node migration pattern

The condition node is the first core node to land all three modern loaders end-to-end; the choices it forced are the template every subsequent node follows.

  • NodeDefaultView is extracted and exported; ComponentLoader is whole-card replacement (mirrors v1's Component). Node.tsx's registered-node card is factored into an exported NodeDefaultView({ data, children }) carrying all card chrome (tag, editable title, menu, drag mousedown, click-to-open-config, copy/drag highlight) plus a children slot. The default render is <NodeDefaultView data />; when an instruction has a ComponentLoader, NodeCard renders the loader instead, and the loader re-wraps <NodeDefaultView data>{subtree}</NodeDefaultView> (the condition node appends its Yes/No <Branch> subtrees). This keeps the v1 "Component replaces the whole card" semantics — branch nodes self-render their nested branches — while sharing one card implementation.
  • The add-node preset flow is wired to PresetFieldsetLoader + DownstreamBranchIndex. AddNodeContext.onCreate opens a small ctx.viewer.dialog (v1's Action.Modal analogue) when the instruction has a PresetFieldsetLoader OR a branching node is inserted above an existing downstream node. The dialog hosts the lazy preset form plus the downstream-placement radio, and submission creates the node then re-parents the downstream node into the chosen branch — byte-for-byte the behaviour of v1's useAddNodeSubmitAction. Mode (rejectOnFalse) is chosen here and rendered read-only in the config drawer, because the branch topology can't be flipped cleanly after the fact.
  • Per-node config components are re-authored in client-v2, mirroring v1 paths/names. Calculation.tsx, RadioWithTooltip.tsx, renderEngineReference.tsx live at the same relative paths under client-v2/components/ as their v1 counterparts (low cognitive cost), Formily-free: css/cx from @emotion/css, useCompileuseT, and pure helpers take an injected t. v1's copies are untouched.
  • The variable aggregator is restructured into v1's multi-scope shape, lit progressively. useWorkflowVariableOptions now concatenates per-scope contributors ($jobsMapByNodeKey, $env, $context, $system, $scopes) and filters empties — same shape as v1. Two scopes are live (node-result via useVariables + adapter; $env from the global getPropertyMetaTree(), independent of any node/trigger migration); the other three are explicit empty stubs lit when their v2 data sources exist (triggers' useVariables, a systemVariables registry, branch nodes' useScopeVariables). Lighting one up is filling a stub, not restructuring.
  • Calculation operands reuse the core TypedVariableInput via an injected metaTree. Rather than duplicate the constant-or-variable switcher, the core @nocobase/client-v2 TypedVariableInput gained an optional metaTree prop (skip the global tree, use the injected one) plus lazy loadData for function-children (relation field drill-down) — a backward-compatible enhancement (existing namespaces/extraNodes callers are unaffected; their trees are pre-resolved arrays). The workflow operand is then a one-line <TypedVariableInput metaTree={useWorkflowVariableOptions()} />, structurally matching v1's one-line <Variable.Input useTypedConstant scope={…} />. The expression field (non-basic engines) reuses the existing WorkflowVariableInput (single-line VariableHybridInput → FlowContextSelector), which already carries double-click-to-select and lazy loading for free.