mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
- **[Reduce card flicker on drag by only writing changed fields on a card move](https://github.com/wekan/wekan/issues/6430)** (#6430):
`Card.move()` wrote `boardId`/`swimlaneId`/`listId` into the update unconditionally, even for a same-board drag to another list (the reported case). Keeping `boardId` in the `$set` on every drag re-ran the `boardId`-gated `Cards.after.update` hook that re-syncs the card's checklists and checklist items via `multi` updates, ran the cross-board consistency guard and the `denyCrossBoardMove` deny-rule DB lookup, and invalidated more reactive dependents than necessary — server work and reactivity churn that contributed to a ~1s card flicker on large boards. `Card.move()` now writes **only the fields that actually change** (via the pure `computeCardMoveModifier` helper) and skips the write entirely when a card is dropped back in the same place, so a same-board move no longer touches boardId or the cross-board hooks. The `moveCard`/`moveCardBoard` activity generators already re-check `doc.X !== oldX`, so trimming the modifier does not drop any activity. Note: this reduces the drag-time work behind the flicker; the residual reactive re-render cost on very large boards is a separate performance topic. Thanks to mimZD and xet7 ! Fixes #6430
This commit is contained in:
1 parent
ed77abd90d
commit
2e7c4ede2a
5 files changed
+178
-4
No files matched your search
@@ -81,6 +81,21 @@ This release adds the following fixes:
|
||||
duplicates; boards that already accumulated thousands still need a one-off cleanup.
|
||||
Thanks to brlin-tw and xet7.
|
||||
|
||||
- **[Reduce card flicker on drag by only writing changed fields on a card move](https://github.com/wekan/wekan/issues/6430)** (#6430):
|
||||
`Card.move()` wrote `boardId`/`swimlaneId`/`listId` into the update unconditionally, even for a
|
||||
same-board drag to another list (the reported case). Keeping `boardId` in the `$set` on every drag
|
||||
re-ran the `boardId`-gated `Cards.after.update` hook that re-syncs the card's checklists and
|
||||
checklist items via `multi` updates, ran the cross-board consistency guard and the
|
||||
`denyCrossBoardMove` deny-rule DB lookup, and invalidated more reactive dependents than
|
||||
necessary — server work and reactivity churn that contributed to a ~1s card flicker on large
|
||||
boards. `Card.move()` now writes **only the fields that actually change** (via the pure
|
||||
`computeCardMoveModifier` helper) and skips the write entirely when a card is dropped back in the
|
||||
same place, so a same-board move no longer touches boardId or the cross-board hooks. The
|
||||
`moveCard`/`moveCardBoard` activity generators already re-check `doc.X !== oldX`, so trimming the
|
||||
modifier does not drop any activity. Note: this reduces the drag-time work behind the flicker; the
|
||||
residual reactive re-render cost on very large boards is a separate performance topic.
|
||||
Thanks to mimZD and xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.75 2026-07-05 WeKan ® release
|
||||
|
||||
+11
-3
@@ -2496,10 +2496,18 @@ Cards.helpers({
|
||||
sort: this.sort,
|
||||
};
|
||||
|
||||
const mutatedFields = { boardId, swimlaneId, listId };
|
||||
// #6430: only write the fields that actually change. Writing boardId on a
|
||||
// same-board move (drag to another list) needlessly triggered the cross-board
|
||||
// after/before hooks (checklist boardId re-sync multi-updates, consistency
|
||||
// guard, denyCrossBoardMove DB lookup) and extra reactive invalidations,
|
||||
// contributing to a ~1s card flicker on large boards. See lib/cardMoveModifier.
|
||||
const { computeCardMoveModifier } = require('./lib/cardMoveModifier');
|
||||
const mutatedFields = computeCardMoveModifier(this, { boardId, swimlaneId, listId, sort });
|
||||
|
||||
if (sort !== null) {
|
||||
mutatedFields.sort = sort;
|
||||
// A true no-op (e.g. a card dropped back in the same place) — skip the write
|
||||
// so it does not run the move hooks/activities or trigger a reactive re-render.
|
||||
if (Object.keys(mutatedFields).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.boardId !== boardId) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
// Pure helper that computes the $set modifier for Card.move(): only the fields
|
||||
// that ACTUALLY change relative to the card's current position. Extracted from
|
||||
// models/cards.js so it is unit-testable without Meteor.
|
||||
//
|
||||
// #6430 (card flicker ~1s on drag on large boards): Card.move() used to write
|
||||
// boardId/swimlaneId/listId unconditionally, even when the card stayed on the
|
||||
// same board/swimlane (the common "drag to another list" case). That meant every
|
||||
// drag:
|
||||
// - re-ran the Cards.after.update hook that boardId-re-syncs the card's
|
||||
// checklists and checklistItems via multi updates (server load + reactive
|
||||
// re-publish of those collections),
|
||||
// - ran the cross-board consistency before-hook and the denyCrossBoardMove
|
||||
// deny-rule DB lookup, and
|
||||
// - invalidated more reactive dependents than necessary.
|
||||
// Writing only the changed fields skips all of that for same-board moves, and an
|
||||
// empty result means a true no-op (card dropped back in place) that the caller
|
||||
// can skip entirely.
|
||||
|
||||
function computeCardMoveModifier(card, target = {}) {
|
||||
const { boardId, swimlaneId, listId, sort } = target;
|
||||
const fields = {};
|
||||
if (boardId !== undefined && boardId !== card.boardId) fields.boardId = boardId;
|
||||
if (swimlaneId !== undefined && swimlaneId !== card.swimlaneId) {
|
||||
fields.swimlaneId = swimlaneId;
|
||||
}
|
||||
if (listId !== undefined && listId !== card.listId) fields.listId = listId;
|
||||
// sort === null means "keep current" (Card.move's default), so it is never a change.
|
||||
if (sort !== undefined && sort !== null && sort !== card.sort) fields.sort = sort;
|
||||
return fields;
|
||||
}
|
||||
|
||||
module.exports = { computeCardMoveModifier };
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "meteor test --once --driver-package meteortesting:mocha",
|
||||
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/unit/resetPasswordEmail.test.js",
|
||||
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/unit/resetPasswordEmail.test.js",
|
||||
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
|
||||
"test:watch": "meteor test --driver-package meteortesting:mocha",
|
||||
"test:e2e": "node tests/e2e/list-regressions.js",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
// Plain-Node unit test (no Meteor) for the Card.move() modifier builder.
|
||||
// Run: node tests/cardMoveModifier.test.cjs
|
||||
//
|
||||
// Regression guard for #6430 ("Card flicker ~1s on drag on large boards"):
|
||||
// Card.move() used to write boardId/swimlaneId/listId unconditionally. On a
|
||||
// same-board drag (the reported case: card dragged to another LIST) that still
|
||||
// put boardId in the $set, so the Cards.after.update hook re-synced the card's
|
||||
// checklists/checklistItems (multi updates), the cross-board consistency guard
|
||||
// and denyCrossBoardMove DB lookup ran, and extra reactive dependents were
|
||||
// invalidated — server work + churn that contributed to the flicker. The fix
|
||||
// writes ONLY the fields that actually change; an empty result is a true no-op
|
||||
// the caller skips entirely.
|
||||
|
||||
const assert = require('assert');
|
||||
const { computeCardMoveModifier } = require('../models/lib/cardMoveModifier');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
// A card currently on board B1, swimlane S1, list L1, sort 3.
|
||||
const CARD = { boardId: 'B1', swimlaneId: 'S1', listId: 'L1', sort: 3 };
|
||||
|
||||
// --- POSITIVE: only changed fields are written ------------------------------
|
||||
test('same-board drag to another list writes only listId (+ new sort)', () => {
|
||||
const mod = computeCardMoveModifier(CARD, {
|
||||
boardId: 'B1', // unchanged
|
||||
swimlaneId: 'S1', // unchanged
|
||||
listId: 'L2', // changed
|
||||
sort: 5, // changed
|
||||
});
|
||||
assert.deepStrictEqual(mod, { listId: 'L2', sort: 5 });
|
||||
});
|
||||
|
||||
test('swimlane-only move writes only swimlaneId', () => {
|
||||
const mod = computeCardMoveModifier(CARD, {
|
||||
boardId: 'B1',
|
||||
swimlaneId: 'S2',
|
||||
listId: 'L1',
|
||||
sort: 3,
|
||||
});
|
||||
assert.deepStrictEqual(mod, { swimlaneId: 'S2' });
|
||||
});
|
||||
|
||||
test('cross-board move writes boardId, swimlaneId and listId', () => {
|
||||
const mod = computeCardMoveModifier(CARD, {
|
||||
boardId: 'B2',
|
||||
swimlaneId: 'S9',
|
||||
listId: 'L9',
|
||||
sort: 0,
|
||||
});
|
||||
assert.deepStrictEqual(mod, {
|
||||
boardId: 'B2',
|
||||
swimlaneId: 'S9',
|
||||
listId: 'L9',
|
||||
sort: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('sort === null means "keep current" and is never written', () => {
|
||||
const mod = computeCardMoveModifier(CARD, {
|
||||
boardId: 'B1',
|
||||
swimlaneId: 'S1',
|
||||
listId: 'L2',
|
||||
sort: null,
|
||||
});
|
||||
assert.deepStrictEqual(mod, { listId: 'L2' });
|
||||
});
|
||||
|
||||
test('an unchanged sort is not written', () => {
|
||||
const mod = computeCardMoveModifier(CARD, { listId: 'L2', sort: 3 });
|
||||
assert.deepStrictEqual(mod, { listId: 'L2' });
|
||||
});
|
||||
|
||||
test('sort 0 is a real value and IS written when it differs', () => {
|
||||
const mod = computeCardMoveModifier(CARD, { listId: 'L1', sort: 0 });
|
||||
assert.deepStrictEqual(mod, { sort: 0 });
|
||||
});
|
||||
|
||||
// --- NEGATIVE: the #6430 needless-boardId-write must not come back ----------
|
||||
test('NEGATIVE: a same-board move must NOT include boardId (triggered the checklist re-sync hook)', () => {
|
||||
const mod = computeCardMoveModifier(CARD, {
|
||||
boardId: 'B1',
|
||||
swimlaneId: 'S1',
|
||||
listId: 'L2',
|
||||
sort: 5,
|
||||
});
|
||||
// The old code wrote { boardId:'B1', swimlaneId:'S1', listId:'L2', sort:5 };
|
||||
// boardId in the $set is exactly what re-ran the boardId-gated after.update
|
||||
// hook (Checklists/ChecklistItems multi updates) on every drag.
|
||||
assert.ok(!('boardId' in mod), 'boardId must be absent for a same-board move');
|
||||
assert.ok(!('swimlaneId' in mod), 'unchanged swimlaneId must be absent');
|
||||
});
|
||||
|
||||
test('NEGATIVE: a true no-op drop yields an EMPTY modifier (caller skips the write)', () => {
|
||||
const mod = computeCardMoveModifier(CARD, {
|
||||
boardId: 'B1',
|
||||
swimlaneId: 'S1',
|
||||
listId: 'L1',
|
||||
sort: 3,
|
||||
});
|
||||
assert.deepStrictEqual(mod, {});
|
||||
assert.strictEqual(Object.keys(mod).length, 0);
|
||||
});
|
||||
|
||||
test('NEGATIVE: undefined target fields are ignored, not written as undefined', () => {
|
||||
const mod = computeCardMoveModifier(CARD, { listId: 'L2' });
|
||||
assert.deepStrictEqual(mod, { listId: 'L2' });
|
||||
assert.ok(!('boardId' in mod) && !('swimlaneId' in mod) && !('sort' in mod));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passing`);
|
||||
Reference in new issue
Block a user