Description
When configuring Slack or Discord through the channel picker sub-flow, entering a valid bot token and pressing Enter loops back to the same page instead of advancing to the next sub-step (app token for Slack, channel IDs for Discord).
Reproduction
netclaw init
- Navigate forward to the Channel Picker step
- Select Slack (or Discord), press Enter to configure
- Enter a valid bot token (
xoxb-... for Slack) and press Enter
- Expected: Advances to the next sub-step (App Token for Slack)
- Actual: Stays on the bot token page indefinitely
Bisection
| Container |
Commit |
Works? |
43cc4a36 (unified channel picker) |
43cc4a36 |
Yes |
66e232b2 (dev HEAD, v0.16.1) |
66e232b2 |
No |
bc896bf5 (wizard cleanup branch) |
bc896bf5 |
No |
Introduced by: 95143415 — feat(tui): make AllowedUserIds an explicit allow/restrict choice (#760) (#778)
Root Cause
Subscription accumulation + cursor blink timer race in _stepSubs.
ChannelPickerStepView.ManagesOwnFocusState returns true, which prevents _stepSubs.Clear() from running in the DynamicLayoutNode factory between sub-step transitions. Each call to SlackStepView.BuildContent() adds new Submitted subscriptions to _stepSubs via .DisposeWith(callbacks.Subscriptions), but old subscriptions from previous sub-step renderings are never disposed.
TextInputBaseNode has a cursor blink timer that fires _invalidated.OnNext() on a real timer thread. If that timer fires between ProcessEvent completing and RenderCurrentPage starting, the old TextInputNode triggers a cascading invalidation that re-evaluates the DynamicLayoutNode factory. This creates duplicate subscriptions. Multiple AdvanceStep() calls fire per Enter, causing the wizard to jump erratically (appearing as a "loop").
Why headless tests pass: VirtualTerminal doesn't run real cursor blink timers — all events process synchronously with no real time passing. The interleaving condition never occurs.
Why #778 triggered it: Adding BuildUserAccessChoiceSubStep increased the subscription surface and likely changed timing enough for the blink timer race to manifest consistently.
Fix Direction
Clear adapter subscriptions in ChannelPickerStepView.BuildContent before delegating to the adapter view:
public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks)
{
_vm = (ChannelPickerStepViewModel)stepVm;
_callbacks = callbacks;
if (_vm.IsInSubFlow && _vm.ActiveAdapterVm is not null && _vm.ActiveAdapterView is not null)
{
callbacks.Subscriptions.Clear(); // <-- prevent subscription accumulation
_vm.ActiveAdapterView.ClearFocusState();
return _vm.ActiveAdapterView.BuildContent(_vm.ActiveAdapterVm, callbacks);
}
return BuildPickerChecklist();
}
This preserves picker cursor state (not in _stepSubs) while preventing subscription accumulation during sub-step transitions. The adapter view's focus state is also cleared, preventing stale TextInputNode instances from intercepting keys.
Environment
- Netclaw v0.16.1 (
dev branch)
- Termina 0.8.0
- .NET 10
- Ubuntu 24.04 (Docker + bare metal)
Description
When configuring Slack or Discord through the channel picker sub-flow, entering a valid bot token and pressing Enter loops back to the same page instead of advancing to the next sub-step (app token for Slack, channel IDs for Discord).
Reproduction
netclaw initxoxb-...for Slack) and press EnterBisection
43cc4a36(unified channel picker)43cc4a3666e232b2(dev HEAD, v0.16.1)66e232b2bc896bf5(wizard cleanup branch)bc896bf5Introduced by:
95143415—feat(tui): make AllowedUserIds an explicit allow/restrict choice (#760) (#778)Root Cause
Subscription accumulation + cursor blink timer race in
_stepSubs.ChannelPickerStepView.ManagesOwnFocusStatereturnstrue, which prevents_stepSubs.Clear()from running in theDynamicLayoutNodefactory between sub-step transitions. Each call toSlackStepView.BuildContent()adds newSubmittedsubscriptions to_stepSubsvia.DisposeWith(callbacks.Subscriptions), but old subscriptions from previous sub-step renderings are never disposed.TextInputBaseNodehas a cursor blink timer that fires_invalidated.OnNext()on a real timer thread. If that timer fires betweenProcessEventcompleting andRenderCurrentPagestarting, the oldTextInputNodetriggers a cascading invalidation that re-evaluates theDynamicLayoutNodefactory. This creates duplicate subscriptions. MultipleAdvanceStep()calls fire per Enter, causing the wizard to jump erratically (appearing as a "loop").Why headless tests pass:
VirtualTerminaldoesn't run real cursor blink timers — all events process synchronously with no real time passing. The interleaving condition never occurs.Why #778 triggered it: Adding
BuildUserAccessChoiceSubStepincreased the subscription surface and likely changed timing enough for the blink timer race to manifest consistently.Fix Direction
Clear adapter subscriptions in
ChannelPickerStepView.BuildContentbefore delegating to the adapter view:This preserves picker cursor state (not in
_stepSubs) while preventing subscription accumulation during sub-step transitions. The adapter view's focus state is also cleared, preventing staleTextInputNodeinstances from intercepting keys.Environment
devbranch)