Skip to content

Commit 2a40903

Browse files
authored
Ensure ESM component child views aren't created concurrently (#8713)
1 parent b9ed9d5 commit 2a40903

3 files changed

Lines changed: 111 additions & 1 deletion

File tree

panel/models/react_component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export class ReactComponentView extends ReactiveESMView {
305305
return created
306306
}
307307

308-
override async update_children(): Promise<void> {
308+
protected override async _update_children_pass(): Promise<void> {
309309
const created_children = new Set(await this.build_child_views())
310310

311311
const new_views = new Map()

panel/models/reactive_esm.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export class ReactiveESMView extends HTMLBoxView {
189189
_rendered: boolean = false
190190
_stale_children: boolean = false
191191
_mounted: Map<string, Set<string>> = new Map()
192+
_update_children_chain: Promise<void> = Promise.resolve()
192193

193194
override initialize(): void {
194195
super.initialize()
@@ -536,7 +537,24 @@ export class ReactiveESMView extends HTMLBoxView {
536537
return null
537538
}
538539

540+
/**
541+
* A children property can trigger more than one update pass for the same
542+
* change, e.g. the property change signal and the manual render policy path
543+
* in `ReactiveESM.watch`. `build_child_views` is async, so two passes that
544+
* overlap both create a view for the same model and only the last one is kept
545+
* in `_child_views`; the other is dropped without `remove()`. An orphaned
546+
* ReactComponent never mounts, so the promise it handed to `root._await_ready`
547+
* is never settled and the root's ready chain stays pending for the rest of
548+
* the session, which silently blocks anything waiting on it. Serializing the
549+
* passes makes the later one a no-op instead of a competing build.
550+
*/
539551
override async update_children(): Promise<void> {
552+
const run = this._update_children_chain.then(() => this._update_children_pass())
553+
this._update_children_chain = run.then(() => undefined, () => undefined)
554+
return run
555+
}
556+
557+
protected async _update_children_pass(): Promise<void> {
540558
const created_children = new Set(await this.build_child_views())
541559

542560
const all_views = this.child_views

panel/tests/ui/test_custom.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)