Thank you for being patient! We're working hard on resolving the issue
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.
┌─────────────────────────────────────────────────────────────────────────┐
│ 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() │
└─────────────────────────────────────────────────────────────────────────┘
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
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.
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.
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.
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.
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 columnrows.{rowId} subsumes rows.{rowId}.cells.*sheets.*.rows (structural) does NOT subsume rows.* (data) —
they're orthogonal| Event | Current Path | Proposed Path | Change |
|---|---|---|---|
NewRowInserted | sheets.*.rows | sheets.*.rows | ✓ (already correct) |
RowsReordered | sheets.*.rows.reorder | sheets.*.rows.reorder | ✓ |
SheetStructureChanged | sheets.*.structure | sheets.*.structure | ✓ |
RowDataChanged | rows.{rowId} | rows.{rowId} | ✓ (intentionally separate from sheets) |
RowHeightChanged | rows.{rowId}.heightPx | rows.{rowId}.heightPx | ✓ |
RowLegendChanged | rows.{rowId}.label | rows.{rowId}.label | ✓ |
ColumnDataChanged | rows.*.cells.{column} | sheets.*.rows.cells.{column} + rows.{rowId}.cells.{column} | SDK emits both; consumer subsumes |
TimepointsChanged | calendars.events.{range} | rows.{rowId}.cells.{column} | Merged into cell path |
TasksChanged | tasks | rows.{rowId}.cells.{column} | Merged into cell path |
CalendarVisibilityChange | calendars.visibility | rows.{rowId}.cells.* | Visibility = which cells render |
ViewportsInvalidated | calendars.events | rows.*.cells | Column 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.Events describe what changed. Each carries a dotted path used for pattern matching and an actor for deduplication.
sheets.* — Sheet ↔ Row Relationship| Event | Path | Payload | Triggered By |
|---|---|---|---|
SheetListChanged | sheets | — | Sheet list change |
SheetMetadataChanged | sheets.{sheetId} | sheetId | Title, description |
SheetStructureChanged | sheets.*.structure | — | Row type change |
FrozenRowsChanged | sheets.*.numFrozenRows | — | Freeze/unfreeze |
NewRowInserted | sheets.*.rows | rowId | Row added to sheet |
RowsReordered | sheets.*.rows.reorder | — | Row reorder |
ColumnDataChanged | sheets.*.rows.cells.{column} | column | Column data arrived (sync, prefetch) |
rows.* — Core Row Data| Event | Path | Payload | Triggered By |
|---|---|---|---|
RowDataChanged | rows.{rowId}[.field] | rowId, field? | Attribute, cell, or formula change |
RowHeightChanged | rows.{rowId}.heightPx | rowId | Drag resize |
RowLegendChanged | rows.{rowId}.label | rowId | Label edit |
ColumnDataChanged | rows.{rowId}.cells.{column} | rowId, column | Cell data change (proposed) |
TimepointsChanged | calendars.events.{range} | range | Calendar event update (legacy — migrate to rows.*.cells.*) |
TasksChanged | tasks | — | Task change (legacy — migrate to rows.*.cells.*) |
user.preferences.* — User Settings| Event | Path | Triggered By |
|---|---|---|
TimezoneChange | user.preferences.timezone | Timezone setting |
ThemeChange | user.preferences.theme | Dark mode toggle |
CalendarPreferencesChanged | user.preferences.calendar | Calendar settings |
ui.* — UI State| Event | Path | Triggered By |
|---|---|---|
LayoutChanged | ui.layout | Row reorder, height preference |
WindowResize | ui.viewport | Browser resize |
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 Pattern | Effects |
|---|---|
rows.*.heightPx | Layout* |
ui.layout | Layout*, ViewportCallbacks, DrawerContent |
ui.viewport | CellWidth, FixedChildPos |
sheets.*.rows | Layout*, CellContentAll, LegendContent, DrawerContent |
sheets.*.rows.reorder | Layout*, CellContentAll, LegendContent, DrawerContent |
sheets.*.structure | Layout*, CellContentAll, LegendContent, DrawerContent |
sheets.*.numFrozenRows | Layout*, CellContentAll, LegendContent, DrawerContent |
rows.*.attributes | Layout*, CellContentAll, LegendContent, DrawerContent |
rows.* | CellContentRow |
rows.*.cells | CellContentColumn |
rows.*.label | LegendRow |
calendars.events.* | CellContentRange |
calendars.events | ViewportCallbacks |
user.preferences.timezone | CellContentAll, LegendContent |
user.preferences.theme | CellContentAll, Decorations |
user.preferences.calendar | CellContentAll |
tasks | CellContentAll |
calendars.visibility | CellContentAll |
sheets | CellContentAll |
*Layout = LegendMeasurement, CellHeights, StaticChildPos, FixedChildPos, BodyMarginTop, LegendHeights, Decorations
Higher-level effects cancel lower-level ones in the same frame:
CellContentAll subsumes CellContentRow, CellContentColumn,
CellContentRangeLegendContent subsumes LegendRowEffects 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
The Sheet component exposes multiple rendering methods at different granularity levels. Higher-level methods call lower-level ones.
| Method | When | What it does |
|---|---|---|
bindSheet(opts) | Sheet change, structure change | Full layout + scroll engine bind + cell creation |
rebindStructure() | NewRowInserted | bindSheet(contextDirtied: true) + deferred updateStaticChildSpans |
rebindAllLegend() | Legend content invalidation | bindSheet(rebindLegend: true, contextDirtied: false) |
| Method | When | What it does |
|---|---|---|
rebindAll(context) | CellContentAll (no structural change) | emit.all() on existing cells + updateStaticChildSpans |
rebindRow(row) | CellContentRow | binder.rebind() for one row across all columns |
rebindColumn(key) | CellContentColumn | Rebind one column |
rebindTimepoints(range) | CellContentRange | Rebind columns overlapping time range |
| Method | When | What it does |
|---|---|---|
rebindCellHeights() | cell-heights effect, drag resize | Sync DOM cell heights from layout spec |
rebindStaticChildPositions() | static-child-pos effect | Reposition spanned static children |
rebindFixedChildPositions() | fixed-child-pos effect | Reposition fixed children (timeline, etc.) |
rebindBodyMarginTop() | body-margin-top effect | Update header offset CSS variable |
rebindScrollHeight() | Height changes | Update scroll container height |
renderHeightOnly() | During drag resize | Bundle of height + position rebinds |
invalidateRowHeight() | Row height preference change | Clear layout cache + bindScrollProps + rebind |
The scroll engine manages a virtual list of columns. Physical DOM cells are recycled as the user scrolls.
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 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().
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.