Skip to content

fix(kanban): a rejected drag rolls the card back on the external-data path too (#4138) - #4204

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4138-kanban-reject-rollback
Aug 10, 2026
Merged

fix(kanban): a rejected drag rolls the card back on the external-data path too (#4138)#4204
yinlianghui merged 1 commit into
mainfrom
claude/issue-4138-kanban-reject-rollback

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4138

An illegal Kanban drag that the server rejects (PATCH 400 invalid_transition) left the card stuck in the target column until a manual reload — but only when the board is hosted by a parent supplying records through the data prop, which is the ListView/console path real users meet. The toast fired and the server value was unchanged, so the board kept showing a move that had not happened.

Root cause, confirmed at origin/main @ b1e42d09b

packages/plugin-kanban/src/ObjectKanban.tsxhandleCardMove ran its failure revert only inside if (!hasExternalData). The premise recorded beside it ("parent handles refresh", line 147) holds for an accepted move: the parent's mutation subscription refetches and the new value propagates. A rejected move changes nothing server-side, so that refetch is never triggered and nothing un-said the optimistic move. Premise of the issue verified, unchanged, and reproduced as a failing test before any edit.

Where the optimistic state actually lives (measured first)

Not in ObjectKanban. This was measured before writing the fix, because the revert has to clear exactly the layer that holds the move:

  • KanbanImpl's handleDragEnd moves the card inside its own boardColumns state and only then calls onCardMove. That is why the card visibly moves on both data paths, with no parent re-render involved.
  • boardColumns is re-synced from the columns prop by an effect keyed on that prop's identity.
  • columns comes from KanbanRenderer, which re-buckets records into a fresh array whenever its schema prop changes identity — and ObjectKanban hands it a new object literal on every render.

So a re-render of ObjectKanban is what un-says the move, by re-bucketing the source-of-truth records. fetchedData is read in exactly one place (the rawData line, 219), and on the external path externalData wins there — which is why the old gated revert was a no-op that nothing could observe.

The fix

One unconditional revert serving both ownerships — deliberately not a second revert path:

  • internal data: fetchedData is the source of truth, so the map corrects the record and re-renders.
  • external data: fetchedData is unread and normally empty, but Array#map always allocates, so the fresh identity re-renders the component and the board re-buckets from externalData — which the server never changed. That is the revert: the card returns to fromColumnId.

The optimistic write on the way in stays gated on internal data deliberately, and the asymmetry is now pinned by a control: writing fetchedData on the external path would re-render against the unchanged parent records and snap an accepted move back before the server had answered.

No host-notification API was added (per the direction ruling on the card); none turned out to be needed, since the revert is the board's own display state.

Tests

New pin packages/plugin-kanban/src/ObjectKanban.rejectedMoveRollback.test.tsx drives the board through DndContext's real onDragEnd, so KanbanImpl's local move, the persist, the failure branch and the re-bucket are all the production code path.

case before after
external + rejected (the issue) RED — card stuck in "In Progress" GREEN
internal + rejected (control) GREEN GREEN
external + accepted (control) GREEN GREEN
internal + accepted (control) GREEN GREEN
rejection toast still fires (control) GREEN GREEN

Red-first, pre-fix, exactly the QA signature — Backlog empty, card left in the target column:

FAIL  ObjectKanban.rejectedMoveRollback.test.tsx > reverts on the EXTERNAL-data path (ListView-hosted): card returns to Backlog
AssertionError: expected [] to deeply equal [ 'Fix the widget' ]
 Test Files  1 failed (1)
      Tests  1 failed | 4 passed (5)

Reverse verification (after committing, git checkout origin/main -- packages/plugin-kanban/src/ObjectKanban.tsx, tests kept). Direction predicted before running: RED on the external+rejected case only, with the stuck-card signature; the four controls stay green. Confirmed exactly — Tests 1 failed | 4 passed (5), same assertion as above. Restored with git checkout HEAD -- ....

Full package, on the restored tree:

pnpm vitest run packages/plugin-kanban --maxWorkers=2
 Test Files  10 passed (10)
      Tests  52 passed (52)

pnpm --filter @object-ui/plugin-kanban type-check   # tsc --noEmit && tsc -p tsconfig.test.json — clean
pnpm --filter @object-ui/plugin-kanban lint         # 0 errors (98 pre-existing no-explicit-any warnings)
npm run check:control-bytes                         # OK (3894 tracked text files)

Dependency closure built first (pnpm --filter '@object-ui/plugin-kanban^...' build) so the type-check read fresh declarations rather than stale dist.

Changeset: .changeset/kanban-rejected-move-rollback-4138.md (@object-ui/plugin-kanban patch) — user-visible behavior change.


Generated by Claude Code

… path too (#4138)

`handleCardMove`'s failure revert was gated on `!hasExternalData`, so the
ListView-hosted board (which receives records via the `data` prop) left the
card in the target column after a server rejection until a manual reload.
The comment's premise — "parent handles refresh" — holds for an accepted
move but not a rejected one: nothing changes server-side, so no refetch is
ever triggered.

The revert is now unconditional, which is what makes it one code path for
both ownerships. The card's on-screen position lives in `KanbanImpl`'s
`boardColumns`, re-synced from the `columns` prop on every identity change,
so a re-render of `ObjectKanban` is what un-says the move: on internal data
the map corrects the record, on external data the fresh array identity
re-renders and the board re-buckets from the parent's unchanged records.

The optimistic write on the way in stays gated deliberately — doing it on
the external path would snap an accepted move back before the server
answered — and both directions are now pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 10, 2026 11:23pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.3 KB 350 KB
Entry file index-DxbUm9If.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 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) 486.25KB 107.52KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 140.66KB 36.25KB
fields (index.js) 226.96KB 56.30KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.87KB 10.80KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.52KB 17.49KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.52KB 30.68KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 162.81KB 39.67KB
plugin-grid (index.js) 188.04KB 49.91KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.04KB 26.67KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 10, 2026 23:35
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 2c8ad7c Aug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4138-kanban-reject-rollback branch August 10, 2026 23:36
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.

Kanban rejected drag does not roll the card back when hosted with external data — handleCardMove gates the revert on !hasExternalData

2 participants