Skip to content

Throttle/coalesce streaming redraws and skip huge chunk redraws to reduce UI stalls#162

Merged
Shougo merged 6 commits into
mainfrom
copilot/fix-ui-freeze-issue
May 9, 2026
Merged

Throttle/coalesce streaming redraws and skip huge chunk redraws to reduce UI stalls#162
Shougo merged 6 commits into
mainfrom
copilot/fix-ui-freeze-issue

Conversation

Copilot AI commented May 9, 2026

Copy link
Copy Markdown
Contributor

大量候補を stream する source(grep / file_rec など)で、chunk 到着ごとに全件再計算付き redraw が走り、UI が断続的に固まる状態でした。
この変更では denops/ddu/ddu.ts の redraw 発火を間引き・集約し、最終表示の整合性を維持したまま再計算回数を抑えます。

  • Redraw scheduling (throttle/coalesce)

    • #refreshItems() の即時 redraw() 呼び出しを #redrawThrottled() に置換。
    • 50ms 窓 (REDRAW_THROTTLE_MS) で redraw 要求を集約し、短時間の連発を回避。
    • pending オプションは mergeRedrawOptions() で合成し、既存の redraw() スケジューリングと整合する挙動に統一。
  • Large chunk handling (follow-up)

    • 1 回の chunk 追加件数が大きい場合(>= 5000REDRAW_STREAMING_CHUNK_LIMIT)は streaming 中の redraw をスキップ。
    • 10000 件級の一括追加時に、途中の重い再計算を避けて引っかかりをさらに抑制。
    • gather 完了時の redraw は維持し、最終表示は正しく反映。
  • Final consistency after gather

    • #refreshSources() 終了時に pending throttled redraw を必ず flush して反映。
    • gather 完了後の最終表示が取りこぼされないように保証。
  • Lifecycle safety (abort/quit/refresh)

    • quit() / cancelToRefresh() で pending timer/options を破棄。
    • abort/refresh 系フローと遅延 redraw の競合を防止。
  • Tests

    • mergeRedrawOptions() の合成挙動テストを追加。
    • 大規模 chunk 判定用 isStreamingRedrawTarget() の境界値テスト(0 / 1 / 4999 / 5000 / 10000)を追加。
Original prompt

Shougo/ddu.vim で、grepfile_rec source のように候補が数万件に達する場合、候補追加中に UI が一定時間フリーズする問題を修正してください。

背景

候補追加中に denops/ddu/ddu.ts#refreshItems() から redraw が頻繁にスケジュールされ、#redrawInternal() が毎回 source 全体に対して重い再計算を行っています。結果として、大量候補の streaming 中に UI が断続的にフリ���ズします。

主なボトルネック候補:

  • #refreshItems() が chunk 到着ごとに this.redraw() を呼ぶ
  • #redrawInternal() が毎回 source 全件に対して filter / sort / converter / ui.refreshItems() を実行する
  • 大量件数で redraw 回数が多すぎる

目的

  • 大量候補追加中でも UI が長時間フリーズしないようにする
  • 最終的な表示整合性は保つ
  • 既存 API や既存 UI/source/filter との互換性をできるだけ維持する

実装方針

まずは最小かつ安全な修正として、denops/ddu/ddu.ts の redraw を throttle / coalesce する方向を優先してください。

想定される方向性:

  • Ddu クラスに redraw 間引き用の状態を追加する
  • chunk ごとの即時 redraw を避け、一定時間内の redraw をまとめる
  • gather 完了時には必ず最終 redraw が走るようにする
  • 既存の #waitRedrawComplete / #scheduledRedrawOptions と競合しないように設計する
  • abort / quit / refresh の挙動を壊さないようにする

対象ファイル

  • denops/ddu/ddu.ts

必要に応じてテストも追加してください。既存の Deno test スタイルに合わせてください。

受け入れ条件

  • grepfile_rec のような大量件数 source で、候補追加中の UI フリーズが明確に軽減される
  • redraw が過剰に連発されなくなる
  • gather 完了後の最終表示は正しい
  • 既存の sync / abort / quit / refresh の挙動を壊さない
  • PR には変更内容と理由がわかる説明を含める

The following is the prior conversation context from the user's chat exploration (may be truncated):

User: @Shougo/ddu.vim grep, file_rec source のように候補が数万ある場合、候補を追加しているときに UI が一定時間フリーズする問題があります。この問題を解決したいです。

Assistant: 承知しました。いま変更の適用を進めるための確認待ちになっているので、承認または拒否をいただければ続行します。

User: @copilot Accepted Confirmation: Are you sure?

Assistant: 原因はかなり絞れています。
大量候補追加中に毎回 redraw() 側で全件 structuredClone + columns + filters + converters + ui.refreshItems() を走らせているため、数万件規模でメイン処理が長時間占有され、UI が固まっています。

特に重いのはこの流れです。

  • #refreshItems() で新規 chunk を受け取るたびに this.redraw(...) を投げる
  • redraw() は最終的に #redrawInternal() を実行する
  • #redrawInternal() では source ごとに #filterItems() を呼ぶ
  • #filterItems()state.items 全体を structuredClone() して、columns / matchers / sorters / converters を毎回全件再実行する
  • その後 ui.refreshItems({ items: allItems }) で UI 全体更新する

該当箇所はここです。

async #refreshItems(
  denops: Denops,
  state: GatherState,
  opts?: { signal?: AbortSignal },
): Promise<void> {
  const { sourceInfo: { sourceOptions }, itemsStream } = state;

  await callOnRefreshItemsHooks(
    denops,
    this.#loader,
    this.#options,
    sourceOptions,
  );

  const path = sourceOptions.path.length > 0
    ? sourceOptions.path
    : this.#context.path;

  for await (const newItems of itemsStream) {
    if (!equal(path, this.#context.path)) {
      if (this.#context.path.length > 0) {
        this.#context.pathHistories.push(this.#context.path);
      }
      this.#context.path = path;
    }

    if (this.#checkSync() && newItems.length > 0) {
      /* no await */ this.redraw(denops, opts);
    }
  }
}
async #redrawInternal(
  denops: Denops,
  { restoreItemState, restoreTree, signal }: Required<RedrawOptions>,
): Promise<void> {
  // ...
  const filterResults = (await Promise.all(
    this.#options.sources
      .map((source) => convertUserString(source))
      .map(async (userSource, sourceIndex) => {
        // ...
        const [done, maxItems, items] = await this.#filterItems(
          denops,
          userSource,
          sourceIndex,
          this.#input,
        );
        // ...
      }),
  )).filter((result): result is NonNullable<typeof result> => result != null);

  let allItems = filterResults.flatMap(({ items }) => items);

  allItems = await callFilters(
    denops,
    this.#loader,
    this.#context,
    this.#options,
    defaultSourceOptions(),
    this.#options.postFilters,
    this.#input,
    allItems,
  );

  await ui.refreshItems({
    denops,
    context: this.#context,
    options: this.#options,
    uiOptions: uiOptions,
    uiParams: uiParams,
    sources: sources,
    items: allItems,
  });
}
async #filterItems(
  denops: Denops,
  userSource: UserSource,
  sourceIndex: number,
  input: string,
): Promise<[boolean, number, DduItem[]]> {
  // ...
  let items = structuredClone(state.items) as DduItem[];
  const allItems = items.length;

  await callColumns(/* ... */);

  const filters = await getFilters(/* ... */);
  items = await this.#runMatchersConcurrently(/* ... */);
  items = await callFilters(/* sorters ... */);

  if (this.#options.converterCache && filters.converters.length > 0) {
    // converter cache path
  } else {
    items = await callFilters(/* converters ... */);
  }

  return [state.isDone, allItems, items];
}

解決方針

一番効くのは、候補追加中の redraw を即時実行ではなく間引きすることです。
つまり、

  • chunk が届くたびに再描画予約はする
  • ただし一...

This pull request was created from Copilot chat.

Copilot AI changed the title [WIP] Fix UI freeze during candidate addition in ddu.vim Throttle and coalesce redraws during streaming gather to reduce UI stalls May 9, 2026
Copilot AI requested a review from Shougo May 9, 2026 03:53
Copilot finished work on behalf of Shougo May 9, 2026 03:53
@Shougo Shougo marked this pull request as ready for review May 9, 2026 03:53
@Shougo

Shougo commented May 9, 2026

Copy link
Copy Markdown
Owner

@copilot 試したみたのですが、まだ引っかかるようです。一度に 10000 くらい候補が追加されるような状態だと駄目ですね

Copilot AI commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 試したみたのですが、まだ引っかかるようです。一度に 10000 くらい候補が追加されるような状態だと駄目ですね

対応しました。dc4cf64 で、1 chunk で大量(>=5000件)追加される場合は streaming 中の redraw をスキップし、完了時の最終 redraw のみに寄せるようにしました(小さい chunk は従来どおり throttle redraw)。これで 10000 件単位の到着時の引っかかりをさらに抑えます。スクリーンショット: /tmp/ddu-redraw-update.svg

Copilot AI changed the title Throttle and coalesce redraws during streaming gather to reduce UI stalls Throttle/coalesce streaming redraws and skip huge chunk redraws to reduce UI stalls May 9, 2026
Copilot finished work on behalf of Shougo May 9, 2026 04:13
@Shougo Shougo merged commit 823f029 into main May 9, 2026
3 checks passed
@Shougo Shougo deleted the copilot/fix-ui-freeze-issue branch May 9, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants