ember-primitives 0.58.0 → 0.59.0
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.
- package/declarations/components/incremental-each.d.ts +75 -4
- package/declarations/components/incremental-each.d.ts.map +1 -1
- package/dist/components/incremental-each.js +92 -60
- package/dist/components/incremental-each.js.map +1 -1
- package/package.json +1 -1
- package/src/components/incremental-each.gts +159 -72
|
@@ -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
|
|
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
|
|
62
|
-
* batch at a time
|
|
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
|
|
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":"
|
|
1
|
+
{"version":3,"file":"incremental-each.d.ts","sourceRoot":"","sources":["../../src/components/incremental-each.gts"],"names":[],"mappings":"AAoSA,OAAO,SAAS,MAAM,oBAAoB,CAAC;AAQ3C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAiBtC,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,46 @@
|
|
|
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
|
+
}
|
|
11
21
|
/**
|
|
12
|
-
* A drop-in replacement for `{{#each}}` that renders a large collection
|
|
13
|
-
* batch at a time
|
|
22
|
+
* A drop-in replacement for `{{#each}}` that renders a large collection
|
|
23
|
+
* a batch at a time on each animation frame, instead of all at once.
|
|
14
24
|
*
|
|
15
25
|
* Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor
|
|
16
26
|
* links, screen readers, print, and SEO all work against the full list.
|
|
17
27
|
* Yielding the main thread between batches keeps the page responsive while
|
|
18
28
|
* the rest of the list is filling in.
|
|
19
29
|
*
|
|
30
|
+
* By default the first batch lands synchronously, so the user sees content
|
|
31
|
+
* on the very first paint. Pass `@initial="lazy"` to defer the first batch
|
|
32
|
+
* to an animation frame as well.
|
|
33
|
+
*
|
|
20
34
|
* Intended for non-scrollable containers, or anywhere a virtual/windowed
|
|
21
35
|
* list does not apply (variable item heights, lists that grow the page,
|
|
22
36
|
* surfaces that need every row indexable).
|
|
23
37
|
*
|
|
38
|
+
* Do not nest one `<IncrementalEach>` inside another. Each level adds an
|
|
39
|
+
* animation-frame delay before its content paints; nesting compounds those
|
|
40
|
+
* delays, so inner rows appear to flicker in with missing sub-content.
|
|
41
|
+
* If you have nested loops, only the outermost one should be
|
|
42
|
+
* `<IncrementalEach>`; leave deeper loops as plain `{{#each}}`.
|
|
43
|
+
*
|
|
24
44
|
* @example
|
|
25
45
|
* ```gjs
|
|
26
46
|
* import { IncrementalEach } from 'ember-primitives';
|
|
@@ -35,82 +55,94 @@ const waiter = buildWaiter("ember-primitives:incremental-each");
|
|
|
35
55
|
* ```
|
|
36
56
|
*/
|
|
37
57
|
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
58
|
#count = cell(0);
|
|
43
|
-
// Plain field so identity checks don't add a render-time dependency
|
|
44
|
-
// on top of `args.items`.
|
|
45
59
|
#itemsRef = null;
|
|
46
|
-
#idleHandle = null;
|
|
47
60
|
#waiterToken = null;
|
|
61
|
+
#doneFor = null;
|
|
48
62
|
constructor(owner, args) {
|
|
49
63
|
super(owner, args);
|
|
50
|
-
registerDestructor(this, () => this.#
|
|
64
|
+
registerDestructor(this, () => this.#endWaiter());
|
|
51
65
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
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.
|
|
66
|
+
// Reset progress and (re)open the test-waiter when `@items` identity
|
|
67
|
+
// changes, so a swap restarts at the first batch, `@onDone` can fire
|
|
68
|
+
// again for the new collection, and `await settled()` knows to wait
|
|
69
|
+
// until `checkDone` closes the waiter. Mutating from a getter is safe
|
|
70
|
+
// here because the writes happen before any consumer reads them in
|
|
71
|
+
// the same render pass.
|
|
65
72
|
/* eslint-disable ember/no-side-effects */
|
|
66
|
-
get
|
|
67
|
-
const items = this.args.items
|
|
73
|
+
get #items() {
|
|
74
|
+
const items = this.args.items;
|
|
75
|
+
assert(`@items must be an array`, items);
|
|
68
76
|
if (items !== this.#itemsRef) {
|
|
69
77
|
this.#itemsRef = items;
|
|
70
|
-
this.#cancel();
|
|
71
78
|
this.#count.current = 0;
|
|
79
|
+
this.#endWaiter();
|
|
80
|
+
if (items.length > 0) {
|
|
81
|
+
this.#waiterToken = waiter.beginAsync();
|
|
82
|
+
}
|
|
72
83
|
}
|
|
73
|
-
|
|
74
|
-
this.#scheduleNextBatch();
|
|
75
|
-
}
|
|
76
|
-
return items.slice(0, this.#count.current);
|
|
84
|
+
return items;
|
|
77
85
|
}
|
|
78
|
-
/* eslint-enable ember/no-side-effects */
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this.#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
/* eslint-enable ember/no-side-effects */ // `"sync"` keeps bucket 0 visible at count=0 (`i = 0 >= 0`); `"lazy"`
|
|
87
|
+
// starts one step behind so even bucket 0 needs a tick.
|
|
88
|
+
get #start() {
|
|
89
|
+
return this.#initial === "sync" ? 0 : -1;
|
|
90
|
+
}
|
|
91
|
+
get i() {
|
|
92
|
+
return this.#start + this.#count.current;
|
|
93
|
+
}
|
|
94
|
+
get bucketed() {
|
|
95
|
+
const size = this.#batchSize;
|
|
96
|
+
return chunk(this.#items, size).map((items, b) => {
|
|
97
|
+
const start = b * size;
|
|
98
|
+
return {
|
|
99
|
+
isReady: () => this.i >= b,
|
|
100
|
+
items: items.map((value, j) => ({
|
|
101
|
+
value,
|
|
102
|
+
index: start + j
|
|
103
|
+
}))
|
|
104
|
+
};
|
|
97
105
|
});
|
|
98
106
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
cancelIdleCallback(this.#idleHandle);
|
|
102
|
-
this.#idleHandle = null;
|
|
103
|
-
}
|
|
104
|
-
this.#endWaiter();
|
|
107
|
+
static {
|
|
108
|
+
n(this.prototype, "bucketed", [cached]);
|
|
105
109
|
}
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
get #batchSize() {
|
|
111
|
+
const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
112
|
+
assert(`<IncrementalEach> @batchSize must be a positive number, got ${requested}`, requested > 0);
|
|
113
|
+
return requested;
|
|
114
|
+
}
|
|
115
|
+
get #initial() {
|
|
116
|
+
const requested = this.args.initial ?? DEFAULT_INITIAL;
|
|
117
|
+
assert(`<IncrementalEach> @initial must be "sync" or "lazy", got ${requested}`, requested === "sync" || requested === "lazy");
|
|
118
|
+
return requested;
|
|
119
|
+
}
|
|
120
|
+
// `#items` is read before `#count` so the count-reset inside `#items`
|
|
121
|
+
// (on `@items` swap) lands before this read of count this render —
|
|
122
|
+
// otherwise tracked-value backtracking asserts.
|
|
123
|
+
tick = () => {
|
|
124
|
+
if (this.#items.length > this.#count.current) {
|
|
125
|
+
requestIdleCallback(() => this.#count.current++, {
|
|
126
|
+
timeout: 10
|
|
127
|
+
});
|
|
110
128
|
}
|
|
129
|
+
};
|
|
130
|
+
checkDone = () => {
|
|
131
|
+
const bucketed = this.bucketed;
|
|
132
|
+
if (this.#doneFor === bucketed) return;
|
|
133
|
+
if (this.i < bucketed.length - 1) return;
|
|
134
|
+
this.#doneFor = bucketed;
|
|
135
|
+
queueMicrotask(() => {
|
|
136
|
+
if (isDestroyed(this) || isDestroying(this)) return;
|
|
137
|
+
this.args.onDone?.();
|
|
138
|
+
this.#endWaiter();
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
#endWaiter() {
|
|
142
|
+
if (this.#waiterToken) waiter.endAsync(this.#waiterToken);
|
|
111
143
|
}
|
|
112
144
|
static {
|
|
113
|
-
setComponentTemplate(precompileTemplate("{{#each this.
|
|
145
|
+
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
146
|
strictMode: true
|
|
115
147
|
}), this);
|
|
116
148
|
}
|
|
@@ -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\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 requestIdleCallback(() => 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","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","requestIdleCallback","timeout","checkDone","queueMicrotask","isDestroyed","isDestroying","onDone","#endWaiter","endAsync","setComponentTemplate","precompileTemplate","strictMode"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -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,20 @@ 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
|
+
|
|
14
26
|
export interface Signature<T = unknown> {
|
|
15
27
|
Args: {
|
|
16
28
|
/**
|
|
@@ -32,7 +44,7 @@ export interface Signature<T = unknown> {
|
|
|
32
44
|
items: readonly T[];
|
|
33
45
|
|
|
34
46
|
/**
|
|
35
|
-
* How many items to add per
|
|
47
|
+
* How many items to add per animation frame.
|
|
36
48
|
*
|
|
37
49
|
* Larger batches add more items per chunk; smaller batches yield to
|
|
38
50
|
* the browser more often.
|
|
@@ -50,6 +62,60 @@ export interface Signature<T = unknown> {
|
|
|
50
62
|
* ```
|
|
51
63
|
*/
|
|
52
64
|
batchSize?: number;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Controls how the initial batch is committed.
|
|
68
|
+
*
|
|
69
|
+
* - `"sync"` (default): the first `@batchSize` items render in the
|
|
70
|
+
* same render pass as mount / `@items` change. The user sees
|
|
71
|
+
* content on the very first paint, and the rest of the list
|
|
72
|
+
* fills in one batch per animation frame. This is the right
|
|
73
|
+
* default for most lists — even a perceived "empty for one
|
|
74
|
+
* frame" is worse than rendering a few extra items synchronously.
|
|
75
|
+
* - `"lazy"`: even the first batch waits for an animation frame, so
|
|
76
|
+
* the initial paint is empty and content arrives one batch per
|
|
77
|
+
* frame. Use this when the first batch itself would be expensive
|
|
78
|
+
* enough to block the first paint, and you'd rather show an
|
|
79
|
+
* empty container than delay it.
|
|
80
|
+
*
|
|
81
|
+
* Default: `"sync"`.
|
|
82
|
+
*
|
|
83
|
+
* ```gjs
|
|
84
|
+
* import { IncrementalEach } from 'ember-primitives';
|
|
85
|
+
*
|
|
86
|
+
* <template>
|
|
87
|
+
* <IncrementalEach @items={{this.rows}} @initial="lazy" as |row|>
|
|
88
|
+
* <my-row @row={{row}} />
|
|
89
|
+
* </IncrementalEach>
|
|
90
|
+
* </template>
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
initial?: "sync" | "lazy";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Called once with no arguments when every item in `@items` has
|
|
97
|
+
* been committed to the DOM. Fires after the final batch lands;
|
|
98
|
+
* does not fire on intermediate batches.
|
|
99
|
+
*
|
|
100
|
+
* Fires again on a fresh swap (new `@items` identity) once that
|
|
101
|
+
* new collection finishes rendering. An empty `@items` array
|
|
102
|
+
* does not fire the callback.
|
|
103
|
+
*
|
|
104
|
+
* Useful for marking the list as ready for screenshot tests,
|
|
105
|
+
* dismissing a loading indicator, or measuring how long the
|
|
106
|
+
* whole render took.
|
|
107
|
+
*
|
|
108
|
+
* ```gjs
|
|
109
|
+
* import { IncrementalEach } from 'ember-primitives';
|
|
110
|
+
*
|
|
111
|
+
* <template>
|
|
112
|
+
* <IncrementalEach @items={{this.rows}} @onDone={{this.handleDone}} as |row|>
|
|
113
|
+
* <my-row @row={{row}} />
|
|
114
|
+
* </IncrementalEach>
|
|
115
|
+
* </template>
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
onDone?: () => void;
|
|
53
119
|
};
|
|
54
120
|
Blocks: {
|
|
55
121
|
/**
|
|
@@ -71,18 +137,28 @@ export interface Signature<T = unknown> {
|
|
|
71
137
|
}
|
|
72
138
|
|
|
73
139
|
/**
|
|
74
|
-
* A drop-in replacement for `{{#each}}` that renders a large collection
|
|
75
|
-
* batch at a time
|
|
140
|
+
* A drop-in replacement for `{{#each}}` that renders a large collection
|
|
141
|
+
* a batch at a time on each animation frame, instead of all at once.
|
|
76
142
|
*
|
|
77
143
|
* Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor
|
|
78
144
|
* links, screen readers, print, and SEO all work against the full list.
|
|
79
145
|
* Yielding the main thread between batches keeps the page responsive while
|
|
80
146
|
* the rest of the list is filling in.
|
|
81
147
|
*
|
|
148
|
+
* By default the first batch lands synchronously, so the user sees content
|
|
149
|
+
* on the very first paint. Pass `@initial="lazy"` to defer the first batch
|
|
150
|
+
* to an animation frame as well.
|
|
151
|
+
*
|
|
82
152
|
* Intended for non-scrollable containers, or anywhere a virtual/windowed
|
|
83
153
|
* list does not apply (variable item heights, lists that grow the page,
|
|
84
154
|
* surfaces that need every row indexable).
|
|
85
155
|
*
|
|
156
|
+
* Do not nest one `<IncrementalEach>` inside another. Each level adds an
|
|
157
|
+
* animation-frame delay before its content paints; nesting compounds those
|
|
158
|
+
* delays, so inner rows appear to flicker in with missing sub-content.
|
|
159
|
+
* If you have nested loops, only the outermost one should be
|
|
160
|
+
* `<IncrementalEach>`; leave deeper loops as plain `{{#each}}`.
|
|
161
|
+
*
|
|
86
162
|
* @example
|
|
87
163
|
* ```gjs
|
|
88
164
|
* import { IncrementalEach } from 'ember-primitives';
|
|
@@ -97,109 +173,120 @@ export interface Signature<T = unknown> {
|
|
|
97
173
|
* ```
|
|
98
174
|
*/
|
|
99
175
|
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
176
|
#count = cell(0);
|
|
105
|
-
|
|
106
|
-
// Plain field so identity checks don't add a render-time dependency
|
|
107
|
-
// on top of `args.items`.
|
|
108
177
|
#itemsRef: readonly T[] | null = null;
|
|
109
|
-
|
|
110
|
-
#idleHandle: number | null = null;
|
|
111
|
-
|
|
112
178
|
#waiterToken: unknown = null;
|
|
179
|
+
#doneFor: object | null = null;
|
|
113
180
|
|
|
114
181
|
constructor(owner: Owner, args: Signature<T>["Args"]) {
|
|
115
182
|
super(owner, args);
|
|
116
183
|
|
|
117
|
-
registerDestructor(this, () => this.#
|
|
184
|
+
registerDestructor(this, () => this.#endWaiter());
|
|
118
185
|
}
|
|
119
186
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
187
|
+
// Reset progress and (re)open the test-waiter when `@items` identity
|
|
188
|
+
// changes, so a swap restarts at the first batch, `@onDone` can fire
|
|
189
|
+
// again for the new collection, and `await settled()` knows to wait
|
|
190
|
+
// until `checkDone` closes the waiter. Mutating from a getter is safe
|
|
191
|
+
// here because the writes happen before any consumer reads them in
|
|
192
|
+
// the same render pass.
|
|
139
193
|
/* eslint-disable ember/no-side-effects */
|
|
140
|
-
get
|
|
141
|
-
const items = this.args.items
|
|
194
|
+
get #items(): readonly T[] {
|
|
195
|
+
const items = this.args.items;
|
|
196
|
+
|
|
197
|
+
assert(`@items must be an array`, items);
|
|
142
198
|
|
|
143
199
|
if (items !== this.#itemsRef) {
|
|
144
200
|
this.#itemsRef = items;
|
|
145
|
-
this.#cancel();
|
|
146
201
|
this.#count.current = 0;
|
|
147
|
-
|
|
202
|
+
this.#endWaiter();
|
|
148
203
|
|
|
149
|
-
|
|
150
|
-
|
|
204
|
+
if (items.length > 0) {
|
|
205
|
+
this.#waiterToken = waiter.beginAsync();
|
|
206
|
+
}
|
|
151
207
|
}
|
|
152
208
|
|
|
153
|
-
return items
|
|
209
|
+
return items;
|
|
154
210
|
}
|
|
155
211
|
/* eslint-enable ember/no-side-effects */
|
|
156
212
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
213
|
+
// `"sync"` keeps bucket 0 visible at count=0 (`i = 0 >= 0`); `"lazy"`
|
|
214
|
+
// starts one step behind so even bucket 0 needs a tick.
|
|
215
|
+
get #start() {
|
|
216
|
+
return this.#initial === "sync" ? 0 : -1;
|
|
217
|
+
}
|
|
162
218
|
|
|
163
|
-
|
|
219
|
+
get i() {
|
|
220
|
+
return this.#start + this.#count.current;
|
|
221
|
+
}
|
|
164
222
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// far sooner.
|
|
169
|
-
this.#idleHandle = requestIdleCallback(
|
|
170
|
-
() => {
|
|
171
|
-
this.#idleHandle = null;
|
|
223
|
+
@cached
|
|
224
|
+
get bucketed() {
|
|
225
|
+
const size = this.#batchSize;
|
|
172
226
|
|
|
173
|
-
|
|
227
|
+
return chunk(this.#items, size).map((items, b) => {
|
|
228
|
+
const start = b * size;
|
|
174
229
|
|
|
175
|
-
|
|
230
|
+
return {
|
|
231
|
+
isReady: () => this.i >= b,
|
|
232
|
+
items: items.map((value, j) => ({ value, index: start + j })),
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
get #batchSize(): number {
|
|
238
|
+
const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
176
239
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
{ timeout: 100 },
|
|
240
|
+
assert(
|
|
241
|
+
`<IncrementalEach> @batchSize must be a positive number, got ${requested}`,
|
|
242
|
+
requested > 0,
|
|
181
243
|
);
|
|
244
|
+
|
|
245
|
+
return requested;
|
|
182
246
|
}
|
|
183
247
|
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
248
|
+
get #initial(): "sync" | "lazy" {
|
|
249
|
+
const requested = this.args.initial ?? DEFAULT_INITIAL;
|
|
250
|
+
|
|
251
|
+
assert(
|
|
252
|
+
`<IncrementalEach> @initial must be "sync" or "lazy", got ${requested}`,
|
|
253
|
+
requested === "sync" || requested === "lazy",
|
|
254
|
+
);
|
|
189
255
|
|
|
190
|
-
|
|
256
|
+
return requested;
|
|
191
257
|
}
|
|
192
258
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
259
|
+
// `#items` is read before `#count` so the count-reset inside `#items`
|
|
260
|
+
// (on `@items` swap) lands before this read of count this render —
|
|
261
|
+
// otherwise tracked-value backtracking asserts.
|
|
262
|
+
tick = () => {
|
|
263
|
+
if (this.#items.length > this.#count.current) {
|
|
264
|
+
requestIdleCallback(() => this.#count.current++, { timeout: 10 });
|
|
197
265
|
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
checkDone = () => {
|
|
269
|
+
const bucketed = this.bucketed;
|
|
270
|
+
|
|
271
|
+
if (this.#doneFor === bucketed) return;
|
|
272
|
+
if (this.i < bucketed.length - 1) return;
|
|
273
|
+
|
|
274
|
+
this.#doneFor = bucketed;
|
|
275
|
+
queueMicrotask(() => {
|
|
276
|
+
if (isDestroyed(this) || isDestroying(this)) return;
|
|
277
|
+
this.args.onDone?.();
|
|
278
|
+
this.#endWaiter();
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
#endWaiter() {
|
|
283
|
+
if (this.#waiterToken) waiter.endAsync(this.#waiterToken);
|
|
198
284
|
}
|
|
199
285
|
|
|
200
286
|
<template>
|
|
201
|
-
{{#each this.
|
|
202
|
-
|
|
203
|
-
|
|
287
|
+
{{(this.tick)}}{{#each this.bucketed as |bucket|}}{{#if (bucket.isReady)}}{{#each
|
|
288
|
+
bucket.items
|
|
289
|
+
as |entry|
|
|
290
|
+
}}{{yield entry.value entry.index}}{{/each}}{{(this.checkDone)}}{{/if}}{{/each}}
|
|
204
291
|
</template>
|
|
205
292
|
}
|