@@ -1567,3 +1567,95 @@ def test_react_root_ready_after_children_append(page):
15671567 return new Promise(r => setTimeout(() => r(resolved), 100))
15681568 }
15691569 """ , timeout = 10000 )
1570+
1571+
1572+ class ReactChildrenRace (ReactComponent ):
1573+
1574+ views = Children ()
1575+ editors = Children ()
1576+
1577+ _esm = """
1578+ export function render({model}) {
1579+ return (
1580+ <div>
1581+ <div id="views">{model.get_child("views")}</div>
1582+ <div id="editors">{model.get_child("editors")}</div>
1583+ </div>
1584+ )
1585+ }"""
1586+
1587+
1588+ # Wraps the host view instance (not the prototype) so only the host's own update
1589+ # passes are counted and not the nested builds of its child views.
1590+ COUNT_OVERLAPPING_BUILDS = """
1591+ () => {
1592+ const find = (view) => {
1593+ if (view.model.type.startsWith('panel.models.esm.')) return view
1594+ for (const child of (view.child_views || [])) {
1595+ const found = find(child)
1596+ if (found) return found
1597+ }
1598+ return null
1599+ }
1600+ let host = null
1601+ for (const root of Object.values(Bokeh.index)) {
1602+ host = find(root)
1603+ if (host) break
1604+ }
1605+ if (host == null) throw new Error('no ESM view found')
1606+ window._overlaps = 0
1607+ window._completed = 0
1608+ let active = 0
1609+ const build = host.build_child_views.bind(host)
1610+ host.build_child_views = async () => {
1611+ active += 1
1612+ if (active > 1) window._overlaps += 1
1613+ try {
1614+ return await build()
1615+ } finally {
1616+ active -= 1
1617+ window._completed += 1
1618+ }
1619+ }
1620+ }
1621+ """
1622+
1623+
1624+ def test_children_updates_do_not_overlap (page ):
1625+ example = ReactChildrenRace (
1626+ views = [Row (ReactChildInner (text = "view-0" ))],
1627+ editors = [ReactChildInner (text = "editor-0" )],
1628+ )
1629+
1630+ serve_component (page , example )
1631+
1632+ expect (page .locator ('.inner' )).to_have_count (2 )
1633+
1634+ page .evaluate (COUNT_OVERLAPPING_BUILDS )
1635+
1636+ # Updating two children props in one event triggers one update pass each.
1637+ # Overlapping passes both build a view for the same model and only the last
1638+ # is kept, so the other is orphaned without `remove()` and never settles the
1639+ # promise it handed to `root._await_ready`.
1640+ example .param .update (
1641+ views = [* example .views , Row (ReactChildInner (text = "view-1" ))],
1642+ editors = [* example .editors , ReactChildInner (text = "editor-1" )],
1643+ )
1644+
1645+ expect (page .locator ('.inner' )).to_have_count (4 )
1646+ expect (page .locator ('#views .inner' )).to_have_count (2 )
1647+ expect (page .locator ('#editors .inner' )).to_have_count (2 )
1648+
1649+ wait_until (lambda : page .evaluate ('() => window._completed' ) >= 2 , page )
1650+ assert page .evaluate ('() => window._overlaps' ) == 0
1651+
1652+ page .wait_for_function ("""
1653+ () => {
1654+ const views = Object.values(Bokeh.index)
1655+ if (views.length === 0) return false
1656+ const root = views[0].root
1657+ let resolved = false
1658+ root.ready.then(() => { resolved = true })
1659+ return new Promise(r => setTimeout(() => r(resolved), 100))
1660+ }
1661+ """ , timeout = 10000 )
0 commit comments