Something went wrong

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

Sheet Invalidation & Rendering - Lona Docs Log in

Sheet Invalidation & Rendering

The invalidation system is the bridge between data changes in the SDK and visual updates in the sheet UI. It uses path-based event routing to map what changed to which UI effects to run.

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                            SDK Layer (lona-js)                          │
│                                                                         │
│  SheetHandle    SheetRow    EventsStore    RowsAccessor                 │
│       │             │            │              │                       │
│       └──────┬──────┘            │              │                       │
│              ▼                   ▼              ▼                       │
│         Invalidation.emit(event)                                        │
│              │                                                          │
│              ▼                                                          │
│     InvalidationSinkImpl                                                │
│       .when("path.pattern", Effect1, Effect2, ...)                      │
│              │                                                          │
│              ▼                                                          │
│     onEffect(event) → resolve effects from path                         │
└──────────────────────────────┬──────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                       App Layer (lona-so)                               │
│                                                                         │
│  InvalidationTransaction.Factory                                        │
│    .current().addEffect(effectId, event)                                │
│    $$.mutate(() => flush())  ← batched per frame                        │
│              │                                                          │
│              ▼                                                          │
│  Effect Handler (wireSheetInvalidation)                                 │
│    1. Subsume: CellContentAll removes CellContentRow, etc.              │
│    2. Execute in order: layout → content → legend → decorations         │
└──────────────────────────────┬──────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                     UI Layer (lona-sdk-ui-js)                           │
│                                                                         │
│  Sheet Component                                                        │
│    rebindCellHeights()      ← cell-heights effect                       │
│    rebindAll()              ← cell-content-all effect                   │
│    rebindStructure()        ← cell-content-all + NewRowInserted         │
│    rebindRow(row)           ← cell-content-row effect                   │
│              │                                                          │
│              ▼                                                          │
│  Scroll Engine                                                          │
│    emit.all() → queueOnBind() → $$.measure() → onBind(cell)             │
│              │                                                          │
│              ▼                                                          │
│  DefaultBinder.onBind()                                                 │
│    RenderPlan → applyPlanFullRebind → Plugin.bindCell()                 │
└─────────────────────────────────────────────────────────────────────────┘

Path Taxonomy

Paths follow a hierarchical resource model. Each segment represents a level in the data hierarchy. * is a wildcard matching any single segment.

user
├── preferences
│   ├── timezone                   TimezoneChange
│   ├── theme                      ThemeChange
│   └── calendar                   CalendarPreferencesChanged
└── sheets                         SheetListChanged
    └── {sheetId}                  SheetMetadataChanged
        ├── structure              SheetStructureChanged
        ├── numFrozenRows          FrozenRowsChanged
        └── rows                   NewRowInserted (sheet↔row relationship)
            ├── reorder            RowsReordered
            └── cells
                └── {column}       column data arrived for this sheet

rows                               core row data (independent of sheet context)
└── {rowId}                        RowDataChanged
    ├── heightPx                   RowHeightChanged
    ├── label                      RowLegendChanged
    ├── attributes                 RowDataChanged(field="attributes")
    └── cells                      cell data
        └── {column}               single cell or column changed

ui
├── layout                         LayoutChanged (geometry recompute)
└── viewport                       WindowResize

Design principles

  1. sheets.*.rows = sheet↔row relationship. Insert, delete, reorder — operations that change which rows belong to a sheet and in what order. Does NOT describe row content changes.

  2. rows.* = core row attributes. Height, label, attributes, cells — the row's own data, independent of which sheet it belongs to. A row can appear in multiple views; its data is global.

  3. rows.{rowId}.cells.{column} = unified cell invalidation. Calendar events, tasks, weather, and user-entered data all flow through the same cell path. The SDK emits rows.{rowId}.cells.{column} for each affected cell. The consumer checks the row type at the application layer to decide rendering strategy. This replaces the separate calendars.events.*, tasks, and calendars.visibility paths.

  4. Column propagation. When a formula row depends on another row, changing a cell in row A can invalidate the same column in row B. The SDK emits individual rows.{rowId}.cells.{column} events per affected row. Consumers can subsume: if many rows in the same column changed, treat as a column-level invalidation.

  5. SDK emits both granularities. When a column of data arrives (e.g., sync delivers weather for April 15), the SDK emits:

    • rows.{rowId}.cells.{column} for each affected row (fine-grained)
    • sheets.*.rows.cells.{column} for the sheet-level column (coarse)

    The consumer subsumes as appropriate:

    • sheets.*.rows.cells.{column} subsumes individual rows.{rowId}.cells.{column} for the same column
    • rows.{rowId} subsumes rows.{rowId}.cells.*
    • sheets.*.rows (structural) does NOT subsume rows.* (data) — they're orthogonal

Current paths vs proposed

EventCurrent PathProposed PathChange
NewRowInsertedsheets.*.rowssheets.*.rows✓ (already correct)
RowsReorderedsheets.*.rows.reordersheets.*.rows.reorder
SheetStructureChangedsheets.*.structuresheets.*.structure
RowDataChangedrows.{rowId}rows.{rowId}✓ (intentionally separate from sheets)
RowHeightChangedrows.{rowId}.heightPxrows.{rowId}.heightPx
RowLegendChangedrows.{rowId}.labelrows.{rowId}.label
ColumnDataChangedrows.*.cells.{column}sheets.*.rows.cells.{column} + rows.{rowId}.cells.{column}SDK emits both; consumer subsumes
TimepointsChangedcalendars.events.{range}rows.{rowId}.cells.{column}Merged into cell path
TasksChangedtasksrows.{rowId}.cells.{column}Merged into cell path
CalendarVisibilityChangecalendars.visibilityrows.{rowId}.cells.*Visibility = which cells render
ViewportsInvalidatedcalendars.eventsrows.*.cellsColumn viewport change

Migration notes:

  • calendars.events.* / tasks / calendars.visibility collapse into rows.*.cells.*. The application layer checks row.type to decide whether to fetch from EventsStore, TasksStore, etc.
  • ColumnDataChanged becomes per-row (rows.{rowId}.cells.{column}) instead of wildcard (rows.*.cells.{column}). When all rows in a column change, emit N events and let the consumer subsume.

Invalidation Events

Events describe what changed. Each carries a dotted path used for pattern matching and an actor for deduplication.

sheets.* — Sheet ↔ Row Relationship

EventPathPayloadTriggered By
SheetListChangedsheetsSheet list change
SheetMetadataChangedsheets.{sheetId}sheetIdTitle, description
SheetStructureChangedsheets.*.structureRow type change
FrozenRowsChangedsheets.*.numFrozenRowsFreeze/unfreeze
NewRowInsertedsheets.*.rowsrowIdRow added to sheet
RowsReorderedsheets.*.rows.reorderRow reorder
ColumnDataChangedsheets.*.rows.cells.{column}columnColumn data arrived (sync, prefetch)

rows.* — Core Row Data

EventPathPayloadTriggered By
RowDataChangedrows.{rowId}[.field]rowId, field?Attribute, cell, or formula change
RowHeightChangedrows.{rowId}.heightPxrowIdDrag resize
RowLegendChangedrows.{rowId}.labelrowIdLabel edit
ColumnDataChangedrows.{rowId}.cells.{column}rowId, columnCell data change (proposed)
TimepointsChangedcalendars.events.{range}rangeCalendar event update (legacy — migrate to rows.*.cells.*)
TasksChangedtasksTask change (legacy — migrate to rows.*.cells.*)

user.preferences.* — User Settings

EventPathTriggered By
TimezoneChangeuser.preferences.timezoneTimezone setting
ThemeChangeuser.preferences.themeDark mode toggle
CalendarPreferencesChangeduser.preferences.calendarCalendar settings

ui.* — UI State

EventPathTriggered By
LayoutChangedui.layoutRow reorder, height preference
WindowResizeui.viewportBrowser resize

Effect Mapping

The InvalidationSinkImpl maps event paths to effects using wildcard patterns. Multiple events can map to the same effect, and one event can trigger multiple effects.

Path → Effects Table

Path PatternEffects
rows.*.heightPxLayout*
ui.layoutLayout*, ViewportCallbacks, DrawerContent
ui.viewportCellWidth, FixedChildPos
sheets.*.rowsLayout*, CellContentAll, LegendContent, DrawerContent
sheets.*.rows.reorderLayout*, CellContentAll, LegendContent, DrawerContent
sheets.*.structureLayout*, CellContentAll, LegendContent, DrawerContent
sheets.*.numFrozenRowsLayout*, CellContentAll, LegendContent, DrawerContent
rows.*.attributesLayout*, CellContentAll, LegendContent, DrawerContent
rows.*CellContentRow
rows.*.cellsCellContentColumn
rows.*.labelLegendRow
calendars.events.*CellContentRange
calendars.eventsViewportCallbacks
user.preferences.timezoneCellContentAll, LegendContent
user.preferences.themeCellContentAll, Decorations
user.preferences.calendarCellContentAll
tasksCellContentAll
calendars.visibilityCellContentAll
sheetsCellContentAll

*Layout = LegendMeasurement, CellHeights, StaticChildPos, FixedChildPos, BodyMarginTop, LegendHeights, Decorations

Subsumption Rules

Higher-level effects cancel lower-level ones in the same frame:

  • CellContentAll subsumes CellContentRow, CellContentColumn, CellContentRange
  • LegendContent subsumes LegendRow

Execution Order

Effects execute in a fixed order within each frame. Layout effects run first so geometry is correct before content binds.

 1. legend-measurement     Measure legend width
 2. cell-width             Compute cell widths
 3. body-margin-top        Set body offset + footer geometry
 4. cell-heights           Bind row heights to DOM
 5. legend-heights         Set legend heights
 6. static-child-pos       Position static children (spanned rows)
 7. fixed-child-pos        Position fixed children (timeline events, etc.)
 ─── layout complete ───
 8. cell-content-all       Full cell rebind (or rebindStructure if NewRowInserted)
 9. cell-content-row       Update specific row
10. cell-content-column    Update specific column
11. cell-content-range     Update time range
 ─── content complete ───
12. legend-content         Full legend rebind
13. legend-row             Update specific legend
14. viewport-callbacks     Emit viewport events to plugins
15. drawer-content         Update detail panel
 ─── legend + callbacks ───
16. decorations            Render selection borders, presence overlays

Sheet Rendering Methods

The Sheet component exposes multiple rendering methods at different granularity levels. Higher-level methods call lower-level ones.

Full Rendering

MethodWhenWhat it does
bindSheet(opts)Sheet change, structure changeFull layout + scroll engine bind + cell creation
rebindStructure()NewRowInsertedbindSheet(contextDirtied: true) + deferred updateStaticChildSpans
rebindAllLegend()Legend content invalidationbindSheet(rebindLegend: true, contextDirtied: false)

Content Rendering

MethodWhenWhat it does
rebindAll(context)CellContentAll (no structural change)emit.all() on existing cells + updateStaticChildSpans
rebindRow(row)CellContentRowbinder.rebind() for one row across all columns
rebindColumn(key)CellContentColumnRebind one column
rebindTimepoints(range)CellContentRangeRebind columns overlapping time range

Layout Rendering

MethodWhenWhat it does
rebindCellHeights()cell-heights effect, drag resizeSync DOM cell heights from layout spec
rebindStaticChildPositions()static-child-pos effectReposition spanned static children
rebindFixedChildPositions()fixed-child-pos effectReposition fixed children (timeline, etc.)
rebindBodyMarginTop()body-margin-top effectUpdate header offset CSS variable
rebindScrollHeight()Height changesUpdate scroll container height
renderHeightOnly()During drag resizeBundle of height + position rebinds
invalidateRowHeight()Row height preference changeClear layout cache + bindScrollProps + rebind

Scroll Engine Cell Lifecycle

The scroll engine manages a virtual list of columns. Physical DOM cells are recycled as the user scrolls.

Cell Creation

Cells are only created by bindIndex() (called from bindSheet). The scroll engine maintains a pool of physical elements sized to fill the viewport plus a padding buffer.

bindIndex(index, { contextDirtied })
  ├── markContextDirtied()     if contextDirtied
  ├── extendToIdxRange()       compute visible range
  ├── applyTransaction()       create/recycle cells to fill viewport
  └── emit.all(false)          if contextDirtied → full rebind all cells

Key: emit.all() iterates existing physical cells only. It does not create new cells. New cells are only created by applyTransaction() during bindIndex(). This is why rebindAll() (which calls emit.all() without bindIndex()) cannot render newly added rows.

onBind Batching

onBind calls are batched via queueOnBind() and executed in the next $$.measure() phase:

emit.all() / emit.indices()
  └── queueOnBind(cell, index, offsetChangedOnly)
        └── adds to onBindBatch map
        └── if first in batch: $$.measure(() => {
              snapshot batch → clear batch → iterate:
                delegate.onBind(cell, index, width, offsetChangedOnly)
            })

This means onBind fires asynchronously (next fastdom measure phase), not synchronously during emit.all(). Code that reads registrations created during onBind must defer via $$.mutate().

Structural Changes (NewRowInserted)

When a row is added, the scroll engine's cell pool doesn't include the new row. The rendering path is:

pushTopLevelRow(id)
  └── emit NewRowInserted(rowId)
        └── cell-content-all effect
              └── detects NewRowInserted in sources
              └── calls rebindStructure()
                    └── bindSheet(contextDirtied: true)
                          └── bindIndex() creates new cells
                          └── emit.all() triggers onBind
                    └── $$.mutate(() => updateStaticChildSpans())
                          └── runs AFTER onBind (registrations exist)
                          └── creates fixed children for span-stacked rows

The $$.mutate() deferral is critical — onBind fires in $$.measure() (read phase), so updateStaticChildSpans must wait for the write phase.