Files
Lauri Ojansivu 7d9a2e3037 A filter no longer leaves a spinner turning over an empty list, and a half-arrived card is not drawn as a blank box.
Two problems from one report with screenshots, both in how a list decides what to
draw at its bottom edge.

1. "ONCE A FILTER IS APPLIED FOR A MEMBER, THE 3 DOTS CONTINUE TO ANIMATE." The
screenshot shows a list with NO cards under the filter and the load-more spinner
still turning under it - and `_updateList` raising the window limit by ten every
idle callback for as long as it stayed on screen.

The spinner asked "does this list hold more cards than the window I asked for?"
and answered it from a total built somewhere else than the cards being drawn. The
cards come from the filtered selector; the total came either from
`list.cards(swimlaneId).length` or, in lazy card-loading mode, from a count
document published per list/swimlane - whose id was `listId::swimlaneId` and did
NOT include the filter. So changing the filter re-subscribed with a new selector
under the SAME document id, two publications wrote one document, and Meteor's
merge box serves whichever subscription it prefers, which during the changeover is
the older, pre-filter one. The list drew its empty filtered window while its count
still described the unfiltered list.

The count document now carries a short stable key for its selector, so each filter
counts into its own document and a count can never describe another filter's
cards. Both subscribers pass it; the two-argument form still returns the old id
for a caller with no selector to hand.

And the spinner no longer depends on that being right. There is nothing more to
fetch unless the window came back FULL: if we asked for twenty cards and got
three, those three are all there are, whatever any count says. That holds in both
card-loading modes and for a stale count in either direction, which is why it is
worth asking as well as the total.

While there: showSpinner asked `isLazyCards()` with no board while cardsWithLimit
asked `isLazyCards(list.boardId)`, so the two could take different branches for
the same list. Both ask about the board now, and both build the selector the same
way instead of from different sources - that disagreement was the bug.

2. "RANDOM BLANK CARDS ARE APPEARING ON THE BOARD." White minicards with the
handle icon and nothing else - no title, no members, no labels - mixed in among
the real ones. In the screenshot the only two that render are the two carrying the
viewer's own avatar.

A minicard renders blank when its document is in minimongo without its fields:
`Cards.getTitle()` returns null for `title === undefined`, and every badge is
conditional on a field that is not there either. That is NOT a card with an empty
title - the schema declares `title` as optional with `defaultValue: ''`, so a card
that went through it has the key, `''` at worst. A document without the key at all
is a partial replication, and several publications ship cards with a projection:
`openCardData` publishes `{ _id: 1 }` as the parent of its children cursors, the
search publications ship their own field lists, and minimongo merges what every
live publication says about an id.

WHICH of them produced these particular stubs is not settled - it needs the
running board to catch - so this is a guard where the card is drawn rather than a
fix at the source: a document that does not carry the field the minicard is built
around is not drawn. `$exists` is what separates the two cases; filtering on a
falsy title would hide real cards. It cannot hide a card, and when the full
document arrives the card appears, which is what the blank box was standing in for
anyway.

tests/listCardWindow.test.cjs covers the reported case (a filter empties the list
and the total still says 25), a short window in general, that a full window with
more behind it still offers to load more, a stale total in both directions,
missing input, that an empty title is still drawn while a missing one is not, that
the selector stays flat and an existing `title` clause is not silently replaced,
that two filters no longer share a count document while the same filter always
names one whatever order it was built in, and that every subscriber names it the
same way.

Thanks to xet7 !
2026-07-31 04:09:40 +03:00

113 lines
5.0 KiB
JavaScript

'use strict';
// Pure, Meteor-free helpers for the CARDS_LOADING / lazy card-loading feature.
// Unit-tested in tests/cardsLoading.test.cjs. Used by server/cards-loading.js,
// server/models/settings.js (env seeding), server/publications/boards.js and
// client/lib/lazyCards.js.
//
// Modes:
// all — the board publication ships every card into minimongo (simple, fully
// featured; heavy for very large boards).
// lazy — each list loads only its visible window; the board ships no cards.
// auto — (DEFAULT) decide PER BOARD by size: a board with more than the
// threshold of cards loads lazily, smaller boards load eagerly ('all').
// This keeps small boards on the simple, full-featured path and only
// pays the windowed-loading overhead where it actually helps (#6480).
const DEFAULT_LAZY_THRESHOLD = 500;
// Effective GLOBAL card-loading mode from any input (env var, setting): one of
// 'all' | 'lazy' | 'auto'. Anything unrecognised / unset defaults to 'auto'.
// Never throws.
function resolveCardsLoadingMode(value) {
const v = String(value == null ? '' : value).trim().toLowerCase();
if (v === 'all') return 'all';
if (v === 'lazy') return 'lazy';
return 'auto';
}
// Parse the CARDS_LOADING env var for the "env is authoritative only when
// explicitly set" case: returns 'all' | 'lazy' | 'auto' when set to one of those,
// otherwise undefined (leave the stored setting alone).
function parseCardsLoadingEnv(value) {
const v = String(value == null ? '' : value).trim().toLowerCase();
return v === 'all' || v === 'lazy' || v === 'auto' ? v : undefined;
}
// The card-count threshold above which an 'auto' board loads lazily. Parses the
// CARDS_LOADING_LAZY_THRESHOLD env var; falls back to the default for unset /
// invalid / negative values. A threshold of 0 makes every 'auto' board lazy.
function cardsLoadingLazyThreshold(value) {
const n = parseInt(value, 10);
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_LAZY_THRESHOLD;
}
// Resolve the EFFECTIVE per-board mode ('all' | 'lazy') from the global mode and a
// board's card count. 'all'/'lazy' are honoured as-is; 'auto' becomes 'lazy' when
// the board has MORE than `threshold` cards, otherwise 'all'. Pure + unit-tested.
function effectiveBoardCardsMode(mode, cardCount, threshold) {
const m = resolveCardsLoadingMode(mode);
if (m === 'all') return 'all';
if (m === 'lazy') return 'lazy';
const count = Number.isFinite(cardCount) ? cardCount : 0;
const limit = Number.isFinite(threshold) ? threshold : DEFAULT_LAZY_THRESHOLD;
return count > limit ? 'lazy' : 'all';
}
// JSON with the keys of every object in a stable order, so two selectors that
// mean the same thing produce the same text whatever order they were built in.
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
if (value && typeof value === 'object' && !(value instanceof Date)) {
const keys = Object.keys(value).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
}
return JSON.stringify(value === undefined ? null : value);
}
// A short key identifying a card selector. djb2 over the stable text: this only
// has to separate one selector from another within one list, not resist anything.
function selectorKey(selector) {
const text = stableStringify(selector);
let hash = 5381;
for (let i = 0; i < text.length; i += 1) {
hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0;
}
return hash.toString(36);
}
// Stable id for one (list, swimlane, selector) window's reactive count doc.
//
// A falsy swimlaneId (undefined, null, '') MUST collapse to the same id, because
// the list body subscribes with `undefined` (list view) while the list header
// reads with '' — both must reference the same count.
//
// The SELECTOR belongs in the id too. Without it the id was listId::swimlaneId,
// so changing the board Filter re-subscribed to `boardListCardCount` with a
// different selector under the SAME document id: two publications then wrote one
// document, and Meteor's merge box serves whichever subscription it prefers —
// which during the changeover is the older, pre-filter one. The list rendered its
// filtered (possibly empty) window while its count still described the unfiltered
// list, which is what kept the "load more" spinner turning over nothing. Each
// filter now counts into its own document, so a count can never describe another
// filter's cards. `selector` is optional: omitting it keeps the old id, which is
// what a caller that has no selector to hand should get.
function windowCountId(listId, swimlaneId, selector) {
const base = `${listId}::${swimlaneId || ''}`;
if (selector === undefined || selector === null) return base;
return `${base}::${selectorKey(selector)}`;
}
export {
DEFAULT_LAZY_THRESHOLD,
resolveCardsLoadingMode,
parseCardsLoadingEnv,
cardsLoadingLazyThreshold,
effectiveBoardCardsMode,
windowCountId,
selectorKey,
stableStringify,
};