ripple 0.4.1 → 0.4.2
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 +55 -0
- package/package.json +2 -2
- package/src/constants.js +4 -0
- package/src/runtime/internal/client/constants.js +6 -0
- package/src/runtime/internal/client/for.js +224 -50
- package/src/runtime/internal/client/hydration.js +0 -16
- package/src/runtime/internal/client/index.js +2 -2
- package/src/runtime/internal/client/operations.js +1 -2
- package/src/runtime/internal/client/render.js +37 -0
- package/src/runtime/internal/client/runtime.js +45 -2
- package/src/runtime/internal/server/index.js +7 -4
- package/tests/client/compiler/compiler.basic.test.tsrx +2 -2
- package/tests/client/for-item.test.tsrx +404 -0
- package/tests/hydration/compiled/client/basic.js +29 -55
- package/tests/hydration/compiled/client/events.js +80 -67
- package/tests/hydration/compiled/client/for.js +95 -80
- package/tests/hydration/compiled/client/fragment-cursor.js +412 -344
- package/tests/hydration/compiled/client/head.js +4 -7
- package/tests/hydration/compiled/client/html.js +40 -64
- package/tests/hydration/compiled/client/if-children.js +30 -34
- package/tests/hydration/compiled/client/if-fragment-controlflow.js +7 -7
- package/tests/hydration/compiled/client/if.js +4 -4
- package/tests/hydration/compiled/client/mixed-control-flow.js +20 -50
- package/tests/hydration/compiled/client/nested-control-flow.js +97 -185
- package/tests/hydration/compiled/client/portal.js +2 -2
- package/tests/hydration/compiled/client/reactivity.js +17 -31
- package/tests/hydration/compiled/client/streaming.js +4 -8
- package/tests/hydration/compiled/client/typed-text.js +4 -15
- package/tests/hydration/compiled/server/events.js +5 -53
- package/tests/hydration/compiled/server/for.js +1 -9
- package/tests/hydration/compiled/server/fragment-cursor.js +36 -324
- package/tests/server/basic.test.tsrx +23 -0
|
@@ -13,9 +13,46 @@ import { event_listener } from './events.js';
|
|
|
13
13
|
import { get_attribute_event_name, is_event_attribute } from '@tsrx/core/runtime/events';
|
|
14
14
|
import { get } from './runtime.js';
|
|
15
15
|
import { hydrating } from './hydration.js';
|
|
16
|
+
import { TEXT_NODE } from '../../../constants.js';
|
|
16
17
|
import { clsx } from 'clsx';
|
|
17
18
|
import { normalize_css_property_name } from '@tsrx/core/runtime/html';
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Sets the text of an element whose only child is that text. The template
|
|
22
|
+
* leaves such an element empty, so the first write creates the text node and
|
|
23
|
+
* later writes update it in place; server-rendered text is adopted as is.
|
|
24
|
+
* The element has a text node exactly when the value last written did not
|
|
25
|
+
* read as empty (an empty write drops the node again), so `prev`, the value
|
|
26
|
+
* the render block wrote before, says whether one exists without a DOM read.
|
|
27
|
+
* @param {Element} element
|
|
28
|
+
* @param {any} value
|
|
29
|
+
* @param {any} prev the value of the previous write (`''` before the first)
|
|
30
|
+
* @returns {void}
|
|
31
|
+
*/
|
|
32
|
+
export function set_text_content(element, value, prev) {
|
|
33
|
+
var str = value == null ? '' : value + '';
|
|
34
|
+
if (hydrating) {
|
|
35
|
+
var text = element.firstChild;
|
|
36
|
+
if (text === null) {
|
|
37
|
+
if (str !== '') {
|
|
38
|
+
element.textContent = str;
|
|
39
|
+
}
|
|
40
|
+
} else if (text.nodeType !== TEXT_NODE || str === '') {
|
|
41
|
+
element.textContent = str;
|
|
42
|
+
} else if (text.nodeValue !== str) {
|
|
43
|
+
text.nodeValue = str;
|
|
44
|
+
}
|
|
45
|
+
} else if (prev == null || prev === '') {
|
|
46
|
+
if (str !== '') {
|
|
47
|
+
element.textContent = str;
|
|
48
|
+
}
|
|
49
|
+
} else if (str === '') {
|
|
50
|
+
element.textContent = '';
|
|
51
|
+
} else {
|
|
52
|
+
/** @type {Text} */ (element.firstChild).nodeValue = str;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
/**
|
|
20
57
|
* @param {Text} text
|
|
21
58
|
* @param {any} value
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
SCHEDULED,
|
|
37
37
|
SELECTOR,
|
|
38
38
|
IF_BLOCK,
|
|
39
|
+
ITEM_BLOCK,
|
|
39
40
|
RELEASED,
|
|
40
41
|
RENDER_ENTRY,
|
|
41
42
|
CREATES_DERIVEDS,
|
|
@@ -389,7 +390,9 @@ function handle_run_error(error, block) {
|
|
|
389
390
|
* @returns {Derived[] | null}
|
|
390
391
|
*/
|
|
391
392
|
function prepare_rerun(block) {
|
|
392
|
-
if
|
|
393
|
+
// A list, an if, and a list item re-run only their own logic; their child
|
|
394
|
+
// blocks (items, the branch's blocks, an item body's nested blocks) stay.
|
|
395
|
+
if ((block.f & (FOR_BLOCK | IF_BLOCK | ITEM_BLOCK)) === 0) {
|
|
393
396
|
destroy_non_branch_children(block);
|
|
394
397
|
}
|
|
395
398
|
run_teardown(block);
|
|
@@ -411,6 +414,45 @@ function register_teardown(block, teardown) {
|
|
|
411
414
|
}
|
|
412
415
|
}
|
|
413
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Runs the body of a branch block that was just created, in place of a first
|
|
419
|
+
* `run_block`: a branch renders untracked and never re-runs, so the only
|
|
420
|
+
* bookkeeping its first run needs is the globals the body reads and the
|
|
421
|
+
* dependencies a nested `item` records on it. Errors are handled as
|
|
422
|
+
* `run_block` handles them.
|
|
423
|
+
* @param {Block} block
|
|
424
|
+
* @param {(anchor: any, value: any) => void} fn
|
|
425
|
+
* @param {any} anchor
|
|
426
|
+
* @param {any} value
|
|
427
|
+
*/
|
|
428
|
+
export function run_branch(block, fn, anchor, value) {
|
|
429
|
+
var previous_block = active_block;
|
|
430
|
+
var previous_reaction = active_reaction;
|
|
431
|
+
var previous_tracking = tracking;
|
|
432
|
+
var previous_dependency = active_dependency;
|
|
433
|
+
var previous_component = active_component;
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
active_block = block;
|
|
437
|
+
active_reaction = block;
|
|
438
|
+
active_component = block.co;
|
|
439
|
+
tracking = false;
|
|
440
|
+
active_dependency = null;
|
|
441
|
+
fn(anchor, value);
|
|
442
|
+
if (active_dependency !== null) {
|
|
443
|
+
block.d = active_dependency;
|
|
444
|
+
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
handle_run_error(error, block);
|
|
447
|
+
} finally {
|
|
448
|
+
active_block = previous_block;
|
|
449
|
+
active_reaction = previous_reaction;
|
|
450
|
+
tracking = previous_tracking;
|
|
451
|
+
active_dependency = previous_dependency;
|
|
452
|
+
active_component = previous_component;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
414
456
|
/**
|
|
415
457
|
* @param {Block} block
|
|
416
458
|
* @param {boolean} [first_run] true when the block has no children, teardown,
|
|
@@ -1258,7 +1300,8 @@ function is_tracking_dirty(tracking) {
|
|
|
1258
1300
|
export function is_block_dirty(block) {
|
|
1259
1301
|
var flags = block.f;
|
|
1260
1302
|
|
|
1261
|
-
|
|
1303
|
+
// A branch never re-runs, except a list item that carries its render block.
|
|
1304
|
+
if ((flags & ITEM_BLOCK) === 0 && (flags & (ROOT_BLOCK | BRANCH_BLOCK)) !== 0) {
|
|
1262
1305
|
return false;
|
|
1263
1306
|
}
|
|
1264
1307
|
if ((flags & BLOCK_HAS_RUN) === 0) {
|
|
@@ -1258,7 +1258,6 @@ export async function render(component, options = {}) {
|
|
|
1258
1258
|
}
|
|
1259
1259
|
}
|
|
1260
1260
|
|
|
1261
|
-
var CONTENT_SPECIAL = /[&<]/;
|
|
1262
1261
|
var ATTR_SPECIAL = /[&"<]/;
|
|
1263
1262
|
var SCRIPT_SPECIAL = /[<>]/;
|
|
1264
1263
|
|
|
@@ -1275,15 +1274,19 @@ function escape_inline_script(str) {
|
|
|
1275
1274
|
|
|
1276
1275
|
/**
|
|
1277
1276
|
* Escapes text or attribute content. Strings without a character to escape
|
|
1278
|
-
* (the common case) return as they are
|
|
1279
|
-
*
|
|
1277
|
+
* (the common case) return as they are; anything else goes through the general
|
|
1278
|
+
* escaper. Text content is checked with two single-character searches: on the
|
|
1279
|
+
* short strings a page is made of they beat one character-class regex test.
|
|
1280
1280
|
* @param {unknown} value
|
|
1281
1281
|
* @param {boolean} [is_attr]
|
|
1282
1282
|
* @returns {string}
|
|
1283
1283
|
*/
|
|
1284
1284
|
export function escape(value, is_attr) {
|
|
1285
1285
|
var str = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
1286
|
-
|
|
1286
|
+
if (is_attr) {
|
|
1287
|
+
return ATTR_SPECIAL.test(str) ? escape_html(str, true) : str;
|
|
1288
|
+
}
|
|
1289
|
+
return str.includes('&') || str.includes('<') ? escape_html(str, false) : str;
|
|
1287
1290
|
}
|
|
1288
1291
|
|
|
1289
1292
|
/**
|
|
@@ -110,8 +110,8 @@ describe('compiler > basics', () => {
|
|
|
110
110
|
|
|
111
111
|
const { code } = compile(source, 'stringish-text.tsrx', { mode: 'client' });
|
|
112
112
|
|
|
113
|
-
expect(code).toContain('_$_.
|
|
114
|
-
expect(code).toContain('
|
|
113
|
+
expect(code).toContain('_$_.set_text_content');
|
|
114
|
+
expect(code).toContain('textContent = title');
|
|
115
115
|
expect(count_occurrences(code, '_$_.expression')).toBe(2);
|
|
116
116
|
});
|
|
117
117
|
|
|
@@ -0,0 +1,404 @@
|
|
|
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('keeps a trailing @for with @empty in place', () => {
|
|
274
|
+
let set: (items: string[]) => void = () => {};
|
|
275
|
+
|
|
276
|
+
function App() @{
|
|
277
|
+
const items = track(['a', 'b']);
|
|
278
|
+
set = (next) => {
|
|
279
|
+
items.value = next;
|
|
280
|
+
};
|
|
281
|
+
<div>
|
|
282
|
+
<b>{'head'}</b>
|
|
283
|
+
@for (const item of items.value; key item) {
|
|
284
|
+
<span>{item}</span>
|
|
285
|
+
} @empty {
|
|
286
|
+
<i>{'none'}</i>
|
|
287
|
+
}
|
|
288
|
+
</div>
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
render(App);
|
|
292
|
+
flushSync();
|
|
293
|
+
const div = container.querySelector('div')!;
|
|
294
|
+
expect(div.innerHTML).toBe('<b>head</b><span>a</span><span>b</span>');
|
|
295
|
+
|
|
296
|
+
set([]);
|
|
297
|
+
flushSync();
|
|
298
|
+
expect(div.innerHTML).toBe('<b>head</b><i>none</i>');
|
|
299
|
+
|
|
300
|
+
set(['c', 'a']);
|
|
301
|
+
flushSync();
|
|
302
|
+
expect(div.innerHTML).toBe('<b>head</b><span>c</span><span>a</span>');
|
|
303
|
+
|
|
304
|
+
set(['a', 'c', 'd']);
|
|
305
|
+
flushSync();
|
|
306
|
+
expect(div.innerHTML).toBe('<b>head</b><span>a</span><span>c</span><span>d</span>');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// A keyed item read only by its update function is held as it is (no
|
|
311
|
+
// tracked): a replaced item re-runs its block directly, and tracked values
|
|
312
|
+
// inside the item still subscribe the block.
|
|
313
|
+
describe('local keyed items', () => {
|
|
314
|
+
it('re-renders a replaced item and follows a tracked value it holds', () => {
|
|
315
|
+
let set: (items: { id: number; label: Tracked<string> }[]) => void = () => {};
|
|
316
|
+
let a: Tracked<string>;
|
|
317
|
+
let b: Tracked<string>;
|
|
318
|
+
let c: Tracked<string>;
|
|
319
|
+
|
|
320
|
+
function App() @{
|
|
321
|
+
a = track('a');
|
|
322
|
+
b = track('b');
|
|
323
|
+
c = track('one');
|
|
324
|
+
const items = track([
|
|
325
|
+
{ id: 1, label: a },
|
|
326
|
+
{ id: 2, label: b },
|
|
327
|
+
]);
|
|
328
|
+
set = (next) => {
|
|
329
|
+
items.value = next;
|
|
330
|
+
};
|
|
331
|
+
<div>
|
|
332
|
+
@for (const item of items.value; key item.id) {
|
|
333
|
+
<span>{item.id + ':' + item.label.value}</span>
|
|
334
|
+
}
|
|
335
|
+
</div>
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
render(App);
|
|
339
|
+
flushSync();
|
|
340
|
+
const div = container.querySelector('div')!;
|
|
341
|
+
expect(div.textContent).toBe('1:a2:b');
|
|
342
|
+
const spans = Array.from(div.querySelectorAll('span'));
|
|
343
|
+
|
|
344
|
+
a.value = 'A';
|
|
345
|
+
flushSync();
|
|
346
|
+
expect(div.textContent).toBe('1:A2:b');
|
|
347
|
+
|
|
348
|
+
set([
|
|
349
|
+
{ id: 2, label: b },
|
|
350
|
+
{ id: 1, label: c },
|
|
351
|
+
]);
|
|
352
|
+
flushSync();
|
|
353
|
+
expect(div.textContent).toBe('2:b1:one');
|
|
354
|
+
expect(Array.from(div.querySelectorAll('span'))).toEqual([spans[1], spans[0]]);
|
|
355
|
+
|
|
356
|
+
a.value = 'AA';
|
|
357
|
+
flushSync();
|
|
358
|
+
expect(div.textContent).toBe('2:b1:one');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('clears and restores leaf text', () => {
|
|
362
|
+
let set: (items: { id: number; text: string }[]) => void = () => {};
|
|
363
|
+
|
|
364
|
+
function App() @{
|
|
365
|
+
const items = track<{ id: number; text: string }[]>([
|
|
366
|
+
{ id: 1, text: 'x' },
|
|
367
|
+
]);
|
|
368
|
+
set = (next) => {
|
|
369
|
+
items.value = next;
|
|
370
|
+
};
|
|
371
|
+
<div>
|
|
372
|
+
@for (const item of items.value; key item.id) {
|
|
373
|
+
<span>{item.text}</span>
|
|
374
|
+
}
|
|
375
|
+
</div>
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
render(App);
|
|
379
|
+
flushSync();
|
|
380
|
+
const span = container.querySelector('span')!;
|
|
381
|
+
expect(span.textContent).toBe('x');
|
|
382
|
+
expect(span.childNodes.length).toBe(1);
|
|
383
|
+
|
|
384
|
+
set([
|
|
385
|
+
{ id: 1, text: '' },
|
|
386
|
+
]);
|
|
387
|
+
flushSync();
|
|
388
|
+
expect(span.childNodes.length).toBe(0);
|
|
389
|
+
|
|
390
|
+
set([
|
|
391
|
+
{ id: 1, text: 'y' },
|
|
392
|
+
]);
|
|
393
|
+
flushSync();
|
|
394
|
+
expect(span.textContent).toBe('y');
|
|
395
|
+
expect(span.childNodes.length).toBe(1);
|
|
396
|
+
|
|
397
|
+
set([
|
|
398
|
+
{ id: 1, text: 'z' },
|
|
399
|
+
]);
|
|
400
|
+
flushSync();
|
|
401
|
+
expect(span.textContent).toBe('z');
|
|
402
|
+
expect(span.childNodes.length).toBe(1);
|
|
403
|
+
});
|
|
404
|
+
});
|