djust docs
Browse documentation

{% dj_activity %} — Pre-Rendered Hidden Panels (React 19.2 Parity, v0.7.0)

On this page

React 19.2 shipped the <Activity> primitive for pre-rendering UI regions that should keep their local state (form inputs, scroll offsets, transient JS) when the user is not looking at them. {% dj_activity %} brings the same semantics to djust: a server-rendered region that can be hidden via the HTML hidden attribute without losing any client-side state.

When to use it

  • Tabbed interfaces. You want each tab's form to preserve what the user typed when they briefly switch away.
  • Multi-step flows. Pre-rendering the next step keeps it instant on reveal while you can still show the current step.
  • Collapsible panels where the user expects their in-progress input to still be there the next time they expand.

If you just need to toggle whether a region is rendered at all, use a plain {% if %} — a re-render is fine. {% dj_activity %} matters when re-rendering would drop local DOM state.

Basic usage

{% load live_tags %}

<button dj-click="switch_tab('profile')">Profile</button>
<button dj-click="switch_tab('notes')">Notes</button>

{% dj_activity "profile" visible=profile_visible %}
    <input type="text" dj-input="set_name" value="{{ name }}"/>
{% enddj_activity %}

{% dj_activity "notes" visible=notes_visible %}
    <textarea dj-input="set_notes">{{ notes }}</textarea>
{% enddj_activity %}
from djust import LiveView
from djust.decorators import event_handler


class TabbedView(LiveView):
    def mount(self, request, **kwargs):
        self.profile_visible = True
        self.notes_visible = False

    @event_handler
    def switch_tab(self, tab: str = "", **kwargs):
        self.profile_visible = tab == "profile"
        self.notes_visible = tab == "notes"

When profile_visible is false, the profile panel's wrapper receives the HTML hidden attribute plus aria-hidden="true" — but the <input> element stays in the DOM with its current value. Switch back to it and the value is still there.

Note on visible semantics. visible is evaluated for truthiness only; it is not compared with the activity name. visible=active_tab with active_tab == "notes" makes every panel visible, because "notes" is truthy. Pass a boolean.

Arguments

ArgumentTypeDefaultPurpose
namestring—Required. Unique within the template.
visiblebooleanTrueWhether the panel is user-visible this pass.
eagerbooleanFalseKeep dispatching events while hidden.

eager=True

By default, events that fire inside a hidden activity are dropped client-side (so a hidden panel can't send ghost inputs) and any pending server-side events for that activity are queued until it becomes visible. eager=True opts out: the activity always dispatches, even while its wrapper is hidden. Useful for a background timer or polling panel that should keep running.

You can also declare eager-activities on the LiveView class:

class Dashboard(LiveView):
    eager_activities = frozenset({"live-ticker"})

This is equivalent to setting eager=True on every {% dj_activity "live-ticker" %} block.

Client API

The client exposes window.djust.activityVisible(name) which returns the current DOM visibility of an activity by name. It reads directly from the DOM (via a MutationObserver-maintained map), so it reflects patches applied seconds ago as well as the initial render:

if (window.djust.activityVisible('profile')) {
    startCamera();
}

A bubbling djust:activity-shown CustomEvent fires on the activity root (and bubbles to window) whenever an activity flips from hidden to visible:

window.addEventListener('djust:activity-shown', (e) => {
    console.log('Activity shown:', e.detail.name);
});

Server API — ActivityMixin

LiveView composes in ActivityMixin automatically.

Visibility is driven by the tag's visible= expression: on every render the tag records its result on the view, so to show or hide a panel, set the variable that expression reads (as in the basic example above).

set_activity_visible(name, visible) is a low-level override of the server-side gate state. The next render replaces it with the value of the template's visible= expression, so it does not show or hide a panel by itself. It matters only in a handler that skips rendering, where it lets the deferred-event queue for that activity drain in the same turn.

is_activity_visible(name) reads the gate state:

class TabbedView(LiveView):
    def check_status(self):
        if self.is_activity_visible("notes"):
            # Notes panel is currently shown to the user.
            ...

Deferred events

When a client event fires in a hidden (non-eager) activity and somehow slips past the client-side gate (e.g. a mid-morph race), the server:

  1. Drops the event into a per-activity FIFO queue (capped at 100 by default; override via activity_event_queue_cap).
  2. Replies with a no-op so the client's loading state clears.
  3. On the next handler that flips the activity to visible=True, drains the queue in FIFO order. Each deferred event is dispatched via the WebSocket consumer's _dispatch_single_event helper and awaited inline, so every queued event completes inside the SAME WebSocket round-trip as the handler that flipped the panel visible — never as a delayed fire-and-forget task.

Comparison with nearby features

FeaturePreserves local DOMRe-renders bodyWhen to pick
{% if %}NoYesSimple conditional content
{% dj_activity %}YesYes (while visible)Tabbed UIs, multi-step forms
{% live_render %}Yes (own view)Yes (own view)Embedding a full child LiveView
dj-prefetchN/AFetches aheadPre-loading the next page
Sticky LiveViewYes (across routes)YesPersistent widget across route changes

Gotchas

Hidden <input> still submits

The HTML hidden attribute suppresses rendering but does not remove an element from its form's submission. If a hidden activity contains a form input, that input's value still posts with the form. Workarounds:

  • Put each panel's inputs in a separate <form>.
  • Add disabled to the inputs when hiding (but note: disabled inputs are skipped from submission — you may want readonly instead).
  • Only submit a subset server-side (validate on handle_event).

Nested activities

An outer hidden wrapper visually hides every descendant regardless of their own declared state. is_activity_visible on the server returns only the declared state of the named activity — it does not walk ancestors. The client gate handles nesting in two steps:

  1. Drop if any hidden non-eager ancestor exists. The event-dispatch gate uses closest('[data-djust-activity][hidden]:not([data-djust-eager="true"])') on the trigger element. A match anywhere up the chain drops the event — so an <outer hidden><inner visible> combination still drops, even though the inner wrapper is marked visible.
  2. Otherwise, stamp _activity with the closest ancestor. Once no hidden ancestor is found, the gate calls closest('[data-djust-activity]') and attaches that wrapper's name as _activity on the outbound payload. The server uses that name for per-activity routing / deferral on the rare mid-morph race where the client gate is stale.

Event ordering across show/hide

Deferred events drain in FIFO order per-activity. If you need strict global ordering with events from other activities, don't rely on the queue — flip the activity visible first and let the client send new events.

Security model

Activity gating is a UX-correctness feature, not an auth boundary. Treat it as "don't waste round-trips on events the user can't see" — never as "prevent hidden handlers from running."

Auth runs in two phases and it's important to keep them distinct:

  • Queued events do NOT re-run handler-level auth at insertion time. When an event arrives for a hidden activity, the consumer pushes (event_name, params) onto a per-activity FIFO queue (cap: 100 per activity) and immediately returns a no-op. The WebSocket frame that caused the queue insertion has already passed CSRF + session / connection authentication — but _validate_event_security, @permission_required, and rate limits are not consulted at this step. The cap is what bounds the queue.

  • Real auth gates always run at dispatch time, regardless of activity state. When the activity becomes visible, each queued event is dispatched through _dispatch_single_event, which runs the FULL auth stack: _validate_event_security, @permission_required decorators, and the rate limiter (CSRF was already validated on the originating frame). A queued event cannot reach its handler without passing all of those — even if the user's permissions have changed in the meantime. There is no path that dispatches a handler without going through these checks, so a user who cannot call admin_only() cannot call it by tricking the client into marking the trigger as an activity event.

  • The _activity param is client-supplied. A client can omit it, forge it to an unknown name, or point it at a different activity. Absent or unknown _activity → server treats the call as visible and dispatches normally. This is deliberate — the server must not rely on the client-side gate for correctness.

  • If a handler must refuse to run when a panel is hidden for a business reason, add an explicit guard in the handler body:

    @event_handler()
    def purchase(self, **kwargs):
        if not self.is_activity_visible("checkout"):
            return  # or raise / log
        ...
    

    Relying on the client-side gate or on _activity routing for this is a bug — you'll ship a bypass.

Troubleshooting

CheckMeaningFix
A070{% dj_activity %} is missing a name argument.Add a name: {% dj_activity "panel" %}.
A071Two {% dj_activity %} blocks share a name in one template.Rename one — names must be unique per template.

Run python manage.py check --tag djust to surface both.

See also

  • Prefetch — pre-load the next page (sibling "make nav feel instant" primitive shipped in the same v0.7.0 batch).
  • Sticky LiveViews — state persistence across routes; includes the ADR-011 section on how Sticky and Activity compose (within-page show/hide vs across-page preservation).