fix(app): keep the member list in sync when switching channels (#1848)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:30:38 +02:00
1 parent 7c5cab2144
commit 3e267b8104
8 files changed
+578 -561

No files matched your search

@@ -81,10 +81,7 @@ export function handleGuildMemberListUpdate(data: GuildMemberListUpdatePayload,
validOps.push({
op: 'SYNC',
range: op.range,
items: op.items.map((item: MemberListItem) => ({
member: item.member,
group: item.group,
})),
items: op.items,
});
}
if (validOps.length === 0 && ops.length > 0) {
@@ -28,13 +28,12 @@ interface MemberListSubscriptionControl {
verifyHydration: () => void;
}
type MemberListSubscriptionPhase = 'offline' | 'hydrating' | 'settled' | 'freshSession';
type MemberListSubscriptionPhase = 'offline' | 'hydrating' | 'settled';
const INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES = normalizeMemberListRanges([[0, MEMBER_LIST_RANGE_MAX_SPAN]]);
const MEMBER_LIST_SUBSCRIPTION_SETTLE_MS = 50;
const MEMBER_LIST_RESUBSCRIBE_DELAY_MS = 1000;
const MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS = 3;
const MEMBER_LIST_RECOVERY_DELAY_MS = 3000;
const MEMBER_LIST_MAX_RECOVERY_ATTEMPTS = 2;
const logger = new Logger('useMemberListSubscription');
let nextMemberListSubscriptionOwnerId = 0;
@@ -56,35 +55,81 @@ export function useMemberListSubscription({
}: UseMemberListSubscriptionOptions): UseMemberListSubscriptionResult {
const [ownerId] = useState(createMemberListSubscriptionOwnerId);
const desiredRangesRef = useRef<NormalizedMemberListRanges>(INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES);
const pendingDesiredRangesRef = useRef<NormalizedMemberListRanges | null>(null);
const settleTimerRef = useRef<number | null>(null);
const controlRef = useRef<MemberListSubscriptionControl | null>(null);
const clearSettleTimer = useCallback(() => {
if (settleTimerRef.current == null) {
return;
}
window.clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}, []);
const sendWithoutControl = useCallback(
(nextDesiredRanges: NormalizedMemberListRanges) => {
if (!MemberSidebar.isActiveMemberListSubscriptionOwner(guildId, channelId, ownerId)) {
MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId);
}
MemberSidebar.subscribeToChannel(guildId, channelId, nextDesiredRanges, ownerId);
},
[guildId, channelId, ownerId],
);
const commitDesiredRanges = useCallback(() => {
settleTimerRef.current = null;
const nextDesiredRanges = pendingDesiredRangesRef.current;
pendingDesiredRangesRef.current = null;
if (nextDesiredRanges == null) {
return;
}
desiredRangesRef.current = nextDesiredRanges;
const control = controlRef.current;
if (control != null) {
control.handleDesiredRanges(true);
return;
}
sendWithoutControl(nextDesiredRanges);
}, [sendWithoutControl]);
const subscribe = useCallback(
(ranges: MemberListRanges) => {
if (!enabled) {
return;
}
const nextDesiredRanges = resolveDesiredRanges(ranges);
const desiredRangesChanged = !areNormalizedMemberListRangesEqual(desiredRangesRef.current, nextDesiredRanges);
desiredRangesRef.current = nextDesiredRanges;
const control = controlRef.current;
if (control != null) {
control.handleDesiredRanges(desiredRangesChanged);
if (!desiredRangesChanged) {
if (areNormalizedMemberListRangesEqual(desiredRangesRef.current, nextDesiredRanges)) {
pendingDesiredRangesRef.current = null;
clearSettleTimer();
const control = controlRef.current;
if (control != null) {
control.handleDesiredRanges(false);
control.verifyHydration();
return;
}
sendWithoutControl(desiredRangesRef.current);
return;
}
if (!MemberSidebar.isActiveMemberListSubscriptionOwner(guildId, channelId, ownerId)) {
MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId);
}
MemberSidebar.subscribeToChannel(guildId, channelId, desiredRangesRef.current, ownerId);
pendingDesiredRangesRef.current = nextDesiredRanges;
clearSettleTimer();
settleTimerRef.current = window.setTimeout(commitDesiredRanges, MEMBER_LIST_SUBSCRIPTION_SETTLE_MS);
},
[guildId, channelId, enabled, ownerId],
[enabled, clearSettleTimer, commitDesiredRanges, sendWithoutControl],
);
useEffect(() => {
desiredRangesRef.current = INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES;
}, [guildId, channelId]);
pendingDesiredRangesRef.current = null;
clearSettleTimer();
}, [guildId, channelId, enabled, clearSettleTimer]);
useEffect(() => {
return () => {
pendingDesiredRangesRef.current = null;
clearSettleTimer();
};
}, [clearSettleTimer]);
useEffect(() => {
if (!enabled) {
@@ -94,8 +139,6 @@ export function useMemberListSubscription({
let reconciliationScheduled = false;
let retryTimer: number | null = null;
let hydrationGeneration = 0;
let freshSessionResetId: string | null = null;
let recoveryAttemptCount = 0;
let resubscribeAttemptCount = 0;
let phase: MemberListSubscriptionPhase = 'offline';
@@ -122,13 +165,12 @@ export function useMemberListSubscription({
function beginRecoveryDemand(): void {
clearRetryTimer();
hydrationGeneration += 1;
recoveryAttemptCount = 0;
resubscribeAttemptCount = 0;
}
function resendStaleSubscription(): void {
clearRetryTimer();
if (disposed || phase === 'freshSession' || !ownsSubscription()) {
if (disposed || !ownsSubscription()) {
return;
}
if (!gatewayAvailable()) {
@@ -141,11 +183,11 @@ export function useMemberListSubscription({
channelId,
attempt: resubscribeAttemptCount,
});
sendDesiredRequest();
sendDesiredRequest(true);
}
function scheduleResubscribe(): void {
if (retryTimer != null) {
if (retryTimer != null || resubscribeAttemptCount >= MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS) {
return;
}
const scheduledGeneration = hydrationGeneration;
@@ -158,76 +200,23 @@ export function useMemberListSubscription({
}, MEMBER_LIST_RESUBSCRIBE_DELAY_MS);
}
function scheduleHydrationRecovery(): void {
if (resubscribeAttemptCount < MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS) {
scheduleResubscribe();
return;
}
scheduleSessionReplacement();
}
function replaceStaleSession(): void {
clearRetryTimer();
if (
disposed ||
recoveryAttemptCount >= MEMBER_LIST_MAX_RECOVERY_ATTEMPTS ||
phase === 'freshSession' ||
!ownsSubscription()
) {
return;
}
const socket = GatewayConnection.socket;
if (!gatewayAvailable() || socket == null) {
phase = 'offline';
return;
}
freshSessionResetId = GatewayConnection.sessionId;
if (freshSessionResetId == null) {
phase = 'offline';
return;
}
recoveryAttemptCount += 1;
phase = 'freshSession';
logger.warn('Member list hydration stalled; replacing the stale Gateway session', {guildId, channelId});
socket.reset(true);
}
function scheduleSessionReplacement(): void {
if (recoveryAttemptCount >= MEMBER_LIST_MAX_RECOVERY_ATTEMPTS || retryTimer != null) {
return;
}
const scheduledGeneration = hydrationGeneration;
retryTimer = window.setTimeout(() => {
retryTimer = null;
if (scheduledGeneration !== hydrationGeneration) {
return;
}
replaceStaleSession();
}, MEMBER_LIST_RECOVERY_DELAY_MS);
}
function verifyHydration(): void {
if (disposed || !ownsSubscription()) {
return;
}
if (phase === 'freshSession') {
return;
}
if (hasHydratedDesiredRanges()) {
clearRetryTimer();
recoveryAttemptCount = 0;
resubscribeAttemptCount = 0;
freshSessionResetId = null;
phase = 'settled';
return;
}
if (phase === 'settled' || phase === 'hydrating') {
phase = 'hydrating';
scheduleHydrationRecovery();
scheduleResubscribe();
}
}
function sendDesiredRequest(): void {
function sendDesiredRequest(forceResend = false): void {
clearRetryTimer();
if (disposed || !gatewayAvailable()) {
phase = 'offline';
@@ -236,7 +225,14 @@ export function useMemberListSubscription({
if (!ownsSubscription()) {
MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId);
}
if (!MemberSidebar.retryChannelSubscription(guildId, channelId, desiredRangesRef.current, ownerId)) {
const reachedWire = MemberSidebar.retryChannelSubscription(
guildId,
channelId,
desiredRangesRef.current,
ownerId,
forceResend,
);
if (!reachedWire && !ownsSubscription()) {
phase = 'offline';
return;
}
@@ -267,9 +263,6 @@ export function useMemberListSubscription({
if (!changed && !reclaimedOwnership) {
return;
}
if (phase === 'freshSession') {
return;
}
sendDesiredRequest();
}
@@ -277,14 +270,6 @@ export function useMemberListSubscription({
if (disposed || !gatewayAvailable()) {
return;
}
if (
phase === 'freshSession' &&
freshSessionResetId != null &&
GatewayConnection.sessionId === freshSessionResetId
) {
return;
}
freshSessionResetId = null;
if (!ownsSubscription()) {
if (MemberSidebar.hasActiveMemberListSubscription()) {
return;
@@ -324,9 +309,7 @@ export function useMemberListSubscription({
if (!isAvailable) {
clearRetryTimer();
MemberSidebar.handleGatewayDisconnected();
if (phase !== 'freshSession') {
phase = 'offline';
}
phase = 'offline';
return;
}
scheduleReconciliation();
File diff suppressed because it is too large. Load diff
@@ -22,6 +22,7 @@ interface MemberListHydrationItemIndexes {
export interface MemberListHydrationInput {
memberCount: number;
groups: ReadonlyArray<MemberListGroupSnapshot>;
layouts?: ReadonlyArray<MemberListGroupLayout>;
itemIndexes: MemberListHydrationItemIndexes;
}
@@ -61,6 +62,10 @@ function isHydratedMemberListRow(context: HydratedMemberListRowContext, rowIndex
return input.itemIndexes.has(rowIndex);
}
function resolveHydrationLayouts(input: MemberListHydrationInput): ReadonlyArray<MemberListGroupLayout> {
return input.layouts ?? buildMemberListLayout(input.groups);
}
function appendHydratedMemberListRange(request: AppendHydratedMemberListRangeRequest): void {
const {target, range, totalRows} = request;
const [rangeStart, rangeEnd] = range;
@@ -87,15 +92,14 @@ function appendHydratedMemberListRange(request: AppendHydratedMemberListRangeReq
}
}
export function getHydratedMemberListRanges(
export function getHydratedMemberListRangesFromNormalized(
input: MemberListHydrationInput,
ranges: MemberListRanges,
normalizedRanges: NormalizedMemberListRanges,
): NormalizedMemberListRanges {
const normalizedRanges = normalizeMemberListRanges(ranges);
if (normalizedRanges.length === 0) {
return normalizedRanges;
}
const layouts = buildMemberListLayout(input.groups);
const layouts = resolveHydrationLayouts(input);
const totalRows = getHydrationTotalRows(input, layouts);
if (totalRows === 0) {
return normalizedRanges;
@@ -111,7 +115,17 @@ export function getHydratedMemberListRanges(
return normalizeMemberListRanges(hydratedRanges);
}
export function getHydratedMemberListRanges(
input: MemberListHydrationInput,
ranges: MemberListRanges,
): NormalizedMemberListRanges {
return getHydratedMemberListRangesFromNormalized(input, normalizeMemberListRanges(ranges));
}
export function isMemberListRangeHydrated(input: MemberListHydrationInput, ranges: MemberListRanges): boolean {
const normalizedRanges = normalizeMemberListRanges(ranges);
return areNormalizedMemberListRangesCovered(normalizedRanges, getHydratedMemberListRanges(input, normalizedRanges));
return areNormalizedMemberListRangesCovered(
normalizedRanges,
getHydratedMemberListRangesFromNormalized(input, normalizedRanges),
);
}
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
export interface MemberListViewOverwrite {
readonly id: string;
readonly allow: bigint;
readonly deny: bigint;
}
export interface MemberListIdentityChannel {
readonly permissionOverwrites: Readonly<Record<string, MemberListViewOverwrite>>;
}
export interface MemberListIdentityGuild {
readonly id: string;
getRole(roleId: string): {readonly permissions: bigint} | undefined;
}
export const OPEN_MEMBER_LIST_IDENTITY = 'everyone';
const IDENTITY_TOKEN_SEPARATOR = ',';
const SCRAMBLE_A = 0xcc9e2d51;
const SCRAMBLE_B = 0x1b873593;
const BLEND_A = 0x85ebca6b;
const BLEND_B = 0xc2b2ae35;
const CHAIN_ADDEND = 0xe6546b64;
const identityTokenEncoder = new TextEncoder();
function rotateLeft32(value: number, shift: number): number {
return (value << shift) | (value >>> (32 - shift));
}
function scramble(value: number): number {
return Math.imul(rotateLeft32(Math.imul(value, SCRAMBLE_A), 15), SCRAMBLE_B);
}
export function fingerprintIdentityTokens(tokens: string): number {
const bytes = identityTokenEncoder.encode(tokens);
const blockCount = bytes.length >>> 2;
let accumulator = 0;
for (let block = 0; block < blockCount; block += 1) {
const offset = block << 2;
const chunk = bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24);
accumulator ^= scramble(chunk);
accumulator = rotateLeft32(accumulator, 13);
accumulator = (Math.imul(accumulator, 5) + CHAIN_ADDEND) | 0;
}
const remainderStart = blockCount << 2;
const remainderLength = bytes.length & 3;
if (remainderLength > 0) {
let remainder = 0;
if (remainderLength === 3) {
remainder ^= bytes[remainderStart + 2]! << 16;
}
if (remainderLength >= 2) {
remainder ^= bytes[remainderStart + 1]! << 8;
}
remainder ^= bytes[remainderStart]!;
accumulator ^= scramble(remainder);
}
accumulator ^= bytes.length;
accumulator ^= accumulator >>> 16;
accumulator = Math.imul(accumulator, BLEND_A);
accumulator ^= accumulator >>> 13;
accumulator = Math.imul(accumulator, BLEND_B);
accumulator ^= accumulator >>> 16;
return accumulator >>> 0;
}
function isViewableWithoutOverrides(
guild: MemberListIdentityGuild | undefined,
channel: MemberListIdentityChannel,
): boolean {
if (guild == null) {
return false;
}
const baselineRole = guild.getRole(guild.id);
if (baselineRole == null) {
return false;
}
if ((baselineRole.permissions & Permissions.VIEW_CHANNEL) !== Permissions.VIEW_CHANNEL) {
return false;
}
for (const overwrite of Object.values(channel.permissionOverwrites)) {
if ((overwrite.deny & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL) {
return false;
}
}
return true;
}
export function buildIdentityTokens(channel: MemberListIdentityChannel): string {
const tokens: Array<string> = [];
for (const overwrite of Object.values(channel.permissionOverwrites)) {
if ((overwrite.allow & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL) {
tokens.push(`allow:${overwrite.id}`);
} else if ((overwrite.deny & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL) {
tokens.push(`deny:${overwrite.id}`);
}
}
tokens.sort();
return tokens.join(IDENTITY_TOKEN_SEPARATOR);
}
export function deriveMemberListIdentity(
guild: MemberListIdentityGuild | undefined,
channel: MemberListIdentityChannel | undefined,
): string {
if (channel == null) {
return OPEN_MEMBER_LIST_IDENTITY;
}
if (isViewableWithoutOverrides(guild, channel)) {
return OPEN_MEMBER_LIST_IDENTITY;
}
return fingerprintIdentityTokens(buildIdentityTokens(channel)).toString();
}
@@ -23,22 +23,22 @@ export function buildMemberListLayout(groups: ReadonlyArray<MemberListGroupSnaps
for (const group of groups) {
const effectiveCount = Math.max(0, group.count);
if (effectiveCount === 0) {
} else {
const headerRowIndex = rowIndex;
const memberStartIndex = memberIndex;
const memberEndIndex = memberIndex + effectiveCount - 1;
const rowEndIndex = headerRowIndex + effectiveCount;
layouts.push({
id: group.id,
count: effectiveCount,
headerRowIndex,
memberStartIndex,
memberEndIndex,
rowEndIndex,
});
rowIndex = rowEndIndex + 1;
memberIndex = memberEndIndex + 1;
continue;
}
const headerRowIndex = rowIndex;
const memberStartIndex = memberIndex;
const memberEndIndex = memberIndex + effectiveCount - 1;
const rowEndIndex = headerRowIndex + effectiveCount;
layouts.push({
id: group.id,
count: effectiveCount,
headerRowIndex,
memberStartIndex,
memberEndIndex,
rowEndIndex,
});
rowIndex = rowEndIndex + 1;
memberIndex = memberEndIndex + 1;
}
return layouts;
}
@@ -66,13 +66,33 @@ export function buildMemberListRowOffsets(
if (safeTotalRows === 0) {
return offsets;
}
const headerRows = new Set<number>();
let rowIndex = 0;
let runningOffset = 0;
for (const layout of layouts) {
headerRows.add(layout.headerRowIndex);
if (rowIndex >= safeTotalRows) {
break;
}
while (rowIndex < layout.headerRowIndex && rowIndex < safeTotalRows) {
runningOffset += memberHeight;
rowIndex += 1;
offsets[rowIndex] = runningOffset;
}
if (rowIndex === layout.headerRowIndex && rowIndex < safeTotalRows) {
runningOffset += headerHeight;
rowIndex += 1;
offsets[rowIndex] = runningOffset;
}
const lastMemberRow = Math.min(layout.rowEndIndex, safeTotalRows - 1);
while (rowIndex <= lastMemberRow) {
runningOffset += memberHeight;
rowIndex += 1;
offsets[rowIndex] = runningOffset;
}
}
for (let rowIndex = 0; rowIndex < safeTotalRows; rowIndex += 1) {
const rowHeight = headerRows.has(rowIndex) ? headerHeight : memberHeight;
offsets[rowIndex + 1] = offsets[rowIndex]! + rowHeight;
while (rowIndex < safeTotalRows) {
runningOffset += memberHeight;
rowIndex += 1;
offsets[rowIndex] = runningOffset;
}
return offsets;
}
@@ -113,15 +133,23 @@ export function getGroupLayoutForRow(
layouts: ReadonlyArray<MemberListGroupLayout>,
rowIndex: number,
): MemberListGroupLayout | null {
for (const layout of layouts) {
if (rowIndex < layout.headerRowIndex) {
return null;
}
if (rowIndex <= layout.rowEndIndex) {
return layout;
let low = 0;
let high = layouts.length - 1;
let candidate: MemberListGroupLayout | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
const layout = layouts[mid]!;
if (layout.headerRowIndex <= rowIndex) {
candidate = layout;
low = mid + 1;
} else {
high = mid - 1;
}
}
return null;
if (candidate == null || rowIndex > candidate.rowEndIndex) {
return null;
}
return candidate;
}
export function getMemberIndexForRow(
@@ -166,15 +194,23 @@ export function getRowIndexForMemberIndex(
if (layouts.length === 0) {
return memberIndex;
}
for (const layout of layouts) {
if (memberIndex < layout.memberStartIndex) {
return null;
}
if (memberIndex <= layout.memberEndIndex) {
return layout.headerRowIndex + 1 + (memberIndex - layout.memberStartIndex);
let low = 0;
let high = layouts.length - 1;
let candidate: MemberListGroupLayout | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
const layout = layouts[mid]!;
if (layout.memberStartIndex <= memberIndex) {
candidate = layout;
low = mid + 1;
} else {
high = mid - 1;
}
}
return null;
if (candidate == null || memberIndex > candidate.memberEndIndex) {
return null;
}
return candidate.headerRowIndex + 1 + (memberIndex - candidate.memberStartIndex);
}
export function getRowIndexRangeForMemberIndexRange(
@@ -189,3 +225,22 @@ export function getRowIndexRangeForMemberIndexRange(
}
return [start, end];
}
export interface MemberListScrollSpan {
top: number;
height: number;
}
export function quantizeMemberListScrollSpan(
scrollTop: number,
viewportHeight: number,
chunkPx: number,
): MemberListScrollSpan {
const safeChunkPx = Number.isFinite(chunkPx) && chunkPx > 0 ? chunkPx : 1;
const safeScrollTop = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0;
const safeViewportHeight = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0;
const firstChunk = Math.floor(safeScrollTop / safeChunkPx) - 1;
const lastChunk = Math.ceil((safeScrollTop + safeViewportHeight) / safeChunkPx) + 1;
const top = Math.max(0, firstChunk * safeChunkPx);
return {top, height: Math.max(0, lastChunk * safeChunkPx - top)};
}
@@ -42,7 +42,7 @@ describe('MemberListRangeUtils', () => {
expect(areNormalizedMemberListRangesCovered(requested, subscribed)).toBe(true);
});
it('builds a bounded, overscanned page window from scroll metrics', () => {
it('builds a bounded, overscanned page window from scroll metrics and keeps the head page subscribed', () => {
expect(
buildMemberListRangeWindow({
scrollTop: 44 * 250,
@@ -52,13 +52,14 @@ describe('MemberListRangeUtils', () => {
totalRows: 500,
}),
).toEqual([
[0, 99],
[100, 199],
[200, 299],
[300, 399],
]);
});
it('keeps scrollbar-dragged bottom windows clamped to the known row count', () => {
it('keeps scrollbar-dragged bottom windows clamped to the last known page', () => {
expect(
buildMemberListRangeWindow({
scrollTop: 44 * 490,
@@ -68,6 +69,7 @@ describe('MemberListRangeUtils', () => {
totalRows: 500,
}),
).toEqual([
[0, 99],
[300, 399],
[400, 499],
]);
@@ -83,7 +85,41 @@ describe('MemberListRangeUtils', () => {
overscanPages: 0,
totalRows: 500,
}),
).toEqual([[200, 299]]);
).toEqual([
[0, 99],
[200, 299],
]);
});
it('omits a duplicate head page when the window already starts at the top of the list', () => {
expect(
buildMemberListRangeWindow({
scrollTop: 0,
clientHeight: 44 * 10,
rowHeight: 44,
bufferRows: 12,
overscanPages: 0,
totalRows: 500,
}),
).toEqual([[0, 99]]);
});
it('keeps page ends aligned so a changing member count does not resubscribe', () => {
const options = {
scrollTop: 44 * 250,
clientHeight: 44 * 10,
rowHeight: 44,
bufferRows: 12,
overscanPages: 0,
};
expect(buildMemberListRangeWindow({...options, totalRows: 450})).toEqual([
[0, 99],
[200, 299],
]);
expect(buildMemberListRangeWindow({...options, totalRows: 451})).toEqual([
[0, 99],
[200, 299],
]);
});
it('builds a tight render window instead of rendering whole subscription pages', () => {
@@ -188,11 +188,12 @@ export function buildMemberListRangeWindow(options: MemberListRangeWindowOptions
lastPage = Math.min(lastPage, maxPage);
}
const ranges: MemberListRanges = [];
if (firstPage > 0) {
ranges.push([0, MEMBER_LIST_RANGE_MAX_SPAN]);
}
for (let page = firstPage; page <= lastPage; page += 1) {
const pageStart = page * MEMBER_LIST_RANGE_PAGE_SIZE;
const maxPageEnd = pageStart + MEMBER_LIST_RANGE_MAX_SPAN;
const pageEnd = safeTotalRows != null ? Math.min(maxPageEnd, safeTotalRows - 1) : maxPageEnd;
ranges.push([pageStart, pageEnd]);
ranges.push([pageStart, pageStart + MEMBER_LIST_RANGE_MAX_SPAN]);
}
return normalizeMemberListRanges(ranges);
}