ripple 0.4.1 → 0.4.3
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/CHANGELOG.md +162 -0
- package/package.json +9 -3
- package/src/constants.js +4 -0
- package/src/jsx-runtime.d.ts +2 -1
- package/src/runtime/array.js +15 -9
- package/src/runtime/index-client.js +25 -7
- package/src/runtime/index-server.js +1 -0
- package/src/runtime/internal/client/attributes.js +313 -0
- package/src/runtime/internal/client/blocks.js +4 -14
- package/src/runtime/internal/client/component.js +2 -5
- package/src/runtime/internal/client/composite.js +14 -6
- package/src/runtime/internal/client/constants.js +12 -6
- package/src/runtime/internal/client/context.js +3 -2
- package/src/runtime/internal/client/css.js +11 -26
- package/src/runtime/internal/client/errors.js +188 -0
- package/src/runtime/internal/client/events.js +79 -34
- package/src/runtime/internal/client/expression.js +21 -116
- package/src/runtime/internal/client/for.js +272 -222
- package/src/runtime/internal/client/head.js +4 -3
- package/src/runtime/internal/client/html.js +5 -3
- package/src/runtime/internal/client/hydrate.js +484 -0
- package/src/runtime/internal/client/hydration-enabled.js +7 -0
- package/src/runtime/internal/client/hydration.js +16 -97
- package/src/runtime/internal/client/if.js +13 -9
- package/src/runtime/internal/client/index.js +16 -8
- package/src/runtime/internal/client/operations.js +12 -9
- package/src/runtime/internal/client/portal.js +3 -2
- package/src/runtime/internal/client/render.js +75 -321
- package/src/runtime/internal/client/root-boundary-enabled.js +7 -0
- package/src/runtime/internal/client/runtime.js +129 -362
- package/src/runtime/internal/client/template-ns.js +47 -0
- package/src/runtime/internal/client/template.js +63 -140
- package/src/runtime/internal/client/track-async.js +356 -0
- package/src/runtime/internal/client/try.js +133 -319
- package/src/runtime/internal/client/types.d.ts +48 -24
- package/src/runtime/internal/server/index.js +38 -33
- package/src/runtime/internal/server/prerender.js +27 -0
- package/src/server/index.js +1 -0
- package/src/utils/class-name.js +28 -0
- package/tests/client/basic/basic.errors.test.tsrx +3 -3
- package/tests/client/compiler/__snapshots__/compiler.typescript.test.tsrx.snap +0 -2
- package/tests/client/compiler/compiler.basic.test.tsrx +4 -4
- package/tests/client/compiler/compiler.tracked-access.test.tsrx +3 -3
- package/tests/client/composite/composite.props.test.tsrx +10 -10
- package/tests/client/derived-release.test.tsrx +3 -3
- package/tests/client/for-item.test.tsrx +443 -0
- package/tests/client/for-selector.test.tsrx +95 -0
- package/tests/hydration/compiled/client/basic.js +97 -123
- package/tests/hydration/compiled/client/composite.js +5 -5
- package/tests/hydration/compiled/client/events.js +84 -79
- package/tests/hydration/compiled/client/for.js +229 -233
- package/tests/hydration/compiled/client/fragment-cursor.js +781 -481
- package/tests/hydration/compiled/client/head.js +23 -26
- package/tests/hydration/compiled/client/hmr.js +4 -4
- package/tests/hydration/compiled/client/html-in-template.js +5 -5
- package/tests/hydration/compiled/client/html.js +174 -208
- package/tests/hydration/compiled/client/if-children.js +71 -70
- package/tests/hydration/compiled/client/if-fragment-controlflow.js +46 -46
- package/tests/hydration/compiled/client/if.js +26 -26
- package/tests/hydration/compiled/client/mixed-control-flow.js +63 -105
- package/tests/hydration/compiled/client/nested-control-flow.js +170 -390
- package/tests/hydration/compiled/client/portal.js +10 -10
- package/tests/hydration/compiled/client/reactivity.js +36 -50
- package/tests/hydration/compiled/client/return.js +2 -2
- package/tests/hydration/compiled/client/streaming.js +34 -40
- package/tests/hydration/compiled/client/switch.js +25 -25
- package/tests/hydration/compiled/client/track-async-serialization.js +41 -41
- package/tests/hydration/compiled/client/try.js +15 -15
- package/tests/hydration/compiled/client/typed-text.js +8 -19
- package/tests/hydration/compiled/server/basic.js +6 -6
- package/tests/hydration/compiled/server/events.js +13 -61
- package/tests/hydration/compiled/server/for.js +23 -31
- package/tests/hydration/compiled/server/fragment-cursor.js +214 -366
- package/tests/hydration/compiled/server/head.js +6 -6
- package/tests/hydration/compiled/server/hmr.js +1 -1
- package/tests/hydration/compiled/server/html.js +1 -1
- package/tests/hydration/compiled/server/if-children.js +9 -9
- package/tests/hydration/compiled/server/if.js +5 -5
- package/tests/hydration/compiled/server/mixed-control-flow.js +4 -4
- package/tests/hydration/compiled/server/portal.js +1 -1
- package/tests/hydration/compiled/server/reactivity.js +9 -9
- package/tests/hydration/compiled/server/streaming.js +9 -9
- package/tests/hydration/compiled/server/switch.js +4 -4
- package/tests/hydration/compiled/server/track-async-serialization.js +16 -16
- package/tests/hydration/compiled/server/try.js +4 -4
- package/tests/hydration/compiled/server/typed-text.js +1 -1
- package/tests/hydration/components/fragment-cursor.tsrx +60 -0
- package/tests/hydration/components/track-async-serialization.tsrx +2 -2
- package/tests/hydration/fragment-cursor.test.js +34 -1
- package/tests/hydration/track-async-serialization.test.js +1 -1
- package/tests/server/basic.components.test.tsrx +3 -3
- package/tests/server/basic.test.tsrx +23 -0
- package/tests/server/track-async-serialization.test.tsrx +6 -6
- package/tests/utils/tracked-types.test.js +3 -3
- package/types/index.d.ts +8 -8
- package/types/server.d.ts +17 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { flushSync, track, type Tracked } from 'ripple';
|
|
3
|
+
|
|
4
|
+
interface Cell {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Row {
|
|
10
|
+
id: number;
|
|
11
|
+
label: string;
|
|
12
|
+
active: boolean;
|
|
13
|
+
cells: Cell[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const cells = (prefix: string) => [
|
|
17
|
+
{ id: 'a', text: `${prefix}a` },
|
|
18
|
+
{ id: 'b', text: `${prefix}b` },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// An item body whose updates live in one render block carries that block on
|
|
22
|
+
// the item block (`_$_.item`): the item re-runs its update function alone,
|
|
23
|
+
// and the nested blocks of its body (a keyed list of cells here) stay.
|
|
24
|
+
describe('list items carrying their render block', () => {
|
|
25
|
+
it('re-renders an item replaced under the same key and keeps its nested list', () => {
|
|
26
|
+
let set: (rows: Row[]) => void = () => {};
|
|
27
|
+
|
|
28
|
+
function App() @{
|
|
29
|
+
const rows = track<Row[]>([
|
|
30
|
+
{ id: 1, label: 'one', active: false, cells: cells('1') },
|
|
31
|
+
{ id: 2, label: 'two', active: false, cells: cells('2') },
|
|
32
|
+
]);
|
|
33
|
+
set = (next) => {
|
|
34
|
+
rows.value = next;
|
|
35
|
+
};
|
|
36
|
+
<table>
|
|
37
|
+
<tbody>
|
|
38
|
+
@for (const row of rows.value; key row.id) {
|
|
39
|
+
<tr class={row.active ? 'active' : 'inactive'}>
|
|
40
|
+
<th>{row.label}</th>
|
|
41
|
+
@for (const cell of row.cells; key cell.id) {
|
|
42
|
+
<td>{cell.text}</td>
|
|
43
|
+
}
|
|
44
|
+
</tr>
|
|
45
|
+
}
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
render(App);
|
|
51
|
+
flushSync();
|
|
52
|
+
|
|
53
|
+
const rows = () => Array.from(container.querySelectorAll('tr'));
|
|
54
|
+
const text = () => rows().map((tr) => `${tr.className}:${tr.textContent}`);
|
|
55
|
+
expect(text()).toEqual(['inactive:one1a1b', 'inactive:two2a2b']);
|
|
56
|
+
|
|
57
|
+
const [first, second] = rows();
|
|
58
|
+
const first_cells = Array.from(first.querySelectorAll('td'));
|
|
59
|
+
|
|
60
|
+
// The first row object is replaced under its key; its cell objects are
|
|
61
|
+
// kept, so the nested list has nothing to do.
|
|
62
|
+
const original = [
|
|
63
|
+
{ id: 1, label: 'one', active: false, cells: cells('1') },
|
|
64
|
+
{ id: 2, label: 'two', active: false, cells: cells('2') },
|
|
65
|
+
];
|
|
66
|
+
set(original);
|
|
67
|
+
flushSync();
|
|
68
|
+
set([{ ...original[0], label: 'uno', active: true }, original[1]]);
|
|
69
|
+
flushSync();
|
|
70
|
+
|
|
71
|
+
expect(text()).toEqual(['active:uno1a1b', 'inactive:two2a2b']);
|
|
72
|
+
expect(rows()[0]).toBe(first);
|
|
73
|
+
expect(rows()[1]).toBe(second);
|
|
74
|
+
expect(Array.from(first.querySelectorAll('td'))).toEqual(first_cells);
|
|
75
|
+
|
|
76
|
+
// A new cell array replaces the cells; the row's own DOM is kept.
|
|
77
|
+
set([
|
|
78
|
+
{
|
|
79
|
+
...original[0],
|
|
80
|
+
cells: [
|
|
81
|
+
{ id: 'a', text: 'A' },
|
|
82
|
+
{ id: 'c', text: 'C' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
original[1],
|
|
86
|
+
]);
|
|
87
|
+
flushSync();
|
|
88
|
+
expect(text()).toEqual(['inactive:oneAC', 'inactive:two2a2b']);
|
|
89
|
+
expect(rows()[0]).toBe(first);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('updates a plain (unkeyed) item body through the item block', () => {
|
|
93
|
+
let set: (items: { text: string }[]) => void = () => {};
|
|
94
|
+
|
|
95
|
+
function App() @{
|
|
96
|
+
const items = track([{ text: 'a' }, { text: 'b' }]);
|
|
97
|
+
set = (next) => {
|
|
98
|
+
items.value = next;
|
|
99
|
+
};
|
|
100
|
+
<div>
|
|
101
|
+
@for (const item of items.value) {
|
|
102
|
+
<span>{item.text}</span>
|
|
103
|
+
}
|
|
104
|
+
</div>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
render(App);
|
|
108
|
+
flushSync();
|
|
109
|
+
expect(container.querySelector('div')?.textContent).toBe('ab');
|
|
110
|
+
|
|
111
|
+
set([{ text: 'a' }, { text: 'c' }, { text: 'd' }]);
|
|
112
|
+
flushSync();
|
|
113
|
+
expect(container.querySelector('div')?.textContent).toBe('acd');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('destroys an item that re-ran its update function', () => {
|
|
117
|
+
let set: (items: { id: number; text: string }[]) => void = () => {};
|
|
118
|
+
|
|
119
|
+
function App() @{
|
|
120
|
+
const items = track([
|
|
121
|
+
{ id: 1, text: 'a' },
|
|
122
|
+
{ id: 2, text: 'b' },
|
|
123
|
+
]);
|
|
124
|
+
set = (next) => {
|
|
125
|
+
items.value = next;
|
|
126
|
+
};
|
|
127
|
+
<div>
|
|
128
|
+
@for (const item of items.value; key item.id) {
|
|
129
|
+
<span>{item.text}</span>
|
|
130
|
+
}
|
|
131
|
+
</div>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render(App);
|
|
135
|
+
flushSync();
|
|
136
|
+
|
|
137
|
+
set([
|
|
138
|
+
{ id: 1, text: 'A' },
|
|
139
|
+
{ id: 2, text: 'B' },
|
|
140
|
+
]);
|
|
141
|
+
flushSync();
|
|
142
|
+
expect(container.querySelector('div')?.textContent).toBe('AB');
|
|
143
|
+
|
|
144
|
+
set([
|
|
145
|
+
{ id: 2, text: 'B' },
|
|
146
|
+
]);
|
|
147
|
+
flushSync();
|
|
148
|
+
expect(container.querySelector('div')?.textContent).toBe('B');
|
|
149
|
+
expect(container.querySelectorAll('span').length).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// A keyed list that re-lays most of its range walks it front to back,
|
|
154
|
+
// inserting each item before the first node not yet in place.
|
|
155
|
+
describe('keyed list re-lay', () => {
|
|
156
|
+
const ids = (n: number) => Array.from({ length: n }, (_, i) => i);
|
|
157
|
+
|
|
158
|
+
it('reverses the middle after dropping both ends (kivi worst case)', () => {
|
|
159
|
+
let set: (items: number[]) => void = () => {};
|
|
160
|
+
|
|
161
|
+
function App() @{
|
|
162
|
+
const items = track(ids(40));
|
|
163
|
+
set = (next) => {
|
|
164
|
+
items.value = next;
|
|
165
|
+
};
|
|
166
|
+
<div>
|
|
167
|
+
@for (const item of items.value; key item) {
|
|
168
|
+
<span data-id={item}>{item}</span>
|
|
169
|
+
}
|
|
170
|
+
</div>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
render(App);
|
|
174
|
+
flushSync();
|
|
175
|
+
|
|
176
|
+
const before = Array.from(container.querySelectorAll('span'));
|
|
177
|
+
const next = ids(40).slice(1, -1).reverse();
|
|
178
|
+
set(next);
|
|
179
|
+
flushSync();
|
|
180
|
+
|
|
181
|
+
const after = Array.from(container.querySelectorAll('span'));
|
|
182
|
+
expect(after.map((el) => el.textContent)).toEqual(next.map(String));
|
|
183
|
+
for (const el of after) {
|
|
184
|
+
expect(el).toBe(before[Number(el.dataset.id)]);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('creates new items inside a re-laid range in order', () => {
|
|
189
|
+
let set: (items: number[]) => void = () => {};
|
|
190
|
+
|
|
191
|
+
function App() @{
|
|
192
|
+
const items = track(ids(40));
|
|
193
|
+
set = (next) => {
|
|
194
|
+
items.value = next;
|
|
195
|
+
};
|
|
196
|
+
<div>
|
|
197
|
+
<b>{'head'}</b>
|
|
198
|
+
@for (const item of items.value; key item) {
|
|
199
|
+
<span data-id={item}>{item}</span>
|
|
200
|
+
}
|
|
201
|
+
<i>{'tail'}</i>
|
|
202
|
+
</div>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
render(App);
|
|
206
|
+
flushSync();
|
|
207
|
+
|
|
208
|
+
const before = Array.from(container.querySelectorAll('span'));
|
|
209
|
+
const reversed = ids(40).reverse();
|
|
210
|
+
const next = [100, ...reversed.slice(0, 20), 101, ...reversed.slice(20), 102];
|
|
211
|
+
set(next);
|
|
212
|
+
flushSync();
|
|
213
|
+
|
|
214
|
+
const children = Array.from(container.querySelector('div')!.children);
|
|
215
|
+
expect(children[0].localName).toBe('b');
|
|
216
|
+
expect(children[children.length - 1].localName).toBe('i');
|
|
217
|
+
expect(children.slice(1, -1).map((el) => el.textContent)).toEqual(next.map(String));
|
|
218
|
+
for (const el of children.slice(1, -1)) {
|
|
219
|
+
const id = Number((el as HTMLElement).dataset.id);
|
|
220
|
+
if (id < 40) expect(el).toBe(before[id]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
set(ids(40));
|
|
224
|
+
flushSync();
|
|
225
|
+
expect(Array.from(container.querySelectorAll('span')).map((el) => el.textContent)).toEqual(
|
|
226
|
+
ids(40).map(String),
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// A trailing `@if` or `@for` behind template siblings appends into its parent
|
|
232
|
+
// (no `<!>` placeholder); the if materializes an anchor when it swaps.
|
|
233
|
+
describe('trailing control flow appends into the parent', () => {
|
|
234
|
+
it('keeps a trailing @if in place across branch swaps', () => {
|
|
235
|
+
let set: (value: number) => void = () => {};
|
|
236
|
+
|
|
237
|
+
function App() @{
|
|
238
|
+
const n = track(0);
|
|
239
|
+
set = (value) => {
|
|
240
|
+
n.value = value;
|
|
241
|
+
};
|
|
242
|
+
<div>
|
|
243
|
+
<b>{'head'}</b>
|
|
244
|
+
@if (n.value === 0) {
|
|
245
|
+
<span>{'zero'}</span>
|
|
246
|
+
} @else if (n.value === 1) {
|
|
247
|
+
<em>{'one'}</em>
|
|
248
|
+
}
|
|
249
|
+
</div>
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
render(App);
|
|
253
|
+
flushSync();
|
|
254
|
+
const div = container.querySelector('div')!;
|
|
255
|
+
expect(div.innerHTML).toBe('<b>head</b><span>zero</span>');
|
|
256
|
+
|
|
257
|
+
set(1);
|
|
258
|
+
flushSync();
|
|
259
|
+
expect(div.textContent).toBe('headone');
|
|
260
|
+
expect(div.querySelector('em')).not.toBeNull();
|
|
261
|
+
|
|
262
|
+
set(2);
|
|
263
|
+
flushSync();
|
|
264
|
+
expect(div.textContent).toBe('head');
|
|
265
|
+
|
|
266
|
+
set(0);
|
|
267
|
+
flushSync();
|
|
268
|
+
expect(div.textContent).toBe('headzero');
|
|
269
|
+
expect(div.firstElementChild?.localName).toBe('b');
|
|
270
|
+
expect(div.lastElementChild?.localName).toBe('span');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('leaves no anchor node behind a trailing @if', () => {
|
|
274
|
+
let set: (value: boolean) => void = () => {};
|
|
275
|
+
|
|
276
|
+
function App() @{
|
|
277
|
+
const open = track(false);
|
|
278
|
+
set = (value) => {
|
|
279
|
+
open.value = value;
|
|
280
|
+
};
|
|
281
|
+
<div>
|
|
282
|
+
<b>{'head'}</b>
|
|
283
|
+
@if (open.value) {
|
|
284
|
+
<span>{'open'}</span>
|
|
285
|
+
}
|
|
286
|
+
</div>
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
render(App);
|
|
290
|
+
flushSync();
|
|
291
|
+
const div = container.querySelector('div')!;
|
|
292
|
+
// The if is the div's tail: nothing follows it, so it needs no anchor,
|
|
293
|
+
// off or on.
|
|
294
|
+
expect(div.childNodes.length).toBe(1);
|
|
295
|
+
|
|
296
|
+
set(true);
|
|
297
|
+
flushSync();
|
|
298
|
+
expect(div.innerHTML).toBe('<b>head</b><span>open</span>');
|
|
299
|
+
expect(div.childNodes.length).toBe(2);
|
|
300
|
+
|
|
301
|
+
set(false);
|
|
302
|
+
flushSync();
|
|
303
|
+
expect(div.innerHTML).toBe('<b>head</b>');
|
|
304
|
+
expect(div.childNodes.length).toBe(1);
|
|
305
|
+
|
|
306
|
+
set(true);
|
|
307
|
+
flushSync();
|
|
308
|
+
expect(div.innerHTML).toBe('<b>head</b><span>open</span>');
|
|
309
|
+
expect(div.childNodes.length).toBe(2);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('keeps a trailing @for with @empty in place', () => {
|
|
313
|
+
let set: (items: string[]) => void = () => {};
|
|
314
|
+
|
|
315
|
+
function App() @{
|
|
316
|
+
const items = track(['a', 'b']);
|
|
317
|
+
set = (next) => {
|
|
318
|
+
items.value = next;
|
|
319
|
+
};
|
|
320
|
+
<div>
|
|
321
|
+
<b>{'head'}</b>
|
|
322
|
+
@for (const item of items.value; key item) {
|
|
323
|
+
<span>{item}</span>
|
|
324
|
+
} @empty {
|
|
325
|
+
<i>{'none'}</i>
|
|
326
|
+
}
|
|
327
|
+
</div>
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
render(App);
|
|
331
|
+
flushSync();
|
|
332
|
+
const div = container.querySelector('div')!;
|
|
333
|
+
expect(div.innerHTML).toBe('<b>head</b><span>a</span><span>b</span>');
|
|
334
|
+
|
|
335
|
+
set([]);
|
|
336
|
+
flushSync();
|
|
337
|
+
expect(div.innerHTML).toBe('<b>head</b><i>none</i>');
|
|
338
|
+
|
|
339
|
+
set(['c', 'a']);
|
|
340
|
+
flushSync();
|
|
341
|
+
expect(div.innerHTML).toBe('<b>head</b><span>c</span><span>a</span>');
|
|
342
|
+
|
|
343
|
+
set(['a', 'c', 'd']);
|
|
344
|
+
flushSync();
|
|
345
|
+
expect(div.innerHTML).toBe('<b>head</b><span>a</span><span>c</span><span>d</span>');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// A keyed item read only by its update function is held as it is (no
|
|
350
|
+
// tracked): a replaced item re-runs its block directly, and tracked values
|
|
351
|
+
// inside the item still subscribe the block.
|
|
352
|
+
describe('local keyed items', () => {
|
|
353
|
+
it('re-renders a replaced item and follows a tracked value it holds', () => {
|
|
354
|
+
let set: (items: { id: number; label: Tracked<string> }[]) => void = () => {};
|
|
355
|
+
let a: Tracked<string>;
|
|
356
|
+
let b: Tracked<string>;
|
|
357
|
+
let c: Tracked<string>;
|
|
358
|
+
|
|
359
|
+
function App() @{
|
|
360
|
+
a = track('a');
|
|
361
|
+
b = track('b');
|
|
362
|
+
c = track('one');
|
|
363
|
+
const items = track([
|
|
364
|
+
{ id: 1, label: a },
|
|
365
|
+
{ id: 2, label: b },
|
|
366
|
+
]);
|
|
367
|
+
set = (next) => {
|
|
368
|
+
items.value = next;
|
|
369
|
+
};
|
|
370
|
+
<div>
|
|
371
|
+
@for (const item of items.value; key item.id) {
|
|
372
|
+
<span>{item.id + ':' + item.label.value}</span>
|
|
373
|
+
}
|
|
374
|
+
</div>
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
render(App);
|
|
378
|
+
flushSync();
|
|
379
|
+
const div = container.querySelector('div')!;
|
|
380
|
+
expect(div.textContent).toBe('1:a2:b');
|
|
381
|
+
const spans = Array.from(div.querySelectorAll('span'));
|
|
382
|
+
|
|
383
|
+
a.value = 'A';
|
|
384
|
+
flushSync();
|
|
385
|
+
expect(div.textContent).toBe('1:A2:b');
|
|
386
|
+
|
|
387
|
+
set([
|
|
388
|
+
{ id: 2, label: b },
|
|
389
|
+
{ id: 1, label: c },
|
|
390
|
+
]);
|
|
391
|
+
flushSync();
|
|
392
|
+
expect(div.textContent).toBe('2:b1:one');
|
|
393
|
+
expect(Array.from(div.querySelectorAll('span'))).toEqual([spans[1], spans[0]]);
|
|
394
|
+
|
|
395
|
+
a.value = 'AA';
|
|
396
|
+
flushSync();
|
|
397
|
+
expect(div.textContent).toBe('2:b1:one');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('clears and restores leaf text', () => {
|
|
401
|
+
let set: (items: { id: number; text: string }[]) => void = () => {};
|
|
402
|
+
|
|
403
|
+
function App() @{
|
|
404
|
+
const items = track<{ id: number; text: string }[]>([
|
|
405
|
+
{ id: 1, text: 'x' },
|
|
406
|
+
]);
|
|
407
|
+
set = (next) => {
|
|
408
|
+
items.value = next;
|
|
409
|
+
};
|
|
410
|
+
<div>
|
|
411
|
+
@for (const item of items.value; key item.id) {
|
|
412
|
+
<span>{item.text}</span>
|
|
413
|
+
}
|
|
414
|
+
</div>
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
render(App);
|
|
418
|
+
flushSync();
|
|
419
|
+
const span = container.querySelector('span')!;
|
|
420
|
+
expect(span.textContent).toBe('x');
|
|
421
|
+
expect(span.childNodes.length).toBe(1);
|
|
422
|
+
|
|
423
|
+
set([
|
|
424
|
+
{ id: 1, text: '' },
|
|
425
|
+
]);
|
|
426
|
+
flushSync();
|
|
427
|
+
expect(span.childNodes.length).toBe(0);
|
|
428
|
+
|
|
429
|
+
set([
|
|
430
|
+
{ id: 1, text: 'y' },
|
|
431
|
+
]);
|
|
432
|
+
flushSync();
|
|
433
|
+
expect(span.textContent).toBe('y');
|
|
434
|
+
expect(span.childNodes.length).toBe(1);
|
|
435
|
+
|
|
436
|
+
set([
|
|
437
|
+
{ id: 1, text: 'z' },
|
|
438
|
+
]);
|
|
439
|
+
flushSync();
|
|
440
|
+
expect(span.textContent).toBe('z');
|
|
441
|
+
expect(span.childNodes.length).toBe(1);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -219,3 +219,98 @@ describe('keyed reconciliation with most items moving', () => {
|
|
|
219
219
|
expect(after.map((el) => el.textContent)).toEqual(order.map(String));
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
// An `@if (outer === item.key)` in a keyed loop body lowers to the same
|
|
224
|
+
// selector as an attribute comparison would, reading the block's key: a
|
|
225
|
+
// change of the outer value re-runs the ifs of its previous and next key
|
|
226
|
+
// alone, and a replaced item never re-runs its if.
|
|
227
|
+
describe('@if selector comparisons', () => {
|
|
228
|
+
interface Item {
|
|
229
|
+
id: number;
|
|
230
|
+
label: string;
|
|
231
|
+
}
|
|
232
|
+
let select: (id: number | undefined) => void = () => {};
|
|
233
|
+
let setItems: (items: Item[]) => void = () => {};
|
|
234
|
+
|
|
235
|
+
function App() @{
|
|
236
|
+
const items = track<Item[]>([
|
|
237
|
+
{ id: 1, label: 'one' },
|
|
238
|
+
{ id: 2, label: 'two' },
|
|
239
|
+
{ id: 3, label: 'three' },
|
|
240
|
+
]);
|
|
241
|
+
const selected = track<number | undefined>(undefined);
|
|
242
|
+
select = (id) => {
|
|
243
|
+
selected.value = id;
|
|
244
|
+
};
|
|
245
|
+
setItems = (next) => {
|
|
246
|
+
items.value = next;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
<ul>
|
|
250
|
+
@for (const item of items.value; key item.id) {
|
|
251
|
+
<li data-id={item.id}>
|
|
252
|
+
<span>{item.label}</span>
|
|
253
|
+
@if (selected.value === item.id) {
|
|
254
|
+
<b>{'current'}</b>
|
|
255
|
+
}
|
|
256
|
+
</li>
|
|
257
|
+
}
|
|
258
|
+
</ul>
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function marked(): string[] {
|
|
262
|
+
return Array.from(container.querySelectorAll('li')).map(
|
|
263
|
+
(li) => `${li.getAttribute('data-id')}:${li.textContent}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
it('moves the marker between rows and keeps it on a replaced row', () => {
|
|
268
|
+
render(App);
|
|
269
|
+
flushSync();
|
|
270
|
+
expect(marked()).toEqual(['1:one', '2:two', '3:three']);
|
|
271
|
+
|
|
272
|
+
select(2);
|
|
273
|
+
flushSync();
|
|
274
|
+
expect(marked()).toEqual(['1:one', '2:twocurrent', '3:three']);
|
|
275
|
+
const b = container.querySelector('b')!;
|
|
276
|
+
|
|
277
|
+
select(3);
|
|
278
|
+
flushSync();
|
|
279
|
+
expect(marked()).toEqual(['1:one', '2:two', '3:threecurrent']);
|
|
280
|
+
|
|
281
|
+
// The selected row's object is replaced under its key: the label
|
|
282
|
+
// updates and the marker stays, in place behind the label.
|
|
283
|
+
setItems([
|
|
284
|
+
{ id: 1, label: 'uno' },
|
|
285
|
+
{ id: 2, label: 'dos' },
|
|
286
|
+
{ id: 3, label: 'tres' },
|
|
287
|
+
]);
|
|
288
|
+
flushSync();
|
|
289
|
+
expect(marked()).toEqual(['1:uno', '2:dos', '3:trescurrent']);
|
|
290
|
+
expect(container.querySelector('li:last-child')!.childNodes.length).toBe(2);
|
|
291
|
+
expect(container.querySelector('b')).not.toBe(b);
|
|
292
|
+
|
|
293
|
+
select(undefined);
|
|
294
|
+
flushSync();
|
|
295
|
+
expect(marked()).toEqual(['1:uno', '2:dos', '3:tres']);
|
|
296
|
+
// Off again, and no anchor node is left behind the label.
|
|
297
|
+
for (const li of container.querySelectorAll('li')) {
|
|
298
|
+
expect(li.childNodes.length).toBe(1);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('marks a row created after the selection was set', () => {
|
|
303
|
+
render(App);
|
|
304
|
+
flushSync();
|
|
305
|
+
select(4);
|
|
306
|
+
flushSync();
|
|
307
|
+
expect(marked()).toEqual(['1:one', '2:two', '3:three']);
|
|
308
|
+
|
|
309
|
+
setItems([
|
|
310
|
+
{ id: 4, label: 'four' },
|
|
311
|
+
{ id: 1, label: 'one' },
|
|
312
|
+
]);
|
|
313
|
+
flushSync();
|
|
314
|
+
expect(marked()).toEqual(['4:fourcurrent', '1:one']);
|
|
315
|
+
});
|
|
316
|
+
});
|