Skip to content

Known Limitations

This page documents behaviors that are inherent to how CascadeUI and Discord interact. These are not bugs — they are architectural constraints that would require fundamentally different designs to remove. Understanding them helps you build around them.


V1 and V2 Views Cannot Push/Pop Between Each Other

Affects: Navigation between StatefulView (V1) and StatefulLayoutView (V2).

What happens: Calling push() or pop() between a V1 and V2 view raises TypeError. For example, a V1 hub cannot push a V2 sub-view.

Why: Discord's IS_COMPONENTS_V2 flag is a one-way switch per message. Once a message is sent as V2 (LayoutView), it cannot revert to V1 (View + embeds), and vice versa. Since push/pop reuse the same Discord message, mixing versions would produce an invalid message state.

Workaround: Use replace() instead of push() for one-way transitions between V1 and V2. replace() creates a new message, so the version flag starts fresh. Note that replace() does not preserve navigation history (no back button).


Ephemeral Messages Cannot Be Persistent

Affects: PersistentView and PersistentLayoutView sent as ephemeral responses.

What happens: The view works during the current session but cannot be re-attached after a bot restart.

Why: Ephemeral messages have no permanent message ID. Discord does not store them server-side after the interaction token expires (~15 minutes). Without a message ID, setup_persistence() cannot call fetch_message() to re-attach the view.

Workaround: Send persistent views as regular (non-ephemeral) messages. If you need a private persistent view, consider using a DM channel instead of ephemeral.


Discord's 40-Component Limit per LayoutView

Affects: Complex V2 views with many interactive elements.

What happens: Discord rejects the message if the total component count exceeds 40 (Containers, TextDisplays, ActionRows, Buttons, Selects, Separators, etc. all count toward this limit).

Why: This is a Discord API constraint, not a CascadeUI limitation. Each component in a LayoutView's tree counts toward the 40-component budget.

Workaround: Use markdown-formatted TextDisplay components to aggregate multiple items into a single component. For example, a list of 10 items as one TextDisplay with line breaks costs 1 component instead of 10. Pagination patterns (PaginatedLayoutView) and tab patterns (TabLayoutView) also help distribute content across multiple states within the budget.


Auto-Defer Timer and Manual Response

Affects: Views with auto_defer = True (the default) where callbacks manually respond to the interaction.

What happens: If your callback takes longer than auto_defer_delay (default 2.5 seconds) to call interaction.response, the auto-defer timer fires first. Your subsequent interaction.response.send_message() call will fail because the response is already consumed.

Why: The auto-defer exists to prevent Discord's 3-second interaction timeout. It checks interaction.response.is_done() before deferring, so it's safe if your callback responds quickly. But if your callback does slow work before responding, the timer wins the race.

Workaround: For callbacks that do slow work, defer explicitly at the start:

async def my_slow_callback(self, interaction):
    await interaction.response.defer()  # Respond immediately
    result = await slow_operation()     # Then do the work
    await interaction.followup.send(f"Done: {result}")

This is standard discord.py practice and works naturally with CascadeUI's auto-defer (which sees is_done() == True and skips).


V2 Views Cannot Be Stripped From Messages

Affects: StatefulLayoutView and its subclasses on exit or timeout.

What happens: Calling message.edit(view=None) on a V2 message produces an empty message (Discord error 50006). Unlike V1 views where stripping the view leaves the embed intact, V2 views are the message content — removing the view removes everything.

Why: In V1, the view (buttons) and content (embed) are separate. In V2, the entire message is the LayoutView's component tree. Sending view=None sends an empty payload, which Discord rejects.

Workaround: CascadeUI handles this automatically. On exit() and on_timeout(), V2 views call _freeze_components() to disable all interactive items, then edit with the frozen view. The visual content is preserved but buttons/selects become unclickable. If you need custom exit behavior, override exit() and call _freeze_components() before editing.


dispatch_scoped() Does Not Create Undo Snapshots

Affects: Views using both dispatch_scoped() and enable_undo = True.

What happens: Changes made via dispatch_scoped() cannot be undone with self.undo(). The undo stack has no record of the change.

Why: dispatch_scoped() fires a SCOPED_UPDATE action, which is in the UndoMiddleware's skip list (_SKIP_ACTIONS). The middleware intentionally ignores bookkeeping actions to avoid polluting the undo stack with framework internals.

Workaround: Dispatch a custom action type instead:

# Instead of:
self.dispatch_scoped({"theme": "dark"})

# Use a custom action:
self.dispatch("SETTINGS_UPDATED", {"theme": "dark"})

Register a custom reducer that writes to the same _scoped state path:

@cascade_reducer("SETTINGS_UPDATED")
async def reduce_settings(action, state):
    scope_key = f"user:{action['payload']['user_id']}"
    scoped = state.setdefault("application", {}).setdefault("_scoped", {})
    scoped.setdefault(scope_key, {}).update(action["payload"]["data"])
    return state

The custom action will be tracked by UndoMiddleware and support undo/redo.


Undo/Redo Does Not Sync Across Independent Views

Affects: Views with enable_undo = True that share scoped state with views in a different session.

What happens: When View A performs an undo, View B (a different class sharing the same scoped state key) does not receive a live UI update. The state is restored correctly — if you refresh View B or navigate away and back, it shows the correct values. But it won't update in real time.

Why: CascadeUI's subscriber system uses state_selector() to avoid unnecessary UI updates. When undo restores a snapshot, the selector compares the new state against what the subscriber last saw. If View B already observed those values (because it was the one that originally made the change), the selector reports "no change" and skips the notification. This is correct behavior for the selector — it prevents redundant edits.

Normal dispatched actions (like SETTINGS_UPDATED) update the state incrementally, so the selector always sees a delta. Undo restores entire snapshots, which can produce states identical to what another view already observed.

Workaround: This only occurs when two different view classes share the same scoped state key and one of them has undo enabled. Within a single session (e.g., the same settings menu pushed/popped through a nav stack), undo/redo works correctly because the views share a session and the same subscriber context.

If you need cross-view undo reactivity, subscribe both views to UNDO and REDO action types and implement a custom update_from_state() that always rebuilds, bypassing the selector optimization:

class MyView(StatefulLayoutView):
    subscribed_actions = {"MY_ACTION", "UNDO", "REDO"}

    def state_selector(self, state):
        # Return None to always trigger update_from_state on UNDO/REDO
        return None

    async def update_from_state(self, state):
        self._build_ui()
        if self.message:
            await self.message.edit(view=self)

Note

Returning None from state_selector() disables the change-detection optimization for that view. It will rebuild on every subscribed action, not just when state actually changes. Use this sparingly.