Skip to content

fix(app-shell): renaming a flow node carries its edges, and commits only a finished, unique id (objectui#11827) - #11837

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11827-flow-node-rename-edges
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11827-flow-node-rename-edges

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11827

Clause-②: no

What was wrong, measured on main (32124ac)

FlowNodeInspector's ID field was the shared InspectorTextField with onCommit={(v) => patchNode({ id: v })}. That field commits from onChange, so every keystroke was a rename of its own, and the patch carried nodes only: every edge naming the old id kept naming it.

This PR's pins were first run with the tests added and the source unchanged (32124ac): 31 failed, 4 passed (35). There were two failure signatures:

  • after typing r: expected [ { nodes: [ …(3) ] } ] to deeply equal []: the first character typed was already a rename.
  • Unable to find a label with the text of: ID, right after the first change event. The selection still named the old id, so locateFlowNode found nothing and the inspector dropped to its empty state after one character. Through the real canvas and inspector, s → x → e renamed x → renamed left both edges dangling, and the Problems panel listed them.

The 4 that passed on main are the controls and are meant to pass: a label edit, read-only, a nested node's read-only id, and a duplicate carrier keeping its edges.

The design

The field: a draft that commits a finished id

A local FlowNodeIdField in FlowNodeInspector.tsx holds what the author types and commits on blur or Enter. Escape puts the stored id back. An Enter that ends an IME composition is the composition's, so it does not commit. The shared InspectorTextField is untouched: every other inspector field still commits on change.

A refused id is never handed to the write. The field shows the stored id again, and the reason sits under it in the flow inspectors' existing refusal line (role="alert", the destructive text style FlowEdgeInspector and FlowNodeConfigField use), with aria-invalid and aria-describedby on the input. The reason stays until the author edits again.

What is refused, and where each rule comes from

nodeRenameRefusal sits in flow-problems.ts beside freshNodeId and edgesAfterNodeRemoval.

  • Another node already has the id. This is the spec's one rule about a node id: FlowSchema refuses a duplicate, and a flow has ONE node id space, the top-level nodes[] plus every region body at every depth. The id space is walked with the spec's own exported collectFlowGraphs, not a hand-rolled walk. Measured on the installed @objectstack/spec 17.7.0: a loop-body node reusing a top-level id is refused at nodes.1.config.body.nodes.0.id.
  • Empty. The spec's FlowNodeSchema.id is a bare z.string(). Measured: FlowSchema.safeParse accepts the empty string, a single space and a b. The designer's own structural check (validateFlowDraft, the engine.flowValidate.nodeMissingId row) already reports a node with no id as an error, so the designer's own write never makes one. No format rule is invented beyond that.
  • An edge still names the id, but no node has it. This one is a design choice. The rename would pick up that stale edge: an edge left naming a missing node is a socket the next node with that id plugs into. That is why objectui#11772's freshNodeId counts edge endpoints as taken. The node would gain a connection the author never drew, or a second copy of one it has (a repeated connection, which this same module flags). The alternative, allowing it as a "repair by rename" gesture, was rejected because it re-attaches silently. The message tells the author to remove or reconnect that edge first.
  • Not refused: an id seen earlier in the session but no longer in the draft. Only a minted id avoids those (objectui#11772's ledger); a deliberate rename may take one.

What one patch carries

  • Every top-level edge endpoint that named the old id: edgesAfterNodeRename, beside edgesAfterNodeRemoval and mirroring its signature. Only source / target move. Each edge keeps its id, condition, label, isDefault, type and its position in the list (the order a decision's branches are tried in). An untouched edge is the same object. While another node still carries the old id (a duplicate, itself a Problems error), the edges are that node's too and stay, which is the removal's rule on the same draft.
  • boundaryConfig.attachedToNodeId: boundaryRefsAfterNodeRename. It is the one key on the spec's FlowNodeSchema, besides an edge endpoint, that names another node by id ("Host node ID this boundary event monitors"). A boundary event left attached to nothing would be named by no Problems row. Top-level nodes only, since a nested node cannot be renamed here.
  • The selection follows through onSelectionChange, which both hosts (the Studio Automations pillar and ResourceEditPage) pass as setSelection. So FlowCanvas.tsx and FlowPreview.tsx are not touched.
  • A decision branch's Target column needs nothing: it is virtual, derived from the out-edges, so it follows the edges. The spec's DecisionConditionSchema is strict and its guidance says a target is never stored on the branch.
  • objectui#11772's session ledger is unchanged. A renamed-away id stays "seen", because the ledger never forgets, so Add connected node never mints it. Pinned through the real canvas.

What a rename does not carry, and why

  • Expression references to a node's outputs (NODEID.decision, NODEID.KEY in CEL text) are not rewritten. Measured after this fix with a throwaway probe through the real inspector and buildFlowProblems: a downstream decision reading x.decision == 'approve' keeps it after x → renamed, and the Problems panel shows only a warning: ok: `x` is not a reference in scope at this step. Rewriting CEL text is not a mechanical edit, so it is outside this card. It is reported to the seat as a finding.
  • Nested (region) nodes: their ids stay read-only in the inspector, as before. Region edges name region nodes only.
  • The canvas's expanded state for a container is session-only view state keyed by id, so a renamed expanded container collapses. It is ephemeral state in the AGENTS.md feat: add live playground for interactive schema demonstration #8 sense.

Pins

  • inspectors/FlowNodeInspector.renameNode-11827.test.tsx, through the real component under a host that merges shallowly and keeps a selection:
    • the card's s → x → e pin: one patch, both edges follow, nothing dangles;
    • Enter commits;
    • r, re, ren write nothing until blur;
    • the selection follows;
    • every edge attribute and the order are kept;
    • a boundary event follows;
    • a duplicate carrier keeps its edges;
    • control: a label edit's patch carries no edges;
    • refusals: taken (top-level and inside a loop region), empty, edge-named. Each writes nothing, reverts the field and is announced, and it clears on the next edit;
    • Escape;
    • an IME Enter;
    • zh-CN text;
    • read-only and nested stay disabled.
  • previews/FlowPreview.renameNode-11827.test.tsx, through the real canvas and inspector side by side:
    • the card's pin, with the Problems panel empty after the rename;
    • the canvas and inspector stay on the renamed node;
    • after renaming node_1, Add connected node does not mint node_1.
  • previews/flow-problems.renameNode-11827.test.ts: the three pure helpers, including region depth (a parallel branch inside a loop body) and a mid-edit draft with holes.

Ablation

The fix is committed first. Then the field's onRename={rename} is put back to onRename={(v) => patchNode({ id: v })}, the old write alone, through ablation-replace.mjs in WRAP mode (it restores on EXIT, INT and TERM against an absolute path):

  • the mutation landed: anchor x1 to x0, replacement x0 to x1, blob 634f4e3 to 96e6d3c;
  • 8 failed, 13 passed (21) across the inspector and canvas pin files. Exactly the rename pins went red: card pin, Enter, selection follows, edge attributes, boundary event, and the three canvas pins. The refusal pins stayed green, as expected, because the field still judges the id;
  • restored: blob after restore 634f4e3 equals HEAD, and git diff HEAD is empty. Re-checked independently with git hash-object against git rev-parse HEAD:PATH.

The pins import the component from source (relative specifiers), so no dist/ sits on the ablation's resolution path.

Gates

Every reading is at f8e702b, the head this PR opens on, unless marked otherwise. objectui#11779 landed during this work; main (16cda0b) was merged in, with no rebase. The one conflict was mechanical: both imports at the head of flow-problems.ts, and the edit-writers section header.

  • Dependency closure built first: pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, 29 packages, exit 0.
  • The whole package, at 714010b, before the merge: pnpm exec vitest run packages/app-shell/ gave 1072 files passed, 1 skipped (1073); 10534 tests passed, 9 skipped; exit 0.
  • After the merge, at f8e702b:
    • pnpm --filter @object-ui/app-shell type-check (the script echoed tsc --noEmit && tsc -p tsconfig.test.json): exit 0.
    • A targeted vitest run of 97 files: this PR's 3 pin files, every flow designer test under inspectors/ and previews/, the Problems panel tests, every test in main's delta, the Studio flow and automation tests, and the designer-table i18n tests. Result: 97 passed; 1481 tests passed, 1 skipped; exit 0.
    • The whole package was not re-run after the merge, and this narrowing is declared. main's delta is app-shell only and passed its own CI, and CI runs the full farm on this merge.
  • eslint: pnpm exec eslint --no-inline-config on the 6 changed source and test files: exit 0, 0 errors. The 4 warnings are all on lines this PR does not touch.
  • check:*, exit 0 each:
    • control-bytes, test-path-roots, changeset-claims, pending-changeset-literals;
    • i18n-keys, i18n-drift, i18n-dead-keys (none of the three new rows is a candidate), i18n-designer-parity;
    • vi-mock-specifiers, vi-mock-inherit, vi-mock-override-shape;
    • new-line-citations (0 added by this branch);
    • changeset-presence, changeset-no-major, changeset-overwrite, changeset-fixed;
    • spec-symbols, designer-field-key-parity, unreferenced-sources, phantom-deps, self-import, handler-key-reads, metadata-write-doors, lint-coverage, type-check-coverage, shell-escape-residue.
  • NOT MEASURED: check:eager-closure (PREREQUISITE NOT MET; see Acceptance notes).
  • Not owed: check:readme-exports, since no export changed.

Acceptance notes

  • The edge-named refusal is a design choice the reviewer may overturn. The alternative is to allow it as a repair gesture. It is named above with why.
  • Expression references are not carried (above). The seat files it if it agrees.
  • check:eager-closure is NOT MEASURED locally (PREREQUISITE NOT MET: it reads a built console bundle). No new module enters any closure. flow-problems.ts already reached @objectstack/spec/automation at runtime through flow-expr-problems.ts and screen-spec.ts (predicateSlotRefusal), and FlowNodeInspector.tsx already reached @object-ui/components through _shared.tsx. Only the tree-shaken bytes of collectFlowGraphs can move. Left to CI.
  • InspectorTextField's commit-on-change behaviour is not changed (⛔ by the claim). The ID field's draft is local to this panel.
  • No new export, prop, type member or language-pack key: the three refusal strings are rows of the metadata-admin designer's own unexported tables (en and zh), and check:i18n-designer-parity is green.

Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8


Generated by Claude Code

claude added 3 commits October 7, 2026 22:05
…nly a finished, unique id (objectui#11827)

The node inspector's ID field committed patchNode({ id }) on every
keystroke with only `nodes` in the patch: every edge naming the old id
was left dangling, and the selection kept naming the old id, so the
inspector lost the node after one character.

- The ID field holds a local draft and commits on blur or Enter; Escape
  reverts; an IME-composition Enter is not a commit.
- A finished id is refused inline when it is empty, when another node in
  the flow's one id space has it (regions included, walked with the
  spec's collectFlowGraphs), or when an edge still names it.
- One patch carries the node's new id, every edge endpoint that named the
  old id (edgesAfterNodeRename) and a boundary event's host
  (boundaryRefsAfterNodeRename); the selection follows the node.

Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8
Co-authored-by: Claude <noreply@anthropic.com>
objectui#11779 landed first and edited flow-problems.ts: both imports are
kept (createContext beside collectFlowGraphs), and the edit-writers section
header keeps this branch's wording with main's "component-free".

Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 336 chunks) 3523.0 KB 3551.8 KB
Main entry chunk (gzip) 157.0 KB 350 KB
Entry file index-D1gx4oHQ.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.82KB 6.58KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 579.68KB 139.46KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 235.41KB 65.46KB
fields (index.js) 266.88KB 67.46KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 38.37KB 10.31KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.50KB 11.82KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 199.63KB 47.46KB
plugin-dashboard (index.js) 144.82KB 39.17KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 247.86KB 65.27KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 176.62KB 45.75KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 239.75KB 65.92KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 116.85KB 29.12KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.10KB 11.81KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.07KB 22.93KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 23:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 23:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 87f7b6c Oct 7, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11827-flow-node-rename-edges branch October 7, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

flow designer: renaming a node in the inspector's ID field leaves its edges naming the old id, so the renamed node is disconnected

1 participant