import Component from "@glimmer/component"; import { cached } from "@glimmer/tracking"; import { assert } from "@ember/debug"; import { isDestroyed, isDestroying, registerDestructor } from "@ember/destroyable"; import { buildWaiter } from "@ember/test-waiters"; import { cell } from "ember-resources"; import type Owner from "@ember/owner"; const DEFAULT_BATCH_SIZE = 50; const DEFAULT_INITIAL = "sync"; const waiter = buildWaiter("ember-primitives:incremental-each"); function chunk(arr: readonly T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < arr.length; i += size) { out.push(arr.slice(i, i + size)); } return out; } // Safari has `requestIdleCallback` behind a flag, effectively absent // for end users. Fall back to `setTimeout(cb, 0)` — Safari users get // the chunking benefit (one batch per task) without the idle-priority // hint that other browsers honor. const ric: typeof requestIdleCallback = typeof requestIdleCallback === "function" ? requestIdleCallback : (cb) => setTimeout(() => cb({ timeRemaining: () => 0, didTimeout: true }), 0); export interface Signature { Args: { /** * The collection of items to render. * * Replacing the array (new identity) restarts rendering from the * first batch. * * ```gjs * import { IncrementalEach } from 'ember-primitives'; * * * ``` */ items: readonly T[]; /** * How many items to add per animation frame. * * Larger batches add more items per chunk; smaller batches yield to * the browser more often. * * Default: 50. Must be positive; `0` or less asserts in development. * * ```gjs * import { IncrementalEach } from 'ember-primitives'; * * * ``` */ batchSize?: number; /** * Controls how the initial batch is committed. * * - `"sync"` (default): the first `@batchSize` items render in the * same render pass as mount / `@items` change. The user sees * content on the very first paint, and the rest of the list * fills in one batch per animation frame. This is the right * default for most lists — even a perceived "empty for one * frame" is worse than rendering a few extra items synchronously. * - `"lazy"`: even the first batch waits for an animation frame, so * the initial paint is empty and content arrives one batch per * frame. Use this when the first batch itself would be expensive * enough to block the first paint, and you'd rather show an * empty container than delay it. * * Default: `"sync"`. * * ```gjs * import { IncrementalEach } from 'ember-primitives'; * * * ``` */ initial?: "sync" | "lazy"; /** * Called once with no arguments when every item in `@items` has * been committed to the DOM. Fires after the final batch lands; * does not fire on intermediate batches. * * Fires again on a fresh swap (new `@items` identity) once that * new collection finishes rendering. An empty `@items` array * does not fire the callback. * * Useful for marking the list as ready for screenshot tests, * dismissing a loading indicator, or measuring how long the * whole render took. * * ```gjs * import { IncrementalEach } from 'ember-primitives'; * * * ``` */ onDone?: () => void; }; Blocks: { /** * Yielded for each rendered item, with the index in the original * `@items` array. * * ```gjs * import { IncrementalEach } from 'ember-primitives'; * * * ``` */ default: [item: T, index: number]; }; } /** * A drop-in replacement for `{{#each}}` that renders a large collection * a batch at a time on each animation frame, instead of all at once. * * Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor * links, screen readers, print, and SEO all work against the full list. * Yielding the main thread between batches keeps the page responsive while * the rest of the list is filling in. * * By default the first batch lands synchronously, so the user sees content * on the very first paint. Pass `@initial="lazy"` to defer the first batch * to an animation frame as well. * * Intended for non-scrollable containers, or anywhere a virtual/windowed * list does not apply (variable item heights, lists that grow the page, * surfaces that need every row indexable). * * Do not nest one `` inside another. Each level adds an * animation-frame delay before its content paints; nesting compounds those * delays, so inner rows appear to flicker in with missing sub-content. * If you have nested loops, only the outermost one should be * ``; leave deeper loops as plain `{{#each}}`. * * @example * ```gjs * import { IncrementalEach } from 'ember-primitives'; * * * ``` */ export class IncrementalEach extends Component> { #count = cell(0); #itemsRef: readonly T[] | null = null; #waiterToken: unknown = null; #doneFor: object | null = null; constructor(owner: Owner, args: Signature["Args"]) { super(owner, args); registerDestructor(this, () => this.#endWaiter()); } // Reset progress and (re)open the test-waiter when `@items` identity // changes, so a swap restarts at the first batch, `@onDone` can fire // again for the new collection, and `await settled()` knows to wait // until `checkDone` closes the waiter. Mutating from a getter is safe // here because the writes happen before any consumer reads them in // the same render pass. /* eslint-disable ember/no-side-effects */ get #items(): readonly T[] { const items = this.args.items; assert(`@items must be an array`, items); if (items !== this.#itemsRef) { this.#itemsRef = items; this.#count.current = 0; this.#endWaiter(); if (items.length > 0) { this.#waiterToken = waiter.beginAsync(); } } return items; } /* eslint-enable ember/no-side-effects */ // `"sync"` keeps bucket 0 visible at count=0 (`i = 0 >= 0`); `"lazy"` // starts one step behind so even bucket 0 needs a tick. get #start() { return this.#initial === "sync" ? 0 : -1; } get i() { return this.#start + this.#count.current; } @cached get bucketed() { const size = this.#batchSize; return chunk(this.#items, size).map((items, b) => { const start = b * size; return { isReady: () => this.i >= b, items: items.map((value, j) => ({ value, index: start + j })), }; }); } get #batchSize(): number { const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE; assert( ` @batchSize must be a positive number, got ${requested}`, requested > 0, ); return requested; } get #initial(): "sync" | "lazy" { const requested = this.args.initial ?? DEFAULT_INITIAL; assert( ` @initial must be "sync" or "lazy", got ${requested}`, requested === "sync" || requested === "lazy", ); return requested; } // `#items` is read before `#count` so the count-reset inside `#items` // (on `@items` swap) lands before this read of count this render — // otherwise tracked-value backtracking asserts. tick = () => { if (this.#items.length > this.#count.current) { ric(() => this.#count.current++, { timeout: 10 }); } }; checkDone = () => { const bucketed = this.bucketed; if (this.#doneFor === bucketed) return; if (this.i < bucketed.length - 1) return; this.#doneFor = bucketed; queueMicrotask(() => { if (isDestroyed(this) || isDestroying(this)) return; this.args.onDone?.(); this.#endWaiter(); }); }; #endWaiter() { if (this.#waiterToken) waiter.endAsync(this.#waiterToken); } }