Cards added in List view no longer land in an archived swimlane.

Board.getDefaultSwimline()/getDefaultSwimlineAsync() picked the first swimlane for the board with no archived filter (ReactiveCache.getSwimlane({ boardId })), so when the first/default swimlane was archived, a card added in List view was assigned to that archived swimlane and became invisible in Swimlane view (visible only in List view until the swimlane was unarchived). Both getters now choose via a pure pickDefaultSwimlane() helper that prefers the first NON-archived swimlane, falling back to the first one only when every swimlane is archived (so callers reading ._id never crash) and returning undefined only when the board has no swimlanes at all -- the single case where the server self-heals a fresh default (#6382/#6429 idempotency preserved).

Tests: tests/defaultSwimlane.test.cjs gains pickDefaultSwimlane cases -- the reported scenario (default archived + active swimlanes -> first active), unchanged behaviour when the first is active, all-archived fallback, empty/null -> undefined, skipping null entries, and a source guard that both getters route through the helper.

Thanks to rangersdo and xet7 !

Fixes #1971,
This commit is contained in:
Lauri Ojansivu committed 2026-07-22 10:15:50 +03:00
1 parent 22d1c5505b
commit 52bb15594b
3 files changed
+99 -32

No files matched your search

+29 -31
View File
@@ -1818,25 +1818,24 @@ Boards.helpers({
},
getDefaultSwimline() {
let result = ReactiveCache.getSwimlane({ boardId: this._id });
if (result === undefined) {
// Check if any swimlane exists for this board to avoid duplicates
const existingSwimlanes = ReactiveCache.getSwimlanes({ boardId: this._id });
if (existingSwimlanes.length > 0) {
// Use the first existing swimlane
result = existingSwimlanes[0];
} else if (Meteor.isServer && this._id) {
// Issue #6382: only the server may auto-create the default swimlane.
// On the client this getter runs inside reactive render contexts; when a
// board's swimlanes are not yet loaded/subscribed (e.g. the default
// subtasks board viewed via "All boards"), getSwimlanes() is transiently
// empty and every re-render would insert another empty swimlane —
// producing thousands of them and freezing the browser. The server
// creates the default swimlane at board creation and self-heals here.
// Issue #6429: self-heal via a DETERMINISTIC _id so concurrent/repeated
// calls are idempotent (see ensureDefaultSwimlaneId).
result = this.ensureDefaultSwimlaneId();
}
// Issue #1971: pick a NON-archived swimlane so a card added in List view
// never lands in an archived (invisible-in-swimlane-view) swimlane. The pure
// pickDefaultSwimlane helper prefers the first active swimlane, falling back
// to the first one only when every swimlane is archived (so callers reading
// ._id never crash).
const { pickDefaultSwimlane } = require('./lib/defaultSwimlane');
let result = pickDefaultSwimlane(ReactiveCache.getSwimlanes({ boardId: this._id }));
if (result === undefined && Meteor.isServer && this._id) {
// Issue #6382: only the server may auto-create the default swimlane. On
// the client this getter runs inside reactive render contexts; when a
// board's swimlanes are not yet loaded/subscribed (e.g. the default
// subtasks board viewed via "All boards"), getSwimlanes() is transiently
// empty and every re-render would insert another empty swimlane —
// producing thousands of them and freezing the browser. The server
// creates the default swimlane at board creation and self-heals here.
// Issue #6429: self-heal via a DETERMINISTIC _id so concurrent/repeated
// calls are idempotent (see ensureDefaultSwimlaneId).
result = this.ensureDefaultSwimlaneId();
}
return result;
},
@@ -1879,18 +1878,17 @@ Boards.helpers({
},
async getDefaultSwimlineAsync() {
let result = await ReactiveCache.getSwimlane({ boardId: this._id });
if (result === undefined) {
const existingSwimlanes = await ReactiveCache.getSwimlanes({ boardId: this._id });
if (existingSwimlanes.length > 0) {
result = existingSwimlanes[0];
} else if (Meteor.isServer && this._id) {
// Issue #6382: never auto-create swimlanes from the client (see
// getDefaultSwimline) — only the server may insert the default one.
// Issue #6429: idempotent deterministic-_id upsert instead of a racy
// check-then-insert that produced thousands of empty swimlanes.
result = await this.ensureDefaultSwimlaneIdAsync();
}
// Issue #1971: prefer a NON-archived swimlane (see getDefaultSwimline).
const { pickDefaultSwimlane } = require('./lib/defaultSwimlane');
let result = pickDefaultSwimlane(
await ReactiveCache.getSwimlanes({ boardId: this._id }),
);
if (result === undefined && Meteor.isServer && this._id) {
// Issue #6382: never auto-create swimlanes from the client (see
// getDefaultSwimline) — only the server may insert the default one.
// Issue #6429: idempotent deterministic-_id upsert instead of a racy
// check-then-insert that produced thousands of empty swimlanes.
result = await this.ensureDefaultSwimlaneIdAsync();
}
return result;
},
+18 -1
View File
@@ -34,4 +34,21 @@ function defaultSwimlaneFields(boardId, title) {
};
}
module.exports = { defaultSwimlaneId, defaultSwimlaneFields };
// Choose a board's default swimlane from its list of swimlanes (#1971). Prefer
// the first NON-archived swimlane so a card added in List view never lands in an
// archived swimlane (which is invisible in Swimlane view — the reported bug).
// If every swimlane is archived, fall back to the first one (matching the old
// non-null behaviour, so callers that read `._id` never crash). Returns
// undefined only when the board has no swimlanes at all, which is the single
// case where the server self-heals a fresh default (#6382/#6429).
function pickDefaultSwimlane(swimlanes) {
if (!Array.isArray(swimlanes) || swimlanes.length === 0) return undefined;
const active = swimlanes.find(s => s && !s.archived);
return active || swimlanes[0];
}
module.exports = {
defaultSwimlaneId,
defaultSwimlaneFields,
pickDefaultSwimlane,
};
+52
View File
@@ -16,6 +16,7 @@ const assert = require('assert');
const {
defaultSwimlaneId,
defaultSwimlaneFields,
pickDefaultSwimlane,
} = require('../models/lib/defaultSwimlane');
let passed = 0;
@@ -134,4 +135,55 @@ test('NEGATIVE: deterministic-id upsert and old random-insert diverge under the
assert.notStrictEqual(fixed.countForBoard(BOARD), buggy.countForBoard(BOARD));
});
// --- #1971: pickDefaultSwimlane skips ARCHIVED swimlanes --------------------
// Bug: adding a card in List view assigned it to the first swimlane even when
// that swimlane was archived, so the card was invisible in Swimlane view.
const sl = (id, archived) => ({ _id: id, boardId: BOARD, archived: !!archived });
test('#1971: the reported case — default archived, active swimlanes exist -> first ACTIVE one', () => {
// Default swimlane "Project" archived; "Project2"/"Project3" active.
const swimlanes = [sl('project', true), sl('project2', false), sl('project3', false)];
assert.strictEqual(pickDefaultSwimlane(swimlanes)._id, 'project2',
'must skip the archived swimlane and pick the first active one');
});
test('#1971: an active first swimlane is returned unchanged (no behaviour change)', () => {
const swimlanes = [sl('default', false), sl('other', false)];
assert.strictEqual(pickDefaultSwimlane(swimlanes)._id, 'default');
});
test('#1971: a swimlane with no archived field counts as active', () => {
assert.strictEqual(pickDefaultSwimlane([{ _id: 'a', boardId: BOARD }])._id, 'a');
});
test('#1971: when EVERY swimlane is archived, fall back to the first (never null/crash)', () => {
// Callers read `._id`, so returning undefined here would crash; the server
// self-heals a fresh default only when there are NO swimlanes at all.
const swimlanes = [sl('a', true), sl('b', true)];
assert.strictEqual(pickDefaultSwimlane(swimlanes)._id, 'a');
});
test('#1971 negative: no swimlanes at all -> undefined (server then self-heals a default)', () => {
assert.strictEqual(pickDefaultSwimlane([]), undefined);
assert.strictEqual(pickDefaultSwimlane(null), undefined);
assert.strictEqual(pickDefaultSwimlane(undefined), undefined);
});
test('#1971 negative: skips null/undefined entries when choosing the active swimlane', () => {
const swimlanes = [null, sl('archived', true), undefined, sl('live', false)];
assert.strictEqual(pickDefaultSwimlane(swimlanes)._id, 'live');
});
// --- source guard: getDefaultSwimline routes through pickDefaultSwimlane -----
test('#1971 source guard: boards.js getDefaultSwimline uses pickDefaultSwimlane', () => {
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'models', 'boards.js'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
const uses = (src.match(/pickDefaultSwimlane\(/g) || []).length;
assert.ok(uses >= 2, 'both getDefaultSwimline and getDefaultSwimlineAsync must use it');
assert.ok(!/getSwimlane\(\{ boardId: this\._id \}\)/.test(src),
'the unfiltered single-swimlane fetch (could return an archived one) must be gone');
});
console.log(`\n${passed} passing`);