DrKLO/Telegram · critical · IllegalArgumentException

only remove and update ops can be dispatched in first pass

Error message

only remove and update ops can be dispatched in first pass

What it means

dispatchFirstPassAndUpdateViewHolders runs the FIRST dispatch pass for ops that were processed against the pre-layout (invisible) view holders. Only REMOVE and UPDATE are valid here because those are the only op types that get routed into the pre-layout dispatch path. ADD and MOVE are always postponed to the second pass. Reaching the default branch means an op with an unexpected command was passed into the first-pass dispatch, which violates the contract established in dispatchAndUpdateViewHolders and is treated as an internal state corruption.

Source

Thrown at TMessagesProj/src/main/java/androidx/recyclerview/widget/AdapterHelper.java:358

            Log.d(TAG, "postponed state after:");
            for (UpdateOp updateOp : mPostponedList) {
                Log.d(TAG, updateOp.toString());
            }
            Log.d(TAG, "----");
        }
    }

    void dispatchFirstPassAndUpdateViewHolders(UpdateOp op, int offsetStart) {
        mCallback.onDispatchFirstPass(op);
        switch (op.cmd) {
            case UpdateOp.REMOVE:
                mCallback.offsetPositionsForRemovingInvisible(offsetStart, op.itemCount);
                break;
            case UpdateOp.UPDATE:
                mCallback.markViewHoldersUpdated(offsetStart, op.itemCount, op.payload);
                break;
            default:
                throw new IllegalArgumentException("only remove and update ops can be dispatched"
                        + " in first pass");
        }
    }

    private int updatePositionWithPostponed(int pos, int cmd) {
        final int count = mPostponedList.size();
        for (int i = count - 1; i >= 0; i--) {
            UpdateOp postponed = mPostponedList.get(i);
            if (postponed.cmd == UpdateOp.MOVE) {
                int start, end;
                if (postponed.positionStart < postponed.itemCount) {
                    start = postponed.positionStart;
                    end = postponed.itemCount;
                } else {
                    start = postponed.itemCount;
                    end = postponed.positionStart;
                }
                if (pos >= start && pos <= end) {

View on GitHub (pinned to 45ab8f4308)

Solutions

  1. Do not call dispatchFirstPassAndUpdateViewHolders from app code or custom layout managers; it is package-private internal API.
  2. Ensure the bundled AdapterHelper source and UpdateOp constants come from the same AndroidX release.
  3. Check ProGuard/R8 configuration is not aggressively optimizing the recyclerview widget package.
  4. Simplify update flow to DiffUtil.dispatchUpdatesTo so ops are always correctly typed.
Defensive patterns

Strategy: validation

Validate before calling

// Only dispatch REMOVE/UPDATE to first pass
boolean isFirstPassable(int cmd) {
    return cmd == UpdateOp.REMOVE || cmd == UpdateOp.UPDATE;
}

Prevention

When it happens

Trigger: A custom LayoutManager or ItemAnimator manually invokes consumeUpdatesInOneDeferredPass or dispatchFirstPassAndUpdateViewHolders with an op it constructed; an UpdateOp whose cmd changed between classification and dispatch; a fork that altered the routing in dispatchUpdatesInOneDeferredPass without updating this switch.

Common situations: Modifying the bundled RecyclerView source in a fork (common in Telegram-style apps) and introducing a new op type without updating all switch statements; an obfuscator that merged switch tables; reentrant notify calls during predictive item animation.

Related errors


AI-assisted analysis of DrKLO/Telegram@45ab8f4308 (2026-08-14). Data as JSON: /api/errors/2d51b9ef351a381a. Report an issue: GitHub.