ember-primitives 0.58.0 → 0.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,7 @@ export interface Signature<T = unknown> {
20
20
  */
21
21
  items: readonly T[];
22
22
  /**
23
- * How many items to add per idle callback.
23
+ * How many items to add per animation frame.
24
24
  *
25
25
  * Larger batches add more items per chunk; smaller batches yield to
26
26
  * the browser more often.
@@ -38,6 +38,58 @@ export interface Signature<T = unknown> {
38
38
  * ```
39
39
  */
40
40
  batchSize?: number;
41
+ /**
42
+ * Controls how the initial batch is committed.
43
+ *
44
+ * - `"sync"` (default): the first `@batchSize` items render in the
45
+ * same render pass as mount / `@items` change. The user sees
46
+ * content on the very first paint, and the rest of the list
47
+ * fills in one batch per animation frame. This is the right
48
+ * default for most lists — even a perceived "empty for one
49
+ * frame" is worse than rendering a few extra items synchronously.
50
+ * - `"lazy"`: even the first batch waits for an animation frame, so
51
+ * the initial paint is empty and content arrives one batch per
52
+ * frame. Use this when the first batch itself would be expensive
53
+ * enough to block the first paint, and you'd rather show an
54
+ * empty container than delay it.
55
+ *
56
+ * Default: `"sync"`.
57
+ *
58
+ * ```gjs
59
+ * import { IncrementalEach } from 'ember-primitives';
60
+ *
61
+ * <template>
62
+ * <IncrementalEach @items={{this.rows}} @initial="lazy" as |row|>
63
+ * <my-row @row={{row}} />
64
+ * </IncrementalEach>
65
+ * </template>
66
+ * ```
67
+ */
68
+ initial?: "sync" | "lazy";
69
+ /**
70
+ * Called once with no arguments when every item in `@items` has
71
+ * been committed to the DOM. Fires after the final batch lands;
72
+ * does not fire on intermediate batches.
73
+ *
74
+ * Fires again on a fresh swap (new `@items` identity) once that
75
+ * new collection finishes rendering. An empty `@items` array
76
+ * does not fire the callback.
77
+ *
78
+ * Useful for marking the list as ready for screenshot tests,
79
+ * dismissing a loading indicator, or measuring how long the
80
+ * whole render took.
81
+ *
82
+ * ```gjs
83
+ * import { IncrementalEach } from 'ember-primitives';
84
+ *
85
+ * <template>
86
+ * <IncrementalEach @items={{this.rows}} @onDone={{this.handleDone}} as |row|>
87
+ * <my-row @row={{row}} />
88
+ * </IncrementalEach>
89
+ * </template>
90
+ * ```
91
+ */
92
+ onDone?: () => void;
41
93
  };
42
94
  Blocks: {
43
95
  /**
@@ -58,18 +110,28 @@ export interface Signature<T = unknown> {
58
110
  };
59
111
  }
60
112
  /**
61
- * A drop-in replacement for `{{#each}}` that renders a large collection a
62
- * batch at a time during the browser's idle periods, instead of all at once.
113
+ * A drop-in replacement for `{{#each}}` that renders a large collection
114
+ * a batch at a time on each animation frame, instead of all at once.
63
115
  *
64
116
  * Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor
65
117
  * links, screen readers, print, and SEO all work against the full list.
66
118
  * Yielding the main thread between batches keeps the page responsive while
67
119
  * the rest of the list is filling in.
68
120
  *
121
+ * By default the first batch lands synchronously, so the user sees content
122
+ * on the very first paint. Pass `@initial="lazy"` to defer the first batch
123
+ * to an animation frame as well.
124
+ *
69
125
  * Intended for non-scrollable containers, or anywhere a virtual/windowed
70
126
  * list does not apply (variable item heights, lists that grow the page,
71
127
  * surfaces that need every row indexable).
72
128
  *
129
+ * Do not nest one `<IncrementalEach>` inside another. Each level adds an
130
+ * animation-frame delay before its content paints; nesting compounds those
131
+ * delays, so inner rows appear to flicker in with missing sub-content.
132
+ * If you have nested loops, only the outermost one should be
133
+ * `<IncrementalEach>`; leave deeper loops as plain `{{#each}}`.
134
+ *
73
135
  * @example
74
136
  * ```gjs
75
137
  * import { IncrementalEach } from 'ember-primitives';
@@ -86,6 +148,15 @@ export interface Signature<T = unknown> {
86
148
  export declare class IncrementalEach<T = unknown> extends Component<Signature<T>> {
87
149
  #private;
88
150
  constructor(owner: Owner, args: Signature<T>["Args"]);
89
- get visible(): readonly T[];
151
+ get i(): number;
152
+ get bucketed(): {
153
+ isReady: () => boolean;
154
+ items: {
155
+ value: T;
156
+ index: number;
157
+ }[];
158
+ }[];
159
+ tick: () => void;
160
+ checkDone: () => void;
90
161
  }
91
162
  //# sourceMappingURL=incremental-each.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"incremental-each.d.ts","sourceRoot":"","sources":["../../src/components/incremental-each.gts"],"names":[],"mappings":"AA6MA,OAAO,SAAS,MAAM,oBAAoB,CAAC;AAO3C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAMtC,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACpC,IAAI,EAAE;QACJ;;;;;;;;;;;;;;;WAeG;QACH,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;QAEpB;;;;;;;;;;;;;;;;;WAiBG;QACH,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,MAAM,EAAE;QACN;;;;;;;;;;;;;WAaG;QACH,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;KACnC,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,qBAAa,eAAe,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;;gBAe3D,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IA0BpD,IAAI,OAAO,IAAI,SAAS,CAAC,EAAE,CAc1B;CAyDF"}
1
+ {"version":3,"file":"incremental-each.d.ts","sourceRoot":"","sources":["../../src/components/incremental-each.gts"],"names":[],"mappings":"AA6SA,OAAO,SAAS,MAAM,oBAAoB,CAAC;AAQ3C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AA0BtC,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACpC,IAAI,EAAE;QACJ;;;;;;;;;;;;;;;WAeG;QACH,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;QAEpB;;;;;;;;;;;;;;;;;WAiBG;QACH,SAAS,CAAC,EAAE,MAAM,CAAC;QAEnB;;;;;;;;;;;;;;;;;;;;;;;;;;WA0BG;QACH,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAE1B;;;;;;;;;;;;;;;;;;;;;;WAsBG;QACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;KACrB,CAAC;IACF,MAAM,EAAE;QACN;;;;;;;;;;;;;WAaG;QACH,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;KACnC,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,qBAAa,eAAe,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;;gBAM3D,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IAsCpD,IAAI,CAAC,WAEJ;IAED,IACI,QAAQ;;;;;;QAWX;IA2BD,IAAI,aAIF;IAEF,SAAS,aAYP;CA4BH"}
@@ -1,26 +1,54 @@
1
1
  import Component from '@glimmer/component';
2
+ import { cached } from '@glimmer/tracking';
2
3
  import { assert } from '@ember/debug';
3
4
  import { registerDestructor, isDestroyed, isDestroying } from '@ember/destroyable';
4
5
  import { buildWaiter } from '@ember/test-waiters';
5
6
  import { cell } from 'ember-resources';
6
7
  import { precompileTemplate } from '@ember/template-compilation';
7
8
  import { setComponentTemplate } from '@ember/component';
9
+ import { n } from 'decorator-transforms/runtime';
8
10
 
9
11
  const DEFAULT_BATCH_SIZE = 50;
12
+ const DEFAULT_INITIAL = "sync";
10
13
  const waiter = buildWaiter("ember-primitives:incremental-each");
14
+ function chunk(arr, size) {
15
+ const out = [];
16
+ for (let i = 0; i < arr.length; i += size) {
17
+ out.push(arr.slice(i, i + size));
18
+ }
19
+ return out;
20
+ }
21
+ // Safari has `requestIdleCallback` behind a flag, effectively absent
22
+ // for end users. Fall back to `setTimeout(cb, 0)` — Safari users get
23
+ // the chunking benefit (one batch per task) without the idle-priority
24
+ // hint that other browsers honor.
25
+ const ric = typeof requestIdleCallback === "function" ? requestIdleCallback : cb => setTimeout(() => cb({
26
+ timeRemaining: () => 0,
27
+ didTimeout: true
28
+ }), 0);
11
29
  /**
12
- * A drop-in replacement for `{{#each}}` that renders a large collection a
13
- * batch at a time during the browser's idle periods, instead of all at once.
30
+ * A drop-in replacement for `{{#each}}` that renders a large collection
31
+ * a batch at a time on each animation frame, instead of all at once.
14
32
  *
15
33
  * Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor
16
34
  * links, screen readers, print, and SEO all work against the full list.
17
35
  * Yielding the main thread between batches keeps the page responsive while
18
36
  * the rest of the list is filling in.
19
37
  *
38
+ * By default the first batch lands synchronously, so the user sees content
39
+ * on the very first paint. Pass `@initial="lazy"` to defer the first batch
40
+ * to an animation frame as well.
41
+ *
20
42
  * Intended for non-scrollable containers, or anywhere a virtual/windowed
21
43
  * list does not apply (variable item heights, lists that grow the page,
22
44
  * surfaces that need every row indexable).
23
45
  *
46
+ * Do not nest one `<IncrementalEach>` inside another. Each level adds an
47
+ * animation-frame delay before its content paints; nesting compounds those
48
+ * delays, so inner rows appear to flicker in with missing sub-content.
49
+ * If you have nested loops, only the outermost one should be
50
+ * `<IncrementalEach>`; leave deeper loops as plain `{{#each}}`.
51
+ *
24
52
  * @example
25
53
  * ```gjs
26
54
  * import { IncrementalEach } from 'ember-primitives';
@@ -35,82 +63,94 @@ const waiter = buildWaiter("ember-primitives:incremental-each");
35
63
  * ```
36
64
  */
37
65
  class IncrementalEach extends Component {
38
- // How many items have been committed to the DOM so far. Bumped one
39
- // batch at a time by the idle callback. Wrapped in a `cell` because
40
- // `@tracked` doesn't compose with `#`-private fields under this
41
- // codebase's decorator transform.
42
66
  #count = cell(0);
43
- // Plain field so identity checks don't add a render-time dependency
44
- // on top of `args.items`.
45
67
  #itemsRef = null;
46
- #idleHandle = null;
47
68
  #waiterToken = null;
69
+ #doneFor = null;
48
70
  constructor(owner, args) {
49
71
  super(owner, args);
50
- registerDestructor(this, () => this.#cancel());
72
+ registerDestructor(this, () => this.#endWaiter());
51
73
  }
52
- get #batchSize() {
53
- const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;
54
- assert(`<IncrementalEach> @batchSize must be a positive number, got ${requested}`, requested > 0);
55
- return requested;
56
- }
57
- // The items that should currently be rendered. `@cached` keeps the
58
- // returned slice stable across renders that don't change `#count` or
59
- // `args.items`, so Glimmer's `{{#each}}` doesn't see a fresh array on
60
- // every render and we only slice once per batch landing. This is
61
- // also where scheduling is driven from: `@items` identity changes
62
- // reset `#count` to zero, and missing items queue the next idle
63
- // callback. Autotrack stays consistent because the only synchronous
64
- // write here (`#count = 0`) happens before `#count` is read.
74
+ // Reset progress and (re)open the test-waiter when `@items` identity
75
+ // changes, so a swap restarts at the first batch, `@onDone` can fire
76
+ // again for the new collection, and `await settled()` knows to wait
77
+ // until `checkDone` closes the waiter. Mutating from a getter is safe
78
+ // here because the writes happen before any consumer reads them in
79
+ // the same render pass.
65
80
  /* eslint-disable ember/no-side-effects */
66
- get visible() {
67
- const items = this.args.items ?? [];
81
+ get #items() {
82
+ const items = this.args.items;
83
+ assert(`@items must be an array`, items);
68
84
  if (items !== this.#itemsRef) {
69
85
  this.#itemsRef = items;
70
- this.#cancel();
71
86
  this.#count.current = 0;
87
+ this.#endWaiter();
88
+ if (items.length > 0) {
89
+ this.#waiterToken = waiter.beginAsync();
90
+ }
72
91
  }
73
- if (this.#count.current < items.length && this.#idleHandle === null) {
74
- this.#scheduleNextBatch();
75
- }
76
- return items.slice(0, this.#count.current);
92
+ return items;
77
93
  }
78
- /* eslint-enable ember/no-side-effects */
79
- #scheduleNextBatch() {
80
- // Defensive: if a batch is already pending, drop it before
81
- // queueing a new one. The `visible` getter already guards on
82
- // `#idleHandle === null`, but a future caller might not.
83
- this.#cancel();
84
- this.#waiterToken = waiter.beginAsync();
85
- // The `timeout` cap ensures forward progress even when the host
86
- // is CPU-bound and the browser never reports a free idle slot.
87
- // In normal use this is a no-op because real idle time arrives
88
- // far sooner.
89
- this.#idleHandle = requestIdleCallback(() => {
90
- this.#idleHandle = null;
91
- if (isDestroyed(this) || isDestroying(this)) return;
92
- const items = this.args.items ?? [];
93
- this.#count.current = Math.min(this.#count.current + this.#batchSize, items.length);
94
- this.#endWaiter();
95
- }, {
96
- timeout: 100
94
+ /* eslint-enable ember/no-side-effects */ // `"sync"` keeps bucket 0 visible at count=0 (`i = 0 >= 0`); `"lazy"`
95
+ // starts one step behind so even bucket 0 needs a tick.
96
+ get #start() {
97
+ return this.#initial === "sync" ? 0 : -1;
98
+ }
99
+ get i() {
100
+ return this.#start + this.#count.current;
101
+ }
102
+ get bucketed() {
103
+ const size = this.#batchSize;
104
+ return chunk(this.#items, size).map((items, b) => {
105
+ const start = b * size;
106
+ return {
107
+ isReady: () => this.i >= b,
108
+ items: items.map((value, j) => ({
109
+ value,
110
+ index: start + j
111
+ }))
112
+ };
97
113
  });
98
114
  }
99
- #cancel() {
100
- if (this.#idleHandle !== null) {
101
- cancelIdleCallback(this.#idleHandle);
102
- this.#idleHandle = null;
103
- }
104
- this.#endWaiter();
115
+ static {
116
+ n(this.prototype, "bucketed", [cached]);
105
117
  }
106
- #endWaiter() {
107
- if (this.#waiterToken !== null) {
108
- waiter.endAsync(this.#waiterToken);
109
- this.#waiterToken = null;
118
+ get #batchSize() {
119
+ const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;
120
+ assert(`<IncrementalEach> @batchSize must be a positive number, got ${requested}`, requested > 0);
121
+ return requested;
122
+ }
123
+ get #initial() {
124
+ const requested = this.args.initial ?? DEFAULT_INITIAL;
125
+ assert(`<IncrementalEach> @initial must be "sync" or "lazy", got ${requested}`, requested === "sync" || requested === "lazy");
126
+ return requested;
127
+ }
128
+ // `#items` is read before `#count` so the count-reset inside `#items`
129
+ // (on `@items` swap) lands before this read of count this render —
130
+ // otherwise tracked-value backtracking asserts.
131
+ tick = () => {
132
+ if (this.#items.length > this.#count.current) {
133
+ ric(() => this.#count.current++, {
134
+ timeout: 10
135
+ });
110
136
  }
137
+ };
138
+ checkDone = () => {
139
+ const bucketed = this.bucketed;
140
+ if (this.#doneFor === bucketed) return;
141
+ if (this.i < bucketed.length - 1) return;
142
+ this.#doneFor = bucketed;
143
+ queueMicrotask(() => {
144
+ if (isDestroyed(this) || isDestroying(this)) return;
145
+ this.args.onDone?.();
146
+ this.#endWaiter();
147
+ });
148
+ };
149
+ #endWaiter() {
150
+ if (this.#waiterToken) waiter.endAsync(this.#waiterToken);
111
151
  }
112
152
  static {
113
- setComponentTemplate(precompileTemplate("{{#each this.visible as |item index|}}\n {{yield item index}}\n{{/each}}", {
153
+ setComponentTemplate(precompileTemplate("{{(this.tick)}}{{#each this.bucketed as |bucket|}}{{#if (bucket.isReady)}}{{#each bucket.items as |entry|}}{{yield entry.value entry.index}}{{/each}}{{(this.checkDone)}}{{/if}}{{/each}}", {
114
154
  strictMode: true
115
155
  }), this);
116
156
  }
@@ -1 +1 @@
1
- {"version":3,"file":"incremental-each.js","sources":["../../src/components/incremental-each.gts"],"sourcesContent":["import Component from \"@glimmer/component\";\nimport { assert } from \"@ember/debug\";\nimport { isDestroyed, isDestroying, registerDestructor } from \"@ember/destroyable\";\nimport { buildWaiter } from \"@ember/test-waiters\";\n\nimport { cell } from \"ember-resources\";\n\nimport type Owner from \"@ember/owner\";\n\nconst DEFAULT_BATCH_SIZE = 50;\n\nconst waiter = buildWaiter(\"ember-primitives:incremental-each\");\n\nexport interface Signature<T = unknown> {\n Args: {\n /**\n * The collection of items to render.\n *\n * Replacing the array (new identity) restarts rendering from the\n * first batch.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} as |row|>\n * <my-row @row={{row}} />\n * </IncrementalEach>\n * </template>\n * ```\n */\n items: readonly T[];\n\n /**\n * How many items to add per idle callback.\n *\n * Larger batches add more items per chunk; smaller batches yield to\n * the browser more often.\n *\n * Default: 50. Must be positive; `0` or less asserts in development.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} @batchSize={{100}} as |row|>\n * <my-row @row={{row}} />\n * </IncrementalEach>\n * </template>\n * ```\n */\n batchSize?: number;\n };\n Blocks: {\n /**\n * Yielded for each rendered item, with the index in the original\n * `@items` array.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} as |row index|>\n * {{index}}: {{row.label}}\n * </IncrementalEach>\n * </template>\n * ```\n */\n default: [item: T, index: number];\n };\n}\n\n/**\n * A drop-in replacement for `{{#each}}` that renders a large collection a\n * batch at a time during the browser's idle periods, instead of all at once.\n *\n * Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor\n * links, screen readers, print, and SEO all work against the full list.\n * Yielding the main thread between batches keeps the page responsive while\n * the rest of the list is filling in.\n *\n * Intended for non-scrollable containers, or anywhere a virtual/windowed\n * list does not apply (variable item heights, lists that grow the page,\n * surfaces that need every row indexable).\n *\n * @example\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <ul>\n * <IncrementalEach @items={{this.rows}} @batchSize={{100}} as |row index|>\n * <li>{{index}}: {{row.label}}</li>\n * </IncrementalEach>\n * </ul>\n * </template>\n * ```\n */\nexport class IncrementalEach<T = unknown> extends Component<Signature<T>> {\n // How many items have been committed to the DOM so far. Bumped one\n // batch at a time by the idle callback. Wrapped in a `cell` because\n // `@tracked` doesn't compose with `#`-private fields under this\n // codebase's decorator transform.\n #count = cell(0);\n\n // Plain field so identity checks don't add a render-time dependency\n // on top of `args.items`.\n #itemsRef: readonly T[] | null = null;\n\n #idleHandle: number | null = null;\n\n #waiterToken: unknown = null;\n\n constructor(owner: Owner, args: Signature<T>[\"Args\"]) {\n super(owner, args);\n\n registerDestructor(this, () => this.#cancel());\n }\n\n get #batchSize(): number {\n const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;\n\n assert(\n `<IncrementalEach> @batchSize must be a positive number, got ${requested}`,\n requested > 0,\n );\n\n return requested;\n }\n\n // The items that should currently be rendered. `@cached` keeps the\n // returned slice stable across renders that don't change `#count` or\n // `args.items`, so Glimmer's `{{#each}}` doesn't see a fresh array on\n // every render and we only slice once per batch landing. This is\n // also where scheduling is driven from: `@items` identity changes\n // reset `#count` to zero, and missing items queue the next idle\n // callback. Autotrack stays consistent because the only synchronous\n // write here (`#count = 0`) happens before `#count` is read.\n /* eslint-disable ember/no-side-effects */\n get visible(): readonly T[] {\n const items = this.args.items ?? [];\n\n if (items !== this.#itemsRef) {\n this.#itemsRef = items;\n this.#cancel();\n this.#count.current = 0;\n }\n\n if (this.#count.current < items.length && this.#idleHandle === null) {\n this.#scheduleNextBatch();\n }\n\n return items.slice(0, this.#count.current);\n }\n /* eslint-enable ember/no-side-effects */\n\n #scheduleNextBatch() {\n // Defensive: if a batch is already pending, drop it before\n // queueing a new one. The `visible` getter already guards on\n // `#idleHandle === null`, but a future caller might not.\n this.#cancel();\n\n this.#waiterToken = waiter.beginAsync();\n\n // The `timeout` cap ensures forward progress even when the host\n // is CPU-bound and the browser never reports a free idle slot.\n // In normal use this is a no-op because real idle time arrives\n // far sooner.\n this.#idleHandle = requestIdleCallback(\n () => {\n this.#idleHandle = null;\n\n if (isDestroyed(this) || isDestroying(this)) return;\n\n const items = this.args.items ?? [];\n\n this.#count.current = Math.min(this.#count.current + this.#batchSize, items.length);\n this.#endWaiter();\n },\n { timeout: 100 },\n );\n }\n\n #cancel() {\n if (this.#idleHandle !== null) {\n cancelIdleCallback(this.#idleHandle);\n this.#idleHandle = null;\n }\n\n this.#endWaiter();\n }\n\n #endWaiter() {\n if (this.#waiterToken !== null) {\n waiter.endAsync(this.#waiterToken);\n this.#waiterToken = null;\n }\n }\n\n <template>\n {{#each this.visible as |item index|}}\n {{yield item index}}\n {{/each}}\n </template>\n}\n"],"names":["DEFAULT_BATCH_SIZE","waiter","buildWaiter","IncrementalEach","Component","cell","constructor","owner","args","registerDestructor","#batchSize","requested","batchSize","assert","visible","items","current","length","slice","#scheduleNextBatch","beginAsync","requestIdleCallback","isDestroyed","isDestroying","Math","min","timeout","#cancel","cancelIdleCallback","#endWaiter","endAsync","setComponentTemplate","precompileTemplate","strictMode"],"mappings":"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"}
1
+ {"version":3,"file":"incremental-each.js","sources":["../../src/components/incremental-each.gts"],"sourcesContent":["import Component from \"@glimmer/component\";\nimport { cached } from \"@glimmer/tracking\";\nimport { assert } from \"@ember/debug\";\nimport { isDestroyed, isDestroying, registerDestructor } from \"@ember/destroyable\";\nimport { buildWaiter } from \"@ember/test-waiters\";\n\nimport { cell } from \"ember-resources\";\n\nimport type Owner from \"@ember/owner\";\n\nconst DEFAULT_BATCH_SIZE = 50;\nconst DEFAULT_INITIAL = \"sync\";\n\nconst waiter = buildWaiter(\"ember-primitives:incremental-each\");\n\nfunction chunk<T>(arr: readonly T[], size: number): T[][] {\n const out: T[][] = [];\n\n for (let i = 0; i < arr.length; i += size) {\n out.push(arr.slice(i, i + size));\n }\n\n return out;\n}\n\n// Safari has `requestIdleCallback` behind a flag, effectively absent\n// for end users. Fall back to `setTimeout(cb, 0)` — Safari users get\n// the chunking benefit (one batch per task) without the idle-priority\n// hint that other browsers honor.\nconst ric: typeof requestIdleCallback =\n typeof requestIdleCallback === \"function\"\n ? requestIdleCallback\n : (cb) => setTimeout(() => cb({ timeRemaining: () => 0, didTimeout: true }), 0);\n\nexport interface Signature<T = unknown> {\n Args: {\n /**\n * The collection of items to render.\n *\n * Replacing the array (new identity) restarts rendering from the\n * first batch.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} as |row|>\n * <my-row @row={{row}} />\n * </IncrementalEach>\n * </template>\n * ```\n */\n items: readonly T[];\n\n /**\n * How many items to add per animation frame.\n *\n * Larger batches add more items per chunk; smaller batches yield to\n * the browser more often.\n *\n * Default: 50. Must be positive; `0` or less asserts in development.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} @batchSize={{100}} as |row|>\n * <my-row @row={{row}} />\n * </IncrementalEach>\n * </template>\n * ```\n */\n batchSize?: number;\n\n /**\n * Controls how the initial batch is committed.\n *\n * - `\"sync\"` (default): the first `@batchSize` items render in the\n * same render pass as mount / `@items` change. The user sees\n * content on the very first paint, and the rest of the list\n * fills in one batch per animation frame. This is the right\n * default for most lists — even a perceived \"empty for one\n * frame\" is worse than rendering a few extra items synchronously.\n * - `\"lazy\"`: even the first batch waits for an animation frame, so\n * the initial paint is empty and content arrives one batch per\n * frame. Use this when the first batch itself would be expensive\n * enough to block the first paint, and you'd rather show an\n * empty container than delay it.\n *\n * Default: `\"sync\"`.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} @initial=\"lazy\" as |row|>\n * <my-row @row={{row}} />\n * </IncrementalEach>\n * </template>\n * ```\n */\n initial?: \"sync\" | \"lazy\";\n\n /**\n * Called once with no arguments when every item in `@items` has\n * been committed to the DOM. Fires after the final batch lands;\n * does not fire on intermediate batches.\n *\n * Fires again on a fresh swap (new `@items` identity) once that\n * new collection finishes rendering. An empty `@items` array\n * does not fire the callback.\n *\n * Useful for marking the list as ready for screenshot tests,\n * dismissing a loading indicator, or measuring how long the\n * whole render took.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} @onDone={{this.handleDone}} as |row|>\n * <my-row @row={{row}} />\n * </IncrementalEach>\n * </template>\n * ```\n */\n onDone?: () => void;\n };\n Blocks: {\n /**\n * Yielded for each rendered item, with the index in the original\n * `@items` array.\n *\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <IncrementalEach @items={{this.rows}} as |row index|>\n * {{index}}: {{row.label}}\n * </IncrementalEach>\n * </template>\n * ```\n */\n default: [item: T, index: number];\n };\n}\n\n/**\n * A drop-in replacement for `{{#each}}` that renders a large collection\n * a batch at a time on each animation frame, instead of all at once.\n *\n * Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor\n * links, screen readers, print, and SEO all work against the full list.\n * Yielding the main thread between batches keeps the page responsive while\n * the rest of the list is filling in.\n *\n * By default the first batch lands synchronously, so the user sees content\n * on the very first paint. Pass `@initial=\"lazy\"` to defer the first batch\n * to an animation frame as well.\n *\n * Intended for non-scrollable containers, or anywhere a virtual/windowed\n * list does not apply (variable item heights, lists that grow the page,\n * surfaces that need every row indexable).\n *\n * Do not nest one `<IncrementalEach>` inside another. Each level adds an\n * animation-frame delay before its content paints; nesting compounds those\n * delays, so inner rows appear to flicker in with missing sub-content.\n * If you have nested loops, only the outermost one should be\n * `<IncrementalEach>`; leave deeper loops as plain `{{#each}}`.\n *\n * @example\n * ```gjs\n * import { IncrementalEach } from 'ember-primitives';\n *\n * <template>\n * <ul>\n * <IncrementalEach @items={{this.rows}} @batchSize={{100}} as |row index|>\n * <li>{{index}}: {{row.label}}</li>\n * </IncrementalEach>\n * </ul>\n * </template>\n * ```\n */\nexport class IncrementalEach<T = unknown> extends Component<Signature<T>> {\n #count = cell(0);\n #itemsRef: readonly T[] | null = null;\n #waiterToken: unknown = null;\n #doneFor: object | null = null;\n\n constructor(owner: Owner, args: Signature<T>[\"Args\"]) {\n super(owner, args);\n\n registerDestructor(this, () => this.#endWaiter());\n }\n\n // Reset progress and (re)open the test-waiter when `@items` identity\n // changes, so a swap restarts at the first batch, `@onDone` can fire\n // again for the new collection, and `await settled()` knows to wait\n // until `checkDone` closes the waiter. Mutating from a getter is safe\n // here because the writes happen before any consumer reads them in\n // the same render pass.\n /* eslint-disable ember/no-side-effects */\n get #items(): readonly T[] {\n const items = this.args.items;\n\n assert(`@items must be an array`, items);\n\n if (items !== this.#itemsRef) {\n this.#itemsRef = items;\n this.#count.current = 0;\n this.#endWaiter();\n\n if (items.length > 0) {\n this.#waiterToken = waiter.beginAsync();\n }\n }\n\n return items;\n }\n /* eslint-enable ember/no-side-effects */\n\n // `\"sync\"` keeps bucket 0 visible at count=0 (`i = 0 >= 0`); `\"lazy\"`\n // starts one step behind so even bucket 0 needs a tick.\n get #start() {\n return this.#initial === \"sync\" ? 0 : -1;\n }\n\n get i() {\n return this.#start + this.#count.current;\n }\n\n @cached\n get bucketed() {\n const size = this.#batchSize;\n\n return chunk(this.#items, size).map((items, b) => {\n const start = b * size;\n\n return {\n isReady: () => this.i >= b,\n items: items.map((value, j) => ({ value, index: start + j })),\n };\n });\n }\n\n get #batchSize(): number {\n const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;\n\n assert(\n `<IncrementalEach> @batchSize must be a positive number, got ${requested}`,\n requested > 0,\n );\n\n return requested;\n }\n\n get #initial(): \"sync\" | \"lazy\" {\n const requested = this.args.initial ?? DEFAULT_INITIAL;\n\n assert(\n `<IncrementalEach> @initial must be \"sync\" or \"lazy\", got ${requested}`,\n requested === \"sync\" || requested === \"lazy\",\n );\n\n return requested;\n }\n\n // `#items` is read before `#count` so the count-reset inside `#items`\n // (on `@items` swap) lands before this read of count this render —\n // otherwise tracked-value backtracking asserts.\n tick = () => {\n if (this.#items.length > this.#count.current) {\n ric(() => this.#count.current++, { timeout: 10 });\n }\n };\n\n checkDone = () => {\n const bucketed = this.bucketed;\n\n if (this.#doneFor === bucketed) return;\n if (this.i < bucketed.length - 1) return;\n\n this.#doneFor = bucketed;\n queueMicrotask(() => {\n if (isDestroyed(this) || isDestroying(this)) return;\n this.args.onDone?.();\n this.#endWaiter();\n });\n };\n\n #endWaiter() {\n if (this.#waiterToken) waiter.endAsync(this.#waiterToken);\n }\n\n <template>\n {{(this.tick)}}{{#each this.bucketed as |bucket|}}{{#if (bucket.isReady)}}{{#each\n bucket.items\n as |entry|\n }}{{yield entry.value entry.index}}{{/each}}{{(this.checkDone)}}{{/if}}{{/each}}\n </template>\n}\n"],"names":["DEFAULT_BATCH_SIZE","DEFAULT_INITIAL","waiter","buildWaiter","chunk","arr","size","out","i","length","push","slice","ric","requestIdleCallback","cb","setTimeout","timeRemaining","didTimeout","IncrementalEach","Component","cell","constructor","owner","args","registerDestructor","#items","items","assert","current","beginAsync","#start","bucketed","map","b","start","isReady","value","j","index","n","prototype","cached","#batchSize","requested","batchSize","#initial","initial","tick","timeout","checkDone","queueMicrotask","isDestroyed","isDestroying","onDone","#endWaiter","endAsync","setComponentTemplate","precompileTemplate","strictMode"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-primitives",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
4
4
  "description": "Making apps easier to build",
5
5
  "keywords": [
6
6
  "ember-addon"
@@ -1,4 +1,5 @@
1
1
  import Component from "@glimmer/component";
2
+ import { cached } from "@glimmer/tracking";
2
3
  import { assert } from "@ember/debug";
3
4
  import { isDestroyed, isDestroying, registerDestructor } from "@ember/destroyable";
4
5
  import { buildWaiter } from "@ember/test-waiters";
@@ -8,9 +9,29 @@ import { cell } from "ember-resources";
8
9
  import type Owner from "@ember/owner";
9
10
 
10
11
  const DEFAULT_BATCH_SIZE = 50;
12
+ const DEFAULT_INITIAL = "sync";
11
13
 
12
14
  const waiter = buildWaiter("ember-primitives:incremental-each");
13
15
 
16
+ function chunk<T>(arr: readonly T[], size: number): T[][] {
17
+ const out: T[][] = [];
18
+
19
+ for (let i = 0; i < arr.length; i += size) {
20
+ out.push(arr.slice(i, i + size));
21
+ }
22
+
23
+ return out;
24
+ }
25
+
26
+ // Safari has `requestIdleCallback` behind a flag, effectively absent
27
+ // for end users. Fall back to `setTimeout(cb, 0)` — Safari users get
28
+ // the chunking benefit (one batch per task) without the idle-priority
29
+ // hint that other browsers honor.
30
+ const ric: typeof requestIdleCallback =
31
+ typeof requestIdleCallback === "function"
32
+ ? requestIdleCallback
33
+ : (cb) => setTimeout(() => cb({ timeRemaining: () => 0, didTimeout: true }), 0);
34
+
14
35
  export interface Signature<T = unknown> {
15
36
  Args: {
16
37
  /**
@@ -32,7 +53,7 @@ export interface Signature<T = unknown> {
32
53
  items: readonly T[];
33
54
 
34
55
  /**
35
- * How many items to add per idle callback.
56
+ * How many items to add per animation frame.
36
57
  *
37
58
  * Larger batches add more items per chunk; smaller batches yield to
38
59
  * the browser more often.
@@ -50,6 +71,60 @@ export interface Signature<T = unknown> {
50
71
  * ```
51
72
  */
52
73
  batchSize?: number;
74
+
75
+ /**
76
+ * Controls how the initial batch is committed.
77
+ *
78
+ * - `"sync"` (default): the first `@batchSize` items render in the
79
+ * same render pass as mount / `@items` change. The user sees
80
+ * content on the very first paint, and the rest of the list
81
+ * fills in one batch per animation frame. This is the right
82
+ * default for most lists — even a perceived "empty for one
83
+ * frame" is worse than rendering a few extra items synchronously.
84
+ * - `"lazy"`: even the first batch waits for an animation frame, so
85
+ * the initial paint is empty and content arrives one batch per
86
+ * frame. Use this when the first batch itself would be expensive
87
+ * enough to block the first paint, and you'd rather show an
88
+ * empty container than delay it.
89
+ *
90
+ * Default: `"sync"`.
91
+ *
92
+ * ```gjs
93
+ * import { IncrementalEach } from 'ember-primitives';
94
+ *
95
+ * <template>
96
+ * <IncrementalEach @items={{this.rows}} @initial="lazy" as |row|>
97
+ * <my-row @row={{row}} />
98
+ * </IncrementalEach>
99
+ * </template>
100
+ * ```
101
+ */
102
+ initial?: "sync" | "lazy";
103
+
104
+ /**
105
+ * Called once with no arguments when every item in `@items` has
106
+ * been committed to the DOM. Fires after the final batch lands;
107
+ * does not fire on intermediate batches.
108
+ *
109
+ * Fires again on a fresh swap (new `@items` identity) once that
110
+ * new collection finishes rendering. An empty `@items` array
111
+ * does not fire the callback.
112
+ *
113
+ * Useful for marking the list as ready for screenshot tests,
114
+ * dismissing a loading indicator, or measuring how long the
115
+ * whole render took.
116
+ *
117
+ * ```gjs
118
+ * import { IncrementalEach } from 'ember-primitives';
119
+ *
120
+ * <template>
121
+ * <IncrementalEach @items={{this.rows}} @onDone={{this.handleDone}} as |row|>
122
+ * <my-row @row={{row}} />
123
+ * </IncrementalEach>
124
+ * </template>
125
+ * ```
126
+ */
127
+ onDone?: () => void;
53
128
  };
54
129
  Blocks: {
55
130
  /**
@@ -71,18 +146,28 @@ export interface Signature<T = unknown> {
71
146
  }
72
147
 
73
148
  /**
74
- * A drop-in replacement for `{{#each}}` that renders a large collection a
75
- * batch at a time during the browser's idle periods, instead of all at once.
149
+ * A drop-in replacement for `{{#each}}` that renders a large collection
150
+ * a batch at a time on each animation frame, instead of all at once.
76
151
  *
77
152
  * Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor
78
153
  * links, screen readers, print, and SEO all work against the full list.
79
154
  * Yielding the main thread between batches keeps the page responsive while
80
155
  * the rest of the list is filling in.
81
156
  *
157
+ * By default the first batch lands synchronously, so the user sees content
158
+ * on the very first paint. Pass `@initial="lazy"` to defer the first batch
159
+ * to an animation frame as well.
160
+ *
82
161
  * Intended for non-scrollable containers, or anywhere a virtual/windowed
83
162
  * list does not apply (variable item heights, lists that grow the page,
84
163
  * surfaces that need every row indexable).
85
164
  *
165
+ * Do not nest one `<IncrementalEach>` inside another. Each level adds an
166
+ * animation-frame delay before its content paints; nesting compounds those
167
+ * delays, so inner rows appear to flicker in with missing sub-content.
168
+ * If you have nested loops, only the outermost one should be
169
+ * `<IncrementalEach>`; leave deeper loops as plain `{{#each}}`.
170
+ *
86
171
  * @example
87
172
  * ```gjs
88
173
  * import { IncrementalEach } from 'ember-primitives';
@@ -97,109 +182,120 @@ export interface Signature<T = unknown> {
97
182
  * ```
98
183
  */
99
184
  export class IncrementalEach<T = unknown> extends Component<Signature<T>> {
100
- // How many items have been committed to the DOM so far. Bumped one
101
- // batch at a time by the idle callback. Wrapped in a `cell` because
102
- // `@tracked` doesn't compose with `#`-private fields under this
103
- // codebase's decorator transform.
104
185
  #count = cell(0);
105
-
106
- // Plain field so identity checks don't add a render-time dependency
107
- // on top of `args.items`.
108
186
  #itemsRef: readonly T[] | null = null;
109
-
110
- #idleHandle: number | null = null;
111
-
112
187
  #waiterToken: unknown = null;
188
+ #doneFor: object | null = null;
113
189
 
114
190
  constructor(owner: Owner, args: Signature<T>["Args"]) {
115
191
  super(owner, args);
116
192
 
117
- registerDestructor(this, () => this.#cancel());
193
+ registerDestructor(this, () => this.#endWaiter());
118
194
  }
119
195
 
120
- get #batchSize(): number {
121
- const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;
122
-
123
- assert(
124
- `<IncrementalEach> @batchSize must be a positive number, got ${requested}`,
125
- requested > 0,
126
- );
127
-
128
- return requested;
129
- }
130
-
131
- // The items that should currently be rendered. `@cached` keeps the
132
- // returned slice stable across renders that don't change `#count` or
133
- // `args.items`, so Glimmer's `{{#each}}` doesn't see a fresh array on
134
- // every render and we only slice once per batch landing. This is
135
- // also where scheduling is driven from: `@items` identity changes
136
- // reset `#count` to zero, and missing items queue the next idle
137
- // callback. Autotrack stays consistent because the only synchronous
138
- // write here (`#count = 0`) happens before `#count` is read.
196
+ // Reset progress and (re)open the test-waiter when `@items` identity
197
+ // changes, so a swap restarts at the first batch, `@onDone` can fire
198
+ // again for the new collection, and `await settled()` knows to wait
199
+ // until `checkDone` closes the waiter. Mutating from a getter is safe
200
+ // here because the writes happen before any consumer reads them in
201
+ // the same render pass.
139
202
  /* eslint-disable ember/no-side-effects */
140
- get visible(): readonly T[] {
141
- const items = this.args.items ?? [];
203
+ get #items(): readonly T[] {
204
+ const items = this.args.items;
205
+
206
+ assert(`@items must be an array`, items);
142
207
 
143
208
  if (items !== this.#itemsRef) {
144
209
  this.#itemsRef = items;
145
- this.#cancel();
146
210
  this.#count.current = 0;
147
- }
211
+ this.#endWaiter();
148
212
 
149
- if (this.#count.current < items.length && this.#idleHandle === null) {
150
- this.#scheduleNextBatch();
213
+ if (items.length > 0) {
214
+ this.#waiterToken = waiter.beginAsync();
215
+ }
151
216
  }
152
217
 
153
- return items.slice(0, this.#count.current);
218
+ return items;
154
219
  }
155
220
  /* eslint-enable ember/no-side-effects */
156
221
 
157
- #scheduleNextBatch() {
158
- // Defensive: if a batch is already pending, drop it before
159
- // queueing a new one. The `visible` getter already guards on
160
- // `#idleHandle === null`, but a future caller might not.
161
- this.#cancel();
222
+ // `"sync"` keeps bucket 0 visible at count=0 (`i = 0 >= 0`); `"lazy"`
223
+ // starts one step behind so even bucket 0 needs a tick.
224
+ get #start() {
225
+ return this.#initial === "sync" ? 0 : -1;
226
+ }
227
+
228
+ get i() {
229
+ return this.#start + this.#count.current;
230
+ }
231
+
232
+ @cached
233
+ get bucketed() {
234
+ const size = this.#batchSize;
235
+
236
+ return chunk(this.#items, size).map((items, b) => {
237
+ const start = b * size;
162
238
 
163
- this.#waiterToken = waiter.beginAsync();
239
+ return {
240
+ isReady: () => this.i >= b,
241
+ items: items.map((value, j) => ({ value, index: start + j })),
242
+ };
243
+ });
244
+ }
164
245
 
165
- // The `timeout` cap ensures forward progress even when the host
166
- // is CPU-bound and the browser never reports a free idle slot.
167
- // In normal use this is a no-op because real idle time arrives
168
- // far sooner.
169
- this.#idleHandle = requestIdleCallback(
170
- () => {
171
- this.#idleHandle = null;
246
+ get #batchSize(): number {
247
+ const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;
248
+
249
+ assert(
250
+ `<IncrementalEach> @batchSize must be a positive number, got ${requested}`,
251
+ requested > 0,
252
+ );
172
253
 
173
- if (isDestroyed(this) || isDestroying(this)) return;
254
+ return requested;
255
+ }
174
256
 
175
- const items = this.args.items ?? [];
257
+ get #initial(): "sync" | "lazy" {
258
+ const requested = this.args.initial ?? DEFAULT_INITIAL;
176
259
 
177
- this.#count.current = Math.min(this.#count.current + this.#batchSize, items.length);
178
- this.#endWaiter();
179
- },
180
- { timeout: 100 },
260
+ assert(
261
+ `<IncrementalEach> @initial must be "sync" or "lazy", got ${requested}`,
262
+ requested === "sync" || requested === "lazy",
181
263
  );
264
+
265
+ return requested;
182
266
  }
183
267
 
184
- #cancel() {
185
- if (this.#idleHandle !== null) {
186
- cancelIdleCallback(this.#idleHandle);
187
- this.#idleHandle = null;
268
+ // `#items` is read before `#count` so the count-reset inside `#items`
269
+ // (on `@items` swap) lands before this read of count this render —
270
+ // otherwise tracked-value backtracking asserts.
271
+ tick = () => {
272
+ if (this.#items.length > this.#count.current) {
273
+ ric(() => this.#count.current++, { timeout: 10 });
188
274
  }
275
+ };
189
276
 
190
- this.#endWaiter();
191
- }
277
+ checkDone = () => {
278
+ const bucketed = this.bucketed;
279
+
280
+ if (this.#doneFor === bucketed) return;
281
+ if (this.i < bucketed.length - 1) return;
282
+
283
+ this.#doneFor = bucketed;
284
+ queueMicrotask(() => {
285
+ if (isDestroyed(this) || isDestroying(this)) return;
286
+ this.args.onDone?.();
287
+ this.#endWaiter();
288
+ });
289
+ };
192
290
 
193
291
  #endWaiter() {
194
- if (this.#waiterToken !== null) {
195
- waiter.endAsync(this.#waiterToken);
196
- this.#waiterToken = null;
197
- }
292
+ if (this.#waiterToken) waiter.endAsync(this.#waiterToken);
198
293
  }
199
294
 
200
295
  <template>
201
- {{#each this.visible as |item index|}}
202
- {{yield item index}}
203
- {{/each}}
296
+ {{(this.tick)}}{{#each this.bucketed as |bucket|}}{{#if (bucket.isReady)}}{{#each
297
+ bucket.items
298
+ as |entry|
299
+ }}{{yield entry.value entry.index}}{{/each}}{{(this.checkDone)}}{{/if}}{{/each}}
204
300
  </template>
205
301
  }