a post-component library for building user-interfaces on the web.

batch invalidations (#204)

fixes reentrancy bugs by avoiding reentrancy.

batches invalidations in a global microtask,
which makes things faster at the cost of invalidate
now returning a promise.

authored by tombl.dev and committed by

GitHub a483e50d a07b1f92

+305 -199
+4 -2
examples/kanban/src/util/router.ts
··· 19 19 routes: Record<string, PageHandler<string, Context>> 20 20 context: Context 21 21 } 22 - interface RouterConfigP<Context, Routes extends { [Path in keyof Routes & string]: PageHandler<Path, Context> }> 23 - extends RouterConfig<Context> { 22 + interface RouterConfigP< 23 + Context, 24 + Routes extends { [Path in keyof Routes & string]: PageHandler<Path, Context> }, 25 + > extends RouterConfig<Context> { 24 26 routes: Routes 25 27 } 26 28
+19 -20
examples/todomvc/main.js
··· 46 46 checked=${this.completed} 47 47 onchange=${e => { 48 48 e.preventDefault() 49 - this.completed = e.target.checked 50 - transition(() => { 51 - invalidate(this) 52 - invalidate(this.app) 49 + transition(async () => { 50 + this.completed = e.target.checked 51 + await invalidate(this, this.app) 53 52 }) 54 53 }} 55 54 /> 56 55 <label 57 56 ondblclick=${() => { 58 - transition(() => { 57 + transition(async () => { 59 58 this.editing = true 60 - invalidate(this) 59 + await invalidate(this) 61 60 }) 62 61 }} 63 62 >${this.title}</label ··· 65 64 <button 66 65 class="destroy" 67 66 onclick=${() => { 68 - transition(() => { 67 + transition(async () => { 69 68 this.app.remove(this.id) 70 - invalidate(this.app) 69 + await invalidate(this.app) 71 70 }) 72 71 }} 73 72 ></button> ··· 83 82 onblur=${e => { 84 83 const value = e.target.value.trim() 85 84 if (value) { 86 - transition(() => { 85 + transition(async () => { 87 86 this.title = value 88 87 this.editing = false 89 - invalidate(this) 88 + await invalidate(this) 90 89 }) 91 90 } 92 91 }} ··· 94 93 if (e.key === 'Enter') { 95 94 const value = e.target.value.trim() 96 95 if (value) { 97 - transition(() => { 96 + transition(async () => { 98 97 this.title = value 99 98 this.editing = false 100 - invalidate(this) 99 + await invalidate(this) 101 100 }) 102 101 } 103 102 } ··· 136 135 if (event.key === 'Enter') { 137 136 const value = event.target.value.trim() 138 137 if (value) { 139 - transition(() => { 138 + transition(async () => { 140 139 this.todos.push(new TodoItem(this, value)) 141 140 event.target.value = '' 142 - invalidate(this) 141 + await invalidate(this) 143 142 }) 144 143 } 145 144 } ··· 156 155 type="checkbox" 157 156 checked=${activeCount === 0} 158 157 onchange=${e => { 159 - transition(() => { 158 + transition(async () => { 160 159 for (const todo of this.todos) todo.completed = e.target.checked 161 - invalidate(this) 160 + await invalidate(this) 162 161 }) 163 162 }} 164 163 /> ··· 187 186 href="#" 188 187 ${classes(this.filter === filter && 'selected')} 189 188 onclick=${() => { 190 - transition(() => { 189 + transition(async () => { 191 190 this.filter = filter 192 - invalidate(this) 191 + await invalidate(this) 193 192 }) 194 193 }} 195 194 >${filter}</a ··· 201 200 ? html`<button 202 201 class="clear-completed" 203 202 onclick=${() => { 204 - transition(() => { 203 + transition(async () => { 205 204 this.todos = this.todos.filter(todo => !todo.completed) 206 - invalidate(this) 205 + await invalidate(this) 207 206 }) 208 207 }} 209 208 >
+9 -2
scripts/test/browser-runtime.ts
··· 7 7 import * as puppeteer from 'puppeteer' 8 8 import type { Runtime } from './main.ts' 9 9 10 - export async function create_browser_runtime(): Promise<Runtime> { 10 + export interface BrowserRuntimeOptions { 11 + collect_coverage?: boolean 12 + } 13 + 14 + export async function create_browser_runtime(options: BrowserRuntimeOptions = {}): Promise<Runtime> { 15 + const collect_coverage = options.collect_coverage ?? true 16 + 11 17 const browser = await puppeteer.launch({ 12 18 // headless: false, 13 19 // devtools: true, ··· 88 94 const { port1, port2 } = new MessageChannel() 89 95 await page.exposeFunction('__postMessage', (data: any) => port1.postMessage(data)) 90 96 91 - await page.coverage.startJSCoverage({ includeRawScriptCoverage: true }) 97 + if (collect_coverage) await page.coverage.startJSCoverage({ includeRawScriptCoverage: true }) 92 98 await page.goto(`http://${addr}/@runner`) 93 99 94 100 const onmessage = await page.waitForFunction(() => window.__onmessage) ··· 97 103 return { 98 104 port: port2, 99 105 async coverage() { 106 + if (!collect_coverage) return [] 100 107 const coverage = await page.coverage.stopJSCoverage() 101 108 return coverage.map(c => c.rawScriptCoverage!) 102 109 },
+7 -4
scripts/test/main.ts
··· 42 42 const coverage: Coverage[] = [] 43 43 44 44 for (const [runtime, files] of Object.entries(all_files)) { 45 - const rt = runtime === 'node' ? await create_node_runtime() : await create_browser_runtime() 45 + const collect_coverage = !args.values.bench 46 + const rt = 47 + runtime === 'node' 48 + ? await create_node_runtime({ collect_coverage }) 49 + : await create_browser_runtime({ collect_coverage }) 46 50 await using _ = rt // workaround for https://issues.chromium.org/issues/409478039 47 51 48 52 const client = createBirpc<ClientFunctions, ServerFunctions>( ··· 78 82 await client.run_benchmarks({ filter }) 79 83 } else { 80 84 await client.run_tests({ filter }) 85 + await client.stop_coverage() 86 + coverage.push(...(await rt.coverage())) 81 87 } 82 - 83 - await client.stop_coverage() 84 - coverage.push(...(await rt.coverage())) 85 88 } 86 89 87 90 if (args.values.bench) {
+10 -4
scripts/test/node-runtime.ts
··· 6 6 import { fileURLToPath } from 'node:url' 7 7 import type { Runtime } from './main.ts' 8 8 9 - export async function create_node_runtime(): Promise<Runtime> { 10 - const coverage_dir = await fs.mkdtemp(path.join(os.tmpdir(), 'coverage-')) 9 + export interface NodeRuntimeOptions { 10 + collect_coverage?: boolean 11 + } 12 + 13 + export async function create_node_runtime(options: NodeRuntimeOptions = {}): Promise<Runtime> { 14 + const collect_coverage = options.collect_coverage ?? true 15 + const coverage_dir = collect_coverage ? await fs.mkdtemp(path.join(os.tmpdir(), 'coverage-')) : null 11 16 const child = child_process.fork(fileURLToPath(import.meta.resolve('./runtime.ts')), { 12 - env: { NODE_V8_COVERAGE: coverage_dir }, 17 + env: collect_coverage ? { ...process.env, NODE_V8_COVERAGE: coverage_dir! } : process.env, 13 18 stdio: 'inherit', 14 19 }) 15 20 ··· 22 27 return { 23 28 port: port2, 24 29 async coverage() { 30 + if (!collect_coverage || !coverage_dir) return [] 25 31 const [filename] = await fs.readdir(coverage_dir) 26 32 const { result } = JSON.parse(await fs.readFile(path.join(coverage_dir, filename), 'utf8')) 27 33 return result ··· 29 35 async [Symbol.asyncDispose]() { 30 36 port1.close() 31 37 child.kill() 32 - await fs.rm(coverage_dir, { recursive: true }) 38 + if (coverage_dir) await fs.rm(coverage_dir, { recursive: true }) 33 39 }, 34 40 } 35 41 }
+15 -3
src/client/controller.ts
··· 9 9 } 10 10 11 11 export const controllers: WeakMap<Renderable, Controller> = new WeakMap() 12 + const invalidated_controllers: Set<Controller> = new Set() 13 + let invalidate_queued: null | Promise<void> = null 12 14 13 15 export function get_controller(renderable: Renderable): Controller { 14 16 let controller = controllers.get(renderable) ··· 24 26 return controller 25 27 } 26 28 27 - export function invalidate(renderable: Renderable): void { 28 - const controller = controllers.get(renderable) 29 - controller?._invalidate.forEach(invalidate => invalidate()) 29 + export function invalidate(...renderables: Renderable[]): Promise<void> { 30 + for (const renderable of renderables) invalidated_controllers.add(get_controller(renderable)) 31 + 32 + return (invalidate_queued ??= Promise.resolve() 33 + .then(() => { 34 + for (const controller of invalidated_controllers) { 35 + invalidated_controllers.delete(controller) 36 + controller._invalidate.forEach(invalidate => invalidate()) 37 + } 38 + }) 39 + .finally(() => { 40 + invalidate_queued = null 41 + })) 30 42 } 31 43 32 44 export function onMount(renderable: Renderable, callback: () => Cleanup): void {
+114 -129
src/client/tests/bench.ts
··· 33 33 this.items = this.items.filter((_, i) => (i + 1) % nth !== 0) 34 34 } 35 35 36 - activate(nth: number) { 36 + async activate(nth: number) { 37 37 for (let i = 0; i < this.items.length; i++) { 38 38 this.items[i].active = (i + 1) % nth === 0 39 - invalidate(this.items[i]) 40 39 } 40 + await invalidate(...this.items) 41 41 } 42 42 43 43 render() { ··· 97 97 } 98 98 } 99 99 100 - advance_each(nth: number) { 100 + async advance_each(nth: number) { 101 + const renderables: AnimBoxState[] = [] 101 102 for (let i = 0; i < this.items.length; i++) { 102 103 if ((i + 1) % nth === 0) { 103 104 this.items[i].time++ 104 - invalidate(this.items[i]) 105 + renderables.push(this.items[i]) 105 106 } 106 107 } 108 + await invalidate(...renderables) 107 109 } 108 110 109 111 render() { ··· 167 169 this.root.children = [] 168 170 } 169 171 170 - reverse() { 172 + async reverse() { 173 + const renderables: TreeNodeState[] = [] 171 174 const reverse_children = (node: TreeNodeState) => { 172 175 if (node.container && node.children) { 173 176 node.children.reverse() ··· 176 179 reverse_children(child) 177 180 } 178 181 } 179 - invalidate(node) 182 + renderables.push(node) 180 183 } 181 184 } 182 185 183 186 reverse_children(this.root) 187 + await invalidate(...renderables) 184 188 } 185 189 186 - insert_first(n: number) { 190 + async insert_first(n: number) { 191 + const renderables: TreeNodeState[] = [] 187 192 function insert_at_containers(node: TreeNodeState, id_counter: { value: number }) { 188 193 if (node.container && node.children) { 189 194 const new_nodes: TreeNodeState[] = [] ··· 197 202 insert_at_containers(child, id_counter) 198 203 } 199 204 } 200 - invalidate(node) 205 + renderables.push(node) 201 206 } 202 207 } 203 208 ··· 216 221 const id_counter = { value: max_id + 1 } 217 222 218 223 insert_at_containers(this.root, id_counter) 224 + await invalidate(...renderables) 219 225 } 220 226 221 - insert_last(n: number) { 227 + async insert_last(n: number) { 228 + const renderables: TreeNodeState[] = [] 222 229 function insert_at_containers(node: TreeNodeState, id_counter: { value: number }) { 223 230 if (node.container && node.children) { 224 231 const new_nodes: TreeNodeState[] = [] ··· 232 239 insert_at_containers(child, id_counter) 233 240 } 234 241 } 235 - invalidate(node) 242 + renderables.push(node) 236 243 } 237 244 } 238 245 ··· 251 258 const id_counter = { value: max_id + 1 } 252 259 253 260 insert_at_containers(this.root, id_counter) 261 + await invalidate(...renderables) 254 262 } 255 263 256 - remove_first(n: number) { 264 + async remove_first(n: number) { 265 + const renderables: TreeNodeState[] = [] 257 266 const remove_from_containers = (node: TreeNodeState) => { 258 267 if (node.container && node.children) { 259 268 node.children.splice(0, Math.min(n, node.children.length)) ··· 264 273 } 265 274 } 266 275 267 - invalidate(node) 276 + renderables.push(node) 268 277 } 269 278 } 270 279 271 280 remove_from_containers(this.root) 281 + await invalidate(...renderables) 272 282 } 273 283 274 - remove_last(n: number) { 284 + async remove_last(n: number) { 285 + const renderables: TreeNodeState[] = [] 275 286 const remove_from_containers = (node: TreeNodeState) => { 276 287 if (node.container && node.children) { 277 288 const length = node.children.length ··· 283 294 } 284 295 } 285 296 286 - invalidate(node) 297 + renderables.push(node) 287 298 } 288 299 } 289 300 290 301 remove_from_containers(this.root) 302 + await invalidate(...renderables) 291 303 } 292 304 293 - move_from_end_to_start(n: number) { 305 + async move_from_end_to_start(n: number) { 306 + const renderables: TreeNodeState[] = [] 294 307 const move_in_containers = (node: TreeNodeState) => { 295 308 if (node.container && node.children && node.children.length > n) { 296 309 const length = node.children.length ··· 303 316 } 304 317 } 305 318 306 - invalidate(node) 319 + renderables.push(node) 307 320 } 308 321 } 309 322 310 323 move_in_containers(this.root) 324 + await invalidate(...renderables) 311 325 } 312 326 313 - move_from_start_to_end(n: number) { 327 + async move_from_start_to_end(n: number) { 328 + const renderables: TreeNodeState[] = [] 314 329 const move_in_containers = (node: TreeNodeState) => { 315 330 if (node.container && node.children && node.children.length > n) { 316 331 const moved = node.children.splice(0, n) ··· 322 337 } 323 338 } 324 339 325 - invalidate(node) 340 + renderables.push(node) 326 341 } 327 342 } 328 343 329 344 move_in_containers(this.root) 345 + await invalidate(...renderables) 330 346 } 331 347 332 348 // Worst case scenarios 333 - kivi_worst_case() { 334 - this.remove_first(1) 335 - this.remove_last(1) 336 - this.reverse() 349 + async kivi_worst_case() { 350 + await this.remove_first(1) 351 + await this.remove_last(1) 352 + await this.reverse() 337 353 } 338 354 339 - snabbdom_worst_case() { 355 + async snabbdom_worst_case() { 356 + const renderables: TreeNodeState[] = [] 340 357 const transform = (node: TreeNodeState) => { 341 358 if (node.container && node.children && node.children.length > 2) { 342 359 const first = node.children.shift() ··· 351 368 } 352 369 } 353 370 354 - invalidate(node) 371 + renderables.push(node) 355 372 } 356 373 } 357 374 358 375 transform(this.root) 376 + await invalidate(...renderables) 359 377 } 360 378 361 - react_worst_case() { 362 - this.remove_first(1) 363 - this.remove_last(1) 364 - this.move_from_end_to_start(1) 379 + async react_worst_case() { 380 + await this.remove_first(1) 381 + await this.remove_last(1) 382 + await this.move_from_end_to_start(1) 365 383 } 366 384 367 - virtual_dom_worst_case() { 368 - this.move_from_start_to_end(2) 385 + async virtual_dom_worst_case() { 386 + await this.move_from_start_to_end(2) 369 387 } 370 388 371 389 render() { ··· 401 419 // Benchmark Cases 402 420 // ============================== 403 421 422 + function bench_setup(name: string, fn: (root: ReturnType<typeof setup>['root']) => void | Promise<void>): void { 423 + bench(name, async () => { 424 + const { root, el } = setup() 425 + try { 426 + await fn(root) 427 + } finally { 428 + root.render(null) 429 + el.remove() 430 + } 431 + }) 432 + } 433 + 404 434 // Table Benchmark Cases 405 - bench('table/small/render', () => { 406 - const { root } = setup() 435 + bench_setup('table/small/render', async root => { 407 436 const state = new TableState(15, 4) 408 437 root.render(state) 409 438 }) 410 439 411 - bench('table/small/removeAll', () => { 412 - const { root } = setup() 440 + bench_setup('table/small/removeAll', async root => { 413 441 const state = new TableState(15, 4) 414 442 root.render(state) 415 443 state.remove_all() 416 - invalidate(state) 444 + await invalidate(state) 417 445 }) 418 446 419 - bench('table/small/sort', () => { 420 - const { root } = setup() 447 + bench_setup('table/small/sort', async root => { 421 448 const state = new TableState(15, 4) 422 449 root.render(state) 423 450 state.sort_by_column(1) 424 - invalidate(state) 451 + await invalidate(state) 425 452 }) 426 453 427 - bench('table/small/filter', () => { 428 - const { root } = setup() 454 + bench_setup('table/small/filter', async root => { 429 455 const state = new TableState(15, 4) 430 456 root.render(state) 431 457 state.filter(4) 432 - invalidate(state) 458 + await invalidate(state) 433 459 }) 434 460 435 - bench('table/small/activate', () => { 436 - const { root } = setup() 461 + bench_setup('table/small/activate', async root => { 437 462 const state = new TableState(15, 4) 438 463 root.render(state) 439 - state.activate(4) 440 - invalidate(state) 464 + await state.activate(4) 441 465 }) 442 466 443 - bench('table/large/render', () => { 444 - const { root } = setup() 467 + bench_setup('table/large/render', async root => { 445 468 const state = new TableState(100, 4) 446 469 root.render(state) 447 470 }) 448 471 449 - bench('table/large/removeAll', () => { 450 - const { root } = setup() 472 + bench_setup('table/large/removeAll', async root => { 451 473 const state = new TableState(100, 4) 452 474 root.render(state) 453 475 state.remove_all() 454 - invalidate(state) 476 + await invalidate(state) 455 477 }) 456 478 457 - bench('table/large/sort', () => { 458 - const { root } = setup() 479 + bench_setup('table/large/sort', async root => { 459 480 const state = new TableState(100, 4) 460 481 root.render(state) 461 482 state.sort_by_column(1) 462 - invalidate(state) 483 + await invalidate(state) 463 484 }) 464 485 465 - bench('table/large/filter', () => { 466 - const { root } = setup() 486 + bench_setup('table/large/filter', async root => { 467 487 const state = new TableState(100, 4) 468 488 root.render(state) 469 489 state.filter(16) 470 - invalidate(state) 490 + await invalidate(state) 471 491 }) 472 492 473 - bench('table/large/activate', () => { 474 - const { root } = setup() 493 + bench_setup('table/large/activate', async root => { 475 494 const state = new TableState(100, 4) 476 495 root.render(state) 477 - state.activate(16) 478 - invalidate(state) 496 + await state.activate(16) 479 497 }) 480 498 481 499 // Animation Benchmark Cases 482 - bench('anim/small/advance', () => { 483 - const { root } = setup() 500 + bench_setup('anim/small/advance', async root => { 484 501 const state = new AnimState(30) 485 502 root.render(state) 486 - state.advance_each(4) 487 - invalidate(state) 503 + await state.advance_each(4) 488 504 }) 489 505 490 - bench('anim/large/advance', () => { 491 - const { root } = setup() 506 + bench_setup('anim/large/advance', async root => { 492 507 const state = new AnimState(100) 493 508 root.render(state) 494 - state.advance_each(16) 495 - invalidate(state) 509 + await state.advance_each(16) 496 510 }) 497 511 498 512 // Tree Benchmark Cases - Small 499 - bench('tree/small/render', () => { 500 - const { root } = setup() 513 + bench_setup('tree/small/render', async root => { 501 514 const state = new TreeState([5, 10]) 502 515 root.render(state) 503 516 }) 504 517 505 - bench('tree/small/removeAll', () => { 506 - const { root } = setup() 518 + bench_setup('tree/small/removeAll', async root => { 507 519 const state = new TreeState([5, 10]) 508 520 root.render(state) 509 521 state.remove_all() 510 - invalidate(state) 522 + await invalidate(state) 511 523 }) 512 524 513 - bench('tree/small/reverse', () => { 514 - const { root } = setup() 525 + bench_setup('tree/small/reverse', async root => { 515 526 const state = new TreeState([5, 10]) 516 527 root.render(state) 517 - state.reverse() 518 - invalidate(state) 528 + await state.reverse() 519 529 }) 520 530 521 - bench('tree/small/insertFirst', () => { 522 - const { root } = setup() 531 + bench_setup('tree/small/insertFirst', async root => { 523 532 const state = new TreeState([5, 10]) 524 533 root.render(state) 525 - state.insert_first(2) 526 - invalidate(state) 534 + await state.insert_first(2) 527 535 }) 528 536 529 - bench('tree/small/insertLast', () => { 530 - const { root } = setup() 537 + bench_setup('tree/small/insertLast', async root => { 531 538 const state = new TreeState([5, 10]) 532 539 root.render(state) 533 - state.insert_last(2) 534 - invalidate(state) 540 + await state.insert_last(2) 535 541 }) 536 542 537 - bench('tree/small/removeFirst', () => { 538 - const { root } = setup() 543 + bench_setup('tree/small/removeFirst', async root => { 539 544 const state = new TreeState([5, 10]) 540 545 root.render(state) 541 - state.remove_first(2) 542 - invalidate(state) 546 + await state.remove_first(2) 543 547 }) 544 548 545 - bench('tree/small/removeLast', () => { 546 - const { root } = setup() 549 + bench_setup('tree/small/removeLast', async root => { 547 550 const state = new TreeState([5, 10]) 548 551 root.render(state) 549 - state.remove_last(2) 550 - invalidate(state) 552 + await state.remove_last(2) 551 553 }) 552 554 553 - bench('tree/small/moveFromEndToStart', () => { 554 - const { root } = setup() 555 + bench_setup('tree/small/moveFromEndToStart', async root => { 555 556 const state = new TreeState([5, 10]) 556 557 root.render(state) 557 - state.move_from_end_to_start(2) 558 - invalidate(state) 558 + await state.move_from_end_to_start(2) 559 559 }) 560 560 561 - bench('tree/small/moveFromStartToEnd', () => { 562 - const { root } = setup() 561 + bench_setup('tree/small/moveFromStartToEnd', async root => { 563 562 const state = new TreeState([5, 10]) 564 563 root.render(state) 565 - state.move_from_start_to_end(2) 566 - invalidate(state) 564 + await state.move_from_start_to_end(2) 567 565 }) 568 566 569 - bench('tree/small/no_change', () => { 570 - const { root } = setup() 567 + bench_setup('tree/small/no_change', async root => { 571 568 const state = new TreeState([5, 10]) 572 569 root.render(state) 573 - invalidate(state) 570 + await invalidate(state) 574 571 }) 575 572 576 573 // Tree Benchmark Cases - Large 577 - bench('tree/large/render', () => { 578 - const { root } = setup() 574 + bench_setup('tree/large/render', async root => { 579 575 const state = new TreeState([50, 10]) 580 576 root.render(state) 581 577 }) 582 578 583 - bench('tree/large/removeAll', () => { 584 - const { root } = setup() 579 + bench_setup('tree/large/removeAll', async root => { 585 580 const state = new TreeState([50, 10]) 586 581 root.render(state) 587 582 state.remove_all() 588 - invalidate(state) 583 + await invalidate(state) 589 584 }) 590 585 591 - bench('tree/large/reverse', () => { 592 - const { root } = setup() 586 + bench_setup('tree/large/reverse', async root => { 593 587 const state = new TreeState([50, 10]) 594 588 root.render(state) 595 - state.reverse() 596 - invalidate(state) 589 + await state.reverse() 597 590 }) 598 591 599 592 // Worst Case Scenarios 600 - bench('tree/worst_case/kivi', () => { 601 - const { root } = setup() 593 + bench_setup('tree/worst_case/kivi', async root => { 602 594 const state = new TreeState([10, 10]) 603 595 root.render(state) 604 - state.kivi_worst_case() 605 - invalidate(state) 596 + await state.kivi_worst_case() 606 597 }) 607 598 608 - bench('tree/worst_case/snabbdom', () => { 609 - const { root } = setup() 599 + bench_setup('tree/worst_case/snabbdom', async root => { 610 600 const state = new TreeState([10, 10]) 611 601 root.render(state) 612 - state.snabbdom_worst_case() 613 - invalidate(state) 602 + await state.snabbdom_worst_case() 614 603 }) 615 604 616 - bench('tree/worst_case/react', () => { 617 - const { root } = setup() 605 + bench_setup('tree/worst_case/react', async root => { 618 606 const state = new TreeState([10, 10]) 619 607 root.render(state) 620 - state.react_worst_case() 621 - invalidate(state) 608 + await state.react_worst_case() 622 609 }) 623 610 624 - bench('tree/worst_case/virtual_dom', () => { 625 - const { root } = setup() 611 + bench_setup('tree/worst_case/virtual_dom', async root => { 626 612 const state = new TreeState([10, 10]) 627 613 root.render(state) 628 - state.virtual_dom_worst_case() 629 - invalidate(state) 614 + await state.virtual_dom_worst_case() 630 615 })
+4 -4
src/client/tests/hydration.test.ts
··· 281 281 assert_eq(el.innerHTML, '<!--?[--><h1>Component content</h1><!--?]-->') 282 282 }) 283 283 284 - test('renderables with state hydrate correctly', () => { 284 + test('renderables with state hydrate correctly', async () => { 285 285 const counter = { 286 286 count: 0, 287 287 render() { ··· 295 295 296 296 // Test state updates 297 297 counter.count = 5 298 - invalidate(counter) 298 + await invalidate(counter) 299 299 assert_eq(el.innerHTML, '<!--?[--><div>Count: <!--?[-->5<!--?]--></div><!--?]-->') 300 300 }) 301 301 ··· 506 506 assert(thrown) 507 507 }) 508 508 509 - test('hydration of deep nesting', () => { 509 + test('hydration of deep nesting', async () => { 510 510 const DEPTH = 10 511 511 512 512 const leaf = { ··· 526 526 assert_eq(el.innerHTML, '<!--?[-->'.repeat(DEPTH + 1) + 'hello!' + '<!--?]-->'.repeat(DEPTH + 1)) 527 527 528 528 leaf.text = 'goodbye' 529 - invalidate(leaf) 529 + await invalidate(leaf) 530 530 531 531 assert_eq(el.innerHTML, '<!--?[-->'.repeat(DEPTH + 1) + 'goodbye' + '<!--?]-->'.repeat(DEPTH + 1)) 532 532 })
+2 -2
src/client/tests/lists.test.ts
··· 165 165 assert_eq(el.innerHTML, '2') 166 166 }) 167 167 168 - test('list can disappear when condition changes', () => { 168 + test('list can disappear when condition changes', async () => { 169 169 const { root, el } = setup() 170 170 171 171 const app = { ··· 180 180 assert_eq(el.innerHTML, '<div>1</div><div>2</div><div>3</div>') 181 181 182 182 app.show = false 183 - invalidate(app) 183 + await invalidate(app) 184 184 assert_eq(el.innerHTML, '') 185 185 }) 186 186
+116 -24
src/client/tests/renderable.test.ts
··· 1 - import { html } from 'dhtml' 1 + import { html, type Displayable } from 'dhtml' 2 2 import { invalidate, onMount, onUnmount } from 'dhtml/client' 3 - import { assert_deep_eq, assert_eq, test } from '../../../scripts/test/test.ts' 3 + import { assert, assert_deep_eq, assert_eq, test } from '../../../scripts/test/test.ts' 4 4 import { setup } from './setup.ts' 5 5 6 - test('renderables work correctly', () => { 6 + test('renderables work correctly', async () => { 7 7 const { root, el } = setup() 8 8 9 9 root.render( ··· 29 29 assert_eq(el.innerHTML, 'Count: 0') 30 30 31 31 // but invalidating it shouldn't: 32 - invalidate(app) 32 + await invalidate(app) 33 33 assert_eq(el.innerHTML, 'Count: 1') 34 - invalidate(app) 34 + await invalidate(app) 35 35 assert_eq(el.innerHTML, 'Count: 2') 36 36 assert_eq(app.i, 3) 37 37 }) ··· 59 59 assert_eq(el.innerHTML, 'this was thrown') 60 60 }) 61 61 62 - test('onMount calls in the right order', () => { 62 + test('onMount calls in the right order', async () => { 63 63 const { root, el } = setup() 64 64 65 65 const sequence: string[] = [] ··· 100 100 sequence.length = 0 101 101 102 102 outer.show = false 103 - invalidate(outer) 103 + await invalidate(outer) 104 104 assert_eq(el.innerHTML, '') 105 105 assert_deep_eq(sequence, ['outer render', 'inner cleanup']) 106 106 sequence.length = 0 107 107 108 108 outer.show = true 109 - invalidate(outer) 109 + await invalidate(outer) 110 110 assert_eq(el.innerHTML, 'inner') 111 111 // inner is mounted a second time because of the above cleanup 112 112 assert_deep_eq(sequence, ['outer render', 'inner mount', 'inner render']) ··· 214 214 assert_eq(calls, 1) 215 215 }) 216 216 217 - test('onUnmount deep works correctly', () => { 217 + test('onUnmount deep works correctly', async () => { 218 218 const { root, el } = setup() 219 219 220 220 const sequence: string[] = [] ··· 250 250 sequence.length = 0 251 251 252 252 outer.show = false 253 - invalidate(outer) 253 + await invalidate(outer) 254 254 assert_eq(el.innerHTML, '') 255 255 assert_deep_eq(sequence, ['outer render', 'inner abort']) 256 256 sequence.length = 0 257 257 258 258 outer.show = true 259 - invalidate(outer) 259 + await invalidate(outer) 260 260 assert_eq(el.innerHTML, 'inner') 261 261 assert_deep_eq(sequence, ['outer render', 'inner render']) 262 262 sequence.length = 0 263 263 264 264 outer.show = false 265 - invalidate(outer) 265 + await invalidate(outer) 266 266 assert_eq(el.innerHTML, '') 267 267 assert_deep_eq(sequence, ['outer render', 'inner abort']) 268 268 sequence.length = 0 269 269 }) 270 270 271 - test('onUnmount shallow works correctly', () => { 271 + test('onUnmount shallow works correctly', async () => { 272 272 const { root, el } = setup() 273 273 274 274 const sequence: string[] = [] ··· 307 307 sequence.length = 0 308 308 309 309 outer.show = false 310 - invalidate(outer) 310 + await invalidate(outer) 311 311 assert_eq(el.innerHTML, '') 312 312 assert_deep_eq(sequence, ['outer render', 'inner abort']) 313 313 sequence.length = 0 314 314 315 315 outer.show = true 316 - invalidate(outer) 316 + await invalidate(outer) 317 317 assert_eq(el.innerHTML, 'inner') 318 318 assert_deep_eq(sequence, ['outer render', 'inner render']) 319 319 sequence.length = 0 320 320 321 321 outer.show = false 322 - invalidate(outer) 322 + await invalidate(outer) 323 323 assert_eq(el.innerHTML, '') 324 324 assert_deep_eq(sequence, ['outer render', 'inner abort']) 325 325 sequence.length = 0 ··· 374 374 } 375 375 }) 376 376 377 - test('renderables can be rendered in multiple places at once', () => { 377 + test('renderables can be rendered in multiple places at once', async () => { 378 378 const { root: root1, el: el1 } = setup() 379 379 const { root: root2, el: el2 } = setup() 380 380 ··· 404 404 405 405 // Update the renderable - both should update 406 406 app.value = 'updated' 407 - invalidate(app) 407 + await invalidate(app) 408 408 assert_eq(el1.innerHTML, 'updated') 409 409 assert_eq(el2.innerHTML, 'updated') 410 410 ··· 418 418 assert_eq(mounted, 0) // Now unmounted 419 419 }) 420 420 421 - test('renderables can be rendered in multiple places at once with a single root', () => { 421 + test('renderables can be rendered in multiple places at once with a single root', async () => { 422 422 const { root, el } = setup() 423 423 424 424 let mounted = 0 ··· 441 441 assert_eq(el.innerHTML, '<span>shared</span><span>shared</span>') 442 442 443 443 thing.value = 'updated' 444 - invalidate(thing) 444 + await invalidate(thing) 445 445 assert_eq(mounted, 1) 446 446 assert_eq(el.innerHTML, '<span>updated</span><span>updated</span>') 447 447 ··· 449 449 assert_eq(mounted, 0) 450 450 }) 451 451 452 - test('invalidating an unmounted renderable does nothing', () => { 452 + test('invalidating an unmounted renderable does nothing', async () => { 453 453 const { root, el } = setup() 454 454 455 455 const app1 = { ··· 470 470 root.render(app2) 471 471 assert_eq(el.textContent, 'app2') 472 472 473 - invalidate(app1) 473 + await invalidate(app1) 474 474 assert_eq(el.textContent, 'app2') 475 475 }) 476 476 ··· 502 502 assert_eq(unmounted, 1) 503 503 }) 504 504 505 - test('invalidating a parent does not re-render a child', () => { 505 + test('invalidating a parent does not re-render a child', async () => { 506 506 const { root, el } = setup() 507 507 508 508 let renders = 0 ··· 523 523 assert_eq(el.innerHTML, 'child') 524 524 assert_eq(renders, 1) 525 525 526 - invalidate(parent) 526 + await invalidate(parent) 527 527 assert_eq(el.innerHTML, 'child') 528 528 assert_eq(renders, 1) 529 529 }) 530 + 531 + test('invalidating parent during child render triggers update', async () => { 532 + const { root, el } = setup() 533 + 534 + let promise: Promise<void> 535 + const item = { 536 + render() { 537 + app.loading = true 538 + promise = invalidate(app) 539 + return 'created' 540 + }, 541 + } 542 + 543 + const app = { 544 + loading: false, 545 + 546 + render() { 547 + if (this.loading) return 'loading' 548 + return item 549 + }, 550 + } 551 + 552 + root.render(app) 553 + assert(promise!) 554 + await promise 555 + assert_eq(el.innerHTML, 'loading') 556 + }) 557 + 558 + test('invalidating grandparent during child render triggers update', async () => { 559 + const { root, el } = setup() 560 + 561 + let promise: Promise<void> 562 + const item = { 563 + render() { 564 + app.loading = true 565 + promise = invalidate(app) 566 + return 'created' 567 + }, 568 + } 569 + 570 + const middle = { 571 + item: null as Displayable, 572 + 573 + render() { 574 + return this.item 575 + }, 576 + } 577 + 578 + const app = { 579 + loading: false, 580 + 581 + render() { 582 + if (this.loading) return 'loading' 583 + return middle 584 + }, 585 + } 586 + 587 + root.render(app) 588 + assert_eq(el.innerHTML, '') 589 + 590 + middle.item = item 591 + await invalidate(middle) 592 + assert(promise!) 593 + await promise 594 + assert_eq(el.innerHTML, 'loading') 595 + }) 596 + 597 + test('invalidate drains reinvalidation of the same renderable before resolve', async () => { 598 + const { root, el } = setup() 599 + 600 + let state = 0 601 + let nested: Promise<void> | undefined 602 + const app = { 603 + render() { 604 + if (state === 1) { 605 + state = 2 606 + nested = invalidate(app) 607 + } 608 + return '' + state 609 + }, 610 + } 611 + 612 + root.render(app) 613 + assert_eq(el.innerHTML, '0') 614 + 615 + state = 1 616 + const promise = invalidate(app) 617 + await promise 618 + assert_eq(el.innerHTML, '2') 619 + assert(nested!) 620 + await nested 621 + })
+5 -5
src/server.ts
··· 117 117 } 118 118 119 119 function render_directive(value: unknown) { 120 - // Treat null/undefined as no-op, matching client behavior. 121 - if (value == null) return '' 120 + // Treat null/undefined as no-op, matching client behavior. 121 + if (value == null) return '' 122 122 123 - // In dev, ensure anything else is a function; on the server we don't execute it. 124 - assert(typeof value === 'function') 125 - return '' 123 + // In dev, ensure anything else is a function; on the server we don't execute it. 124 + assert(typeof value === 'function') 125 + return '' 126 126 } 127 127 128 128 function render_attribute(name: string, value: unknown) {