Skip to content

Commit fa0d3ba

Browse files
committed
Make RefreshComponent less twitchy via cancellation.
1 parent 991be15 commit fa0d3ba

3 files changed

Lines changed: 77 additions & 6 deletions

File tree

lua/lean/tui.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,16 @@ function Element:render_lines(renderer)
551551
if element.__async_init and renderer then
552552
renderer.pending_elements[element] = true
553553
element.__async_init(function(resolved_element) ---@type Element resolved_element
554+
-- Carry state (foldable open/close, RefreshComponent polling
555+
-- cancellation, ...) from the prior resolved subtree to the new
556+
-- one. Without this, every async frame would orphan whatever
557+
-- `__state` handles lived in the previous subtree — most
558+
-- noticeably leaking RefreshComponent polling loops that then
559+
-- compound into a render storm.
560+
local previous = element.__children[1]
561+
if previous then
562+
Element.transfer_state(previous, resolved_element)
563+
end
554564
element:set_children { resolved_element }
555565
renderer.pending_elements[element] = nil
556566
renderer:render()

lua/lean/widgets/ProofWidgets/RefreshComponent.lua

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,40 @@ local Html = require 'proofwidgets.html'
77
---
88
--- Displays HTML that updates over time as a background Lean thread pushes
99
--- new frames via the awaitRefresh RPC method.
10+
---
11+
--- A subtle point: each tree rebuild (cursor move, panel re-render, ...)
12+
--- creates a fresh RefreshComponent element with its own polling loop. If
13+
--- the previous element's loop is left running, every server frame it
14+
--- receives forces a full `renderer:render()` on the orphaned subtree,
15+
--- which compounds with every rebuild until the infoview is constantly
16+
--- re-rendering itself ("hourglass + flicker even when idle"). To avoid
17+
--- this we publish a `__state` cancel handle; `transfer_state` carries it
18+
--- to the new element which immediately cancels the old polling loop.
1019
---@param ctx RenderContext
1120
---@param props { state: table, cancelTk: table }
1221
---@return Element
1322
return function(ctx, props)
23+
local cancelled = false
24+
1425
local element = Element:new {
1526
__async_init = function(rerender)
16-
-- Start the monitor call (fire-and-forget).
17-
-- It loops forever server-side and cancels the background computation
18-
-- when the RPC session closes.
27+
-- Start the monitor call (fire-and-forget). It loops forever server-side
28+
-- and cancels the background computation when the RPC session closes.
1929
async.run(function()
2030
ctx:rpc_call('ProofWidgets.RefreshComponent.monitor', props)
2131
end)
2232

23-
-- Poll for new HTML frames.
33+
-- Poll for new HTML frames. Bail as soon as a newer element has
34+
-- adopted our position (via `__state`), otherwise the orphaned loop
35+
-- would keep firing `renderer:render()` after every server frame.
2436
async.run(function()
2537
local idx = 0
26-
while true do
38+
while not cancelled do
2739
local response, err = ctx:rpc_call(
2840
'ProofWidgets.RefreshComponent.awaitRefresh',
2941
{ state = props.state, oldIdx = idx }
3042
)
31-
if err or not response then
43+
if err or not response or cancelled then
3244
break
3345
end
3446
idx = response.idx
@@ -37,5 +49,17 @@ return function(ctx, props)
3749
end)
3850
end,
3951
}
52+
53+
element.__state = {
54+
snapshot = function()
55+
return function()
56+
cancelled = true
57+
end
58+
end,
59+
restore = function(_, cancel_previous)
60+
cancel_previous()
61+
end,
62+
}
63+
4064
return element
4165
end

spec/tui/core_spec.lua

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,43 @@ describe('Element', function()
599599
assert.is.equal('▼ outer\n\n▶ inner', root:to_string())
600600
end)
601601

602+
it('transfers state across __async_init resolve callbacks', function()
603+
-- Models what RefreshComponent needs: between successive resolutions
604+
-- of an `__async_init`-driven element, state on the previous resolved
605+
-- subtree (cancellation handles, foldable open-state, ...) must
606+
-- transfer to the new one — otherwise nested stateful children
607+
-- (e.g. RefreshComponent polling loops) get orphaned every frame.
608+
local restored = {}
609+
local function child_with_state(name)
610+
local elem = Element:new { text = name }
611+
elem.__state = {
612+
snapshot = function()
613+
return name
614+
end,
615+
restore = function(_, saved)
616+
table.insert(restored, saved)
617+
end,
618+
}
619+
return elem
620+
end
621+
622+
local resolve
623+
local async_elem = Element:new {
624+
__async_init = function(rerender)
625+
resolve = rerender
626+
end,
627+
}
628+
local buffer = require('std.nvim.buffer').create { scratch = true, listed = false }
629+
local renderer = async_elem:renderer { buffer = buffer }
630+
renderer:render()
631+
632+
resolve(child_with_state 'a')
633+
resolve(child_with_state 'b')
634+
resolve(child_with_state 'c')
635+
636+
assert.are.same({ 'a', 'b' }, restored)
637+
end)
638+
602639
it('transfers state via a user-attached __state handle', function()
603640
-- Widgets without a built-in handle (foldables have one already) can
604641
-- opt into rebuild-survival by attaching a `__state` table with

0 commit comments

Comments
 (0)