charmbracelet / charmbracelet/bubbletea
Bug: Nested Model Fails to Exit/Switch State via Custom Command
- Dominant language
- Go
- Stars
- 44.9k
- Forks
- 1.3k
- PR merge metrics
- No merged PRs in 30d
Description
**Summary**
A sub-model, when returning a custom command to signal an exit/state switch in the parent model, occasionally fails to close or transfer control reliably. The issue appears to be related to the Bubble Tea command/message lifecycle within nested model architecture, specifically in how the parent model processes the custom exit message (BackToMenuMsg) relative to model delegation.
**Environment**
- Bubble Tea Version: [ v1.3.4]
- Go Version: [1.24.4]
- OS, Windows 10/amd64
- Terminal Emulator Visual Studio/DOS/Powershell
**Bug Reproduce**
Steps to reproduce the behaviour:
1. Start the parent program, which initializes the child model (e.g., Kycamlmodel).
2. Interact with the child model (e.g., type a few characters).
3. Press the Escape key (esc) on the keyboard.
4. The child model returns a command: return m, sendBackToMenuCmd("view_account").
**Expected Behaviour**
- The parent model should:
- Receive the BackToMenuMsg message.
- Change its internal state (e.g., m.state = StateMenuBrowsing).
- Update its active content type (m.activeContentType = msg.TargetView).
- Render the new view (main menu or "view_account").
**Actual Behaviour**
The program remains stuck, displaying the child model's view (Kycamlmodel.View()), indicating the parent's state switch logic was not executed or was immediately overridden by delegation
**Source Code - Sub Model**
Below is an example source code snippet, that is common for all 35 sub models, running under the parent program - if needed to reproduce the behaviour.
```go
// --- Kycamlmodel's Update Method ---
func (m Kycamlmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ... other code ...
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "esc" {
// Child returns its model and the command to exit/switch state
return m, sendBackToMenuCmd("view_account")
}
}
return m, nil
}
```
**Source Code - Parent Model**
There are no bugs in the Parent Model. Below are code snippets which are true for all Sub Models:
```go
// --- 1. Custom Message Type (Data Carrier) ---
type BackToMenuMsg struct {}
/ --- 2. Standalone Command Factory (Globally Callable) ---
// This closure captures the 'target' string and creates the command.
func sendBackToMenuCmd(target string) tea.Cmd {
return func() tea.Msg {
return BackToMenuMsg{}
}
}
```
**Update Method**
```go
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd // Command returned by the current state's update logic or a child model
var cmds []tea.Cmd // Slice to accumulate multiple commands
// --- Global Key Handling (Quit First) ---
// Check for global quit commands regardless of the current state.
if key, ok := msg.(tea.KeyMsg); ok {
if key.String() == "ctrl+c" || key.String() == "q" {
m.quitting = true // Set quitting flag for final View render
return m, tea.Quit // Return the Quit command to terminate the program
}
}
```
************* IMPORTANT CODE *************
```go
switch msg := msg.(type) {
case BackToMenuMsg:
// The child told us to switch state. We execute it immediately.
m.state = StateMenuBrowsing
m.activeContentType = msg.TargetView
// Immediately return nil command; DO NOT delegate or continue.
return m, nil
}
```
************* SUB MODEL HANDLING METHODS, IN UPDATE *************
```go
// ⬇️ STEP 2: Only delegate messages that are NOT the exit command ⬇️
var subCmd tea.Cmd
var updatedModel tea.Model
if m.state == StateContentDisplay && m.activeContentType == "create_account" {
updatedModel, subCmd = m.kycModel.Update(msg)
// Safely assert the model back to the concrete type
if kycModel, ok := updatedModel.(Kycamlmodel); ok {
m.kycModel = kycModel
}
return m, subCmd
}
return m, nil
}
```
************* ADDITIONAL CODE UNDER STATEMENUBROWSING; IN THE UPDATE METHOD *************
The following code snippet is the same for all 35 other Sub Models:
```go
case StateMenuBrowsing:
case "create_account":
m.activeContentType = "create_account"
m.createaccountModel = kmlNewModel()
cmds = append(cmds, m.createaccountModel.Init())
```
**Additional context**
So far, only three programs of the 35 fail to close when the escape key has been pressed. Everything I know has been tried to change this. For example, when watching the code execution in the Parent Model, for this method below:
```go
func sendBackToMenuCmd(target string) tea.Cmd {
return func() tea.Msg {
return BackToMenuMsg{}
}
}
```
**Conclusion**
When the execution point hits the - "return func() tea.Msg", as seen in the code snippet above, it immediately jumps to the closing parenthesis, for the "sendBackToMenuCmd" method, without executing any of the return code. This is inexplicable behaviour. I am presuming that this is the same for the other three Sub Models. Does anyone know if there is any type of fix for it, as I have tried everything and anything imaginable?
**Update:**
The Charmbracelet library textinput _seems_ be causing all or most of the problem. All sub models which employed textinput, namely 4, fail to respond to the ESC key and close down. When I remove all the textinput library and calls, the problem is resolved. However, I am still testing to make absolutely sure that this is the case.
All programs work perfectly as standalone.
*******LAST UPDATE*******
I have done _rigorous_ and _exhaustive_ testing, and this is my conclusion:
The textinput library,
"github.com/charmbracelet/bubbles/textinput"
...is not causing any of the issue! However, when the textinput library is used, it can _expose_ some existent, but non-apparent flaws in the Lipgloss logic, but _mostly_ when the code is within a sub-model. And, more importantly, when there are more than an estimated 12 - 15 sub-models.
*******THE REAL SOURCE OF THE CAUSE*******
Now, that it has been established that the Lipgloss, textinput library is not the cause, I can confirm that the issue is to do with the Switch statements, within the Update function.
That is, the Switch statements at the "case StateMenuBrowsing:", and the "case StateContentDisplay:".
*******OVERCOMING AND SOLVING THE ISSUE*******
The only way that I have found to overcome the issue is to _stack_ the case arguments. In other words, one must provide multiple argument within each case statement, rather than just only having one!
For example:
Rather than having a traditional case statement with only one argument, which looks like this:
```go
case "vote_results":
m.activeContentType = "vote_results"
m.voterResultsModel = VoterInitialModel(GetVoterResults())
cmds = append(cmds, m.voterResultsModel.Init()) // Initialize another sub-model
case "view_assets":
m.activeContentType = "view_assets"
m.viewAssetsModel = LoadPersonalAssets()
cmds = append(cmds, m.viewAssetsModel.Init()) // Initialize another sub-model
```
It is much better to use multiple arguments, within a single case statement, as seen in the example below:
```go
case "vote_results", "view_assets":
if (actionKey == "vote_results") {
m.activeContentType = "vote_results"
m.voterResultsModel = VoterInitialModel(GetVoterResults())
cmds = append(cmds, m.voterResultsModel.Init()) // Initialize another sub-model
}
if (actionKey == "view_assets") {
m.activeContentType = "view_assets"
m.viewAssetsModel = LoadPersonalAssets()
cmds = append(cmds, m.viewAssetsModel.Init()) // Initialize another sub-model
}
```
Then the problematic issues which effect sub-models, so that they fail to close, disappears!
I hope this _traumatic_ story, and its eventual code resolution, will help many.
WhipMeHarder
Contributor guide
Research direction
The payload names no repository files or tests. Start by reproducing the parent and child Update flow around BackToMenuMsg, sendBackToMenuCmd, and the StateMenuBrowsing/StateContentDisplay switches; done would require a minimal reproducible case and a verified cause or fix for the inconsistent state transition.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 20/100