Something went wrong

Thank you for being patient! We're working hard on resolving the issue

Lifecycle - Lona Docs Log in

Gesture Lifecycle

The complete lifecycle of a gesture from a DOM pointer event to a persisted mutation, then back through invalidation to the next render. Use this doc to debug where in the chain a gesture is hanging or to understand which actor owns which responsibility.

This page is the reference companion to Gestures, which is the how-to. The lifecycle below describes both layers — item-level (renderer-owned via Span.callbacks) and column-level (via CalendarGesture.Manager).

The cast

ActorModuleResponsibility
Pluginyour RenderPlugin<…> implConstructs subcolumns; injects mutation targets
Subcolumn factoryCalendarSubcolumn.Provider.subcolumnFromRowLoads records; builds cards + handlers; populates Span.callbacks
Componentyour LonaWebComponent (e.g. LonaCalendarTaskCard)Visual content only; reflects state via attributes
Handlerrenderer-private class (e.g. TaskCardHandler)Holds drag state; builds Callbacks; paints ghost; calls target
Wrapper cellCalendarColumnCellGeometry: edge classification, capture, position resolve
Mutation targetrenderer-defined interface (TaskMutationTarget, …)Translates (ref, range) to producer-row writes
Producer clientdomain client for the source rowIssues the actual API write
Sheet hostCalendarSheetPlugin + CalendarDaysRowLays out subcolumns; wires bindCallbacks; rebinds on invalidation
GESTURE_MANAGERshared singletonNative pointer dispatch on cells; column-level routing
CalendarGesture.Managerparent-plugin-ownedColumn-level gesture state machine

Item-level lifecycle (renderer-owned)

The lifecycle of a drag-resize on an existing item, from cold start to persisted state.

Phase 1 — bind / render

sheet renders a row of type "task-list"
  │
  ▼
PluginManager.getPlugin("task-list") → TaskListPlugin
  │
  ▼
TaskListPlugin.bindCell($cell, context)
  │  parent calendar plugin discovers child rows and asks each
  │  for its subcolumn:
  ▼
TaskListPlugin.subcolumnProvider.subcolumnFromRow(sheet, row, parentRow)
  ├── builds TaskMutationTarget (closes over producer event client)
  └── returns { renderer: "spans", makeSpans, makeLayout }
  │
  ▼
CalendarDaysRow.renderSpans (parent plugin)
  ├── awaits subcolumn.makeSpans(windowed, tzr)
  │     for each TaskRecord:
  │       ├── $card = LonaCalendarTaskCard.make(); $card.bind(record, …)
  │       ├── handler = new TaskCardHandler(record, target, …, $card)
  │       └── span = { id, $e: $card, start, end,
  │                    pointerEvents: "all",
  │                    callbacks: handler.callbacks() }
  ├── for each span:
  │     ├── wraps $card in CalendarColumnCell.makeWith({content: $card})
  │     ├── sets style --default-interactable from span.pointerEvents
  │     └── wrapper.bindCallbacks(span.callbacks, "task-card")
  └── appends wrapper into the lane layout

After Phase 1 the DOM is hot and ready for input. The wrapper cell holds a reference to the renderer's Callbacks. The handler holds references to target, record, and $card. Nothing is captured; no pointer events are pending.

Phase 2 — pointer down

user presses pointer on a card body (or top/bottom 4px edge)
  │
  ▼
DOM pointerdown event on the wrapper CalendarColumnCell
  │
  ▼
GESTURE_MANAGER.addPointerEvent (registered in cell.bindCallbacks)
  ├── reads offset.x, offset.y via the cell's bounding rect
  └── delegates to the cell's onPointerDown closure:
        ├── classifies handle:
        │     offset.y <= 4         → "top"
        │     offset.y >= H - 4     → "bottom"
        │     else                  → null (body)
        ├── builds PointerPosition(offset, layoutInfo.time.start)
        └── invokes callbacks.onPointerDown({position, handle})
  │
  ▼
TaskCardHandler.onPointerDown
  ├── stores dragStart = {handle, range: record.time, startTime: position.resolve(...)}
  └── card.toggleAttribute("dragging", true)  // visual reflects state

At this point: the cell has not called setPointerCapture yet — that decision is made on pointermove. The handler is armed.

Phase 3 — pointer move

user moves pointer (any direction)
  │
  ▼
DOM pointermove on the wrapper cell
  │
  ▼
CalendarColumnCell.onpointermove
  ├── e.stopPropagation()
  ├── builds PointerPosition from offsetX/offsetY
  └── invokes callbacks.onPointerMove({position})
  │
  ▼
TaskCardHandler.onPointerMove
  ├── if dragStart is null: return { shouldCapturePointer: false }
  ├── t = position.resolve(windowed, pxPerHr)
  ├── proposed = derive(t)         // depends on dragStart.handle
  ├── updateGhost(proposed)        // paints/repositions sibling DOM
  └── return { shouldCapturePointer: true }
  │
  ▼
CalendarColumnCell honors return:
  ├── true  → setPointerCapture(e.pointerId)
  └── false → releasePointerCapture(e.pointerId)

Once captured, subsequent pointermove and pointerup events fire on the captured cell even if the pointer has moved outside its bounds. This is what lets a user drag past the edge of the column without losing the gesture.

Phase 4 — pointer up (commit)

user releases pointer
  │
  ▼
DOM pointerup on the captured wrapper cell
  │
  ▼
GESTURE_MANAGER.addPointerEvent.onClick (cell's pointer-up wrapper)
  └── invokes callbacks.onPointerUpCell({position})
  │
  ▼
TaskCardHandler.onPointerUpCell (async)
  ├── if dragStart is null: return
  ├── t = position.resolve(windowed, pxPerHr)
  ├── proposed = derive(t)
  ├── dragStart = null
  ├── card.toggleAttribute("dragging", false)
  ├── removeGhost()
  └── await target.setTime(record.ref, proposed)
        │
        ▼
   TaskMutationTarget.setTime
        ├── looks up the producer-row task client by record.ref.rowId
        └── calls client.setTime(record.ref.itemId, proposed)
              │
              ▼
         Producer task client
              ├── optimistic local update (if supported)
              ├── network write
              └── emits row invalidation when ack'd

The handler's await resolves when the target write is acknowledged. At that moment the local row store has been updated (optimistically, typically) and the rest of the system is told to re-render.

Phase 5 — invalidation

producer client emits invalidation for record.ref.rowId
  │
  ▼
SheetViewModel observes the row change
  │
  ▼
sheet diffs the affected rows; schedules rebind for changed cells

This phase happens regardless of whether the gesture started here — the same invalidation path fires when someone else edits the same event from another client.

Phase 6 — rebind

sheet rebinds affected cells
  │
  ▼
CalendarDaysRow.renderSpans (same as Phase 1)
  ├── awaits subcolumn.makeSpans(windowed, tzr)  // fresh fetch
  │     ├── records returned now reflect the committed change
  │     └── each $e is recycled by id when possible
  ├── for spans whose id is unchanged, recycles the existing wrapper
  └── for new ids, runs Phase 1 wrapping

The handler from Phase 1 was discarded after pointerUpCell (its dragStart is null and nothing references it). A fresh handler is built for the new record on the next makeSpans cycle.

Column-level lifecycle (gesture manager)

The lifecycle of a drag-to-create. Most of Phase 1 is shared — the key difference is the wrapper cell ends up with asEmptyCellDelegate(GestureInfo) instead of span.callbacks.

Phase 1 — bind / render (column-level)

TaskListPlugin.subcolumnProvider.subcolumnFromRow returns
  { renderer: "spans", makeSpans, makeLayout, createHandler }
                                                  │
        SubcolumnCreateHandler ────────────────────┘
        ├── onDragCreate(range, ctx) — what to do on commit
        ├── onClickCreate(position, ctx)
        ├── updateGhost(range)
        └── clearGhost()

CalendarDaysRow.renderSpans
  ├── for cells with no span at this time slot, the wrapper gets
  │   bindCallbacks(asEmptyCellDelegate(GestureInfo))
  └── GestureInfo carries createHandler so column gestures can call it

App-layer wiring (one-time):
  CalendarGesture.Manager.addGesture(new DragToCreateTask(handle, coord))

Phase 2 — pointer down (column-level)

user presses pointer on empty space
  │
  ▼
CalendarColumnCell.onPointerDown classifies handle (always null for empty)
  └── invokes asEmptyCellDelegate.onPointerDown({position, handle: null})
  │
  ▼
asEmptyCellDelegate forwards to CalendarGesture.Manager
  ├── for each registered gesture, in registration order:
  │     ├── if gesture.guard(info) is false: skip
  │     ├── if gesture.canHandle(event) is false: skip
  │     └── else: gesture.onPointerDown(...)
  └── gesture returns { finished: false } → stays active

If multiple gestures canHandle the event, the manager activates all of them concurrently. allowsConcurrentGestures() decides whether a strong gesture preempts weaker ones.

Phase 3 — pointer move (column-level)

user drags pointer
  │
  ▼
asEmptyCellDelegate.onPointerMove forwards to active gestures
  │
  ▼
DragToCreateTask.onPointerMove
  ├── computes endTime from position
  ├── info.createHandler.updateGhost(range)
  └── return { shouldCapturePointer: true }
        │
        ▼
   Cell honors return → setPointerCapture

The createHandler.updateGhost callback is the plugin's ghost renderer — the create flow has its own ghost concept distinct from the per-item handler ghost in the item-level path.

Phase 4 — pointer up (column-level)

user releases pointer
  │
  ▼
asEmptyCellDelegate.onPointerUpCell or onPointerUpColumn
  │
  ▼
DragToCreateTask.onPointerUpColumn
  ├── computes final range
  ├── info.createHandler.onDragCreate(range, ctx)
  │     │
  │     ▼
  │  Plugin's create flow:
  │     ├── reveal popover with create form
  │     ├── on form submit: construct new record
  │     └── target.create(/* row, range, fields */) or equivalent
  ├── info.createHandler.clearGhost()
  └── return { finished: true } → gesture removed from active set

Column-level gestures often defer the actual write until the user fills in a form. The gesture itself only opens UI; the form submit calls the mutation target. Phases 5–6 (invalidation, rebind) are identical to the item-level lifecycle.

Cancellation and cleanup

A gesture can be cancelled before commit. Sources of cancellation:

TriggerItem-level behaviorColumn-level behavior
User releases outside any cellonPointerUpCell still fires (cell captured); handler may treat as commit or no-oponPointerUpColumn fires; gesture decides
Strong gesture preemptsn/a (cell-bound capture isolates)onFinished(cancelledBy) called; clean up state, dismiss menus
Pointer cancelled (e.g. system gesture)lostpointercapture in browser; handler should reset on next interactiononFinished(null) called
Component disconnected mid-gestureDOM removal releases capture; no onPointerUpCell fires; handler is GC'dgesture stays active; clean up via coordinator.dismissOtherSystems if menu open

Handlers should be defensive about dragStart === null in onPointerUpCell (the user might have released without a prior move) and idempotent about removeGhost().

Where state lives at each phase

PhaseComponent ($card)HandlerWrapper cellSheet store
Bindbound from recordconstructed, dragStart=nullwraps card; bindCallbacks(handler.callbacks())row record
Pointer downdragging=true attrdragStart setrecording pointer id (not yet captured)unchanged
Pointer moveunchangeddragStart updated; ghost paintedcaptured (after first move)unchanged
Pointer updragging=falsedragStart=null; ghost removedcapture releasedoptimistic write applied
Invalidationunchangedunchanged (will be discarded)unchangedrow record updated
Rebindre-bound from new record (or recycled)new handler instancereused (same id) or replacedup to date

Debugging tips

  • Pointer events don't reach the handler. Check that Span.pointerEvents is "all" (not "none") and the wrapper cell's CSS pointer-events resolves to auto via --default-interactable. Inspect the cell's bindCallbacks registration in DevTools — there should be exactly one addPointerEvent registration per cell.
  • handle is always null. The cell only classifies edges when the offset is within 4px. Confirm the cell has the expected height (offsetHeight > 8px) and the user is actually pressing within the edge zone. The cell's editable attribute toggles the visual resize-handle indicators but does not change classification.
  • shouldCapturePointer doesn't capture. Verify the handler returns { shouldCapturePointer: true } from onPointerMove every move once the drag starts (not just the first move). The cell calls releasePointerCapture on any move that returns false.
  • Drag commits but the visual doesn't update. The producer client must emit invalidation for the affected row; without it, the sheet doesn't rebind. Check that target.setTime is awaiting through to the actual API write and that the optimistic local update is applied.
  • Stale handler captures old record. A handler closes over the record passed at construction. If the record changes in-flight (e.g. concurrent edit from another client), the handler will commit using the stale ref. Either re-bind on every render (Phase 1 already does this) or have the handler dereference through a LiveData<TaskRecord> instead of a plain field.

See also