Skip to content

fix(log-viewer): mark a caller row by the caller, not the calls it made - #980

Merged
lcottercertinia merged 3 commits into
certinia:mainfrom
lukecotter:fix-inspector-mark-rows
Sep 1, 2026
Merged

fix(log-viewer): mark a caller row by the caller, not the calls it made#980
lcottercertinia merged 3 commits into
certinia:mainfrom
lukecotter:fix-inspector-mark-rows

Conversation

@lukecotter

Copy link
Copy Markdown
Collaborator

Stacked on #977. Its commit shows in this diff until that merges. Review from be6412ea onward.

📝 PR Overview

Two bugs in the inspector's row mark, both about which rows light up.

A grid row told the inspector the wrong thing. A row under the pointer emitted every call it counts, so a Bottom Up caller row marked the inspector rows for the leaf calls underneath it rather than for the caller itself. The forward direction stopped doing that in #973; the reverse direction was still on the old rule, so the two disagreed depending on which side you pointed at.

A mark could come back after being dropped. The sweep reaches only the rows a table has attached. A row lit while on screen, then scrolled out, kept the class when the mark moved away, and the renderer re-attaches such a row without running the row formatter again, so the stale highlight returned with it.

🛠️ Changes made

  • rowFrames(row, root, direction) — a bottom-up caller row climbs to its own depth, depthOf(_pathId) - 1 hops above each call it counts. A top-down row already sits at its frames' depth, so it climbs nothing.
  • LogStore.framesAbove — the climb, next to stackByEventIndex, which already owned this parent-pointer walk. The inspector's frameEventIndexes now reads it too, so the two sides cannot drift.
  • The direction is read at hover time, from directionOf(this.viewMode), which the sibling _emitDetailSelection already uses. No new parameter and no second source of truth.
  • litByHost — what each table's mark has lit, whichever half lit it, so a new mark can un-light an element the renderer has since detached.
  • The detail:locate doc described the old rule; it now says what the protocol carries.

🧩 Type of change (check all applicable)

  • 🐛 Bug fix - something not working as expected
  • ♻️ Refactor - internal changes with no user impact
  • ✨ New feature – adds new functionality
  • ⚡ Performance Improvement
  • 📝 Documentation - README or documentation site changes
  • 🔧 Chore - dev tooling, CI, config
  • 💥 Breaking change

📷 Screenshots / gifs / video [optional]

N/A. What changes is which rows carry the highlight.

🔗 Related Issues

Follows #973, which changed the forward direction, and #975, which shipped the mark mechanism the second fix corrects.

✅ Tests added?

  • 👍 yes
  • 🙅 no, not needed
  • 🙋 no, I need help

Three rowFrames cases and one for the detached row. Each guard was proven by reverting the code it covers: dropping the direction check makes a top-down row climb; removing the shared climb fails one rowFrames test and two frameEventIndexes tests, which also shows the inspector test runs the real method rather than a copy of it; restoring the old clearing fails the detached-row test.

📚 Docs updated?

  • 🔖 README.md
  • 🔖 CHANGELOG.md
  • 📖 help site
  • 🧪 Marked any pre-release-only features
  • 🙅 not needed

No entry: both fixes correct the unreleased Inspector, so they belong to its existing entry. Ticked to record that it was considered.

Anything else we need to know? [optional]

Where to start. LogStore.framesAbove is the mechanism; rowFrames is the grid's use of it and frameEventIndexes the inspector's.

Why the mark has to remember what it lit. Tabulator builds a row's element once (Row.create() is guarded by `this.created`) and `Row.initialize()` re-uses it, and both `deinitialize()` calls in Tabulator are inside `reinitializeRows()`, a column-layout path. So an ordinary scroll neither rebuilds the element nor re-runs the formatter: the class persists, and clearing it has to reach elements the query cannot see.

On the dedupe. A merged row aggregates distinct caller frames that share a signature, so the climb is many-to-many and the answer can be as long as what was asked about. It is not safe to climb from one call and assume the rest agree.

Test plan.

  • `pnpm lint` and `pnpm test`.
  • Call Tree, Bottom Up, Inspector open. Hover a bucket row, then a caller row one level down: the inspector mark moves up the stack with you rather than staying on the leaf calls.
  • Step two and three levels up: one frame per level.
  • Analysis: same, hovering a row under a method bucket.
  • Aggregated and Time Order: unchanged, since a row there already sits at its own frames' depth.
  • Hover an inspector row so a grid row lights, scroll that row out of view, move the pointer off the inspector row, then scroll back: no highlight. That is the second fix.
  • Both themes, Inspector docked at the side and at the bottom.

Known unrelated failure locally. `lana/src/services/tests/servicesRuntime.test.ts` cannot resolve `effect` in a worktree not installed since #951. It passes in CI.

Follow-up, not in this PR. deriveCalls already walks each call's parents inside chainReaches and stops exactly at the frame the row is, then keeps the index and throws the frame away, so framesAbove re-walks the same edges. Fusing the two means having chainReaches return the node it stopped at.

Four views each answered three source-keyed events, so the same source test
sat in nine places and the same three unsubscribe fields in four. The bus
gains onSource, and wireInspectorTab names the set once: a view supplies
only what it does differently and gets one unsubscribe.
The sweep reaches only the rows a table has attached, so a row lit while on
screen kept the class when the mark moved away and it was scrolled out. The
formatter does not run again for a row the renderer only re-attaches, so the
old mark came back with it. litByHost records what either half lit.
A grid row under the pointer told the inspector every call it counts, so a
Bottom Up caller row marked rows for the leaf calls rather than for itself.
rowFrames climbs to the row's own depth, as the forward direction has since
certinia#973, reading the direction the table shows so a top-down row does not.
@lcottercertinia
lcottercertinia merged commit b9ed80d into certinia:main Sep 1, 2026
7 checks passed
lcottercertinia pushed a commit that referenced this pull request Sep 1, 2026
…g it (#981)

# 📝 PR Overview

The inspector's row mark reached a row two ways: a sweep of the rows
attached when the mark moved, and the row formatter as a row is built.
Neither reaches a row that was built earlier, detached when it scrolled
out of view, and only then named by a mark. The renderer re-attaches
such a row without re-running the formatter, so it comes back with no
mark. Scroll to it, or sort with it just off screen, and the highlight
is simply missing.

The fix watches the arrival: a `MutationObserver` for `childList` on the
element the renderer attaches rows to. A row entering the table is a
child mutation, so a scroll and a structural render are one case, and a
table destroyed and rebuilt into the same container is watched again
rather than going quiet.

## 🛠️ Changes made

- **`watchRenders(host)`** — one observer per marked table, established
on the first non-empty mark. The callback re-sweeps only while a mark is
set, and touches nothing but a class, so it cannot report itself back.
- **Keyed on the row-holding element, not the container.** Several views
destroy the table and build another in the same element;
`CallStackDetail` does it on every event change. Keying on the container
would leave the watch pointing at a destroyed table, and the old
observer is disconnected when a new one takes over.

## 🧩 Type of change (check all applicable)

- [x] 🐛 Bug fix - something not working as expected
- [ ] ✨ New feature – adds new functionality
- [ ] ♻️ Refactor - internal changes with no user impact
- [ ] ⚡ Performance Improvement
- [ ] 📝 Documentation - README or documentation site changes
- [ ] 🔧 Chore - dev tooling, CI, config
- [ ] 💥 Breaking change

## 📷 Screenshots / gifs / video [optional]

N/A. What changes is whether a row carries its highlight when the grid
hands it back.

## 🔗 Related Issues

Completes the row mark from #975 and #980.

## ✅ Tests added?

- [x] 👍 yes
- [ ] 🙅 no, not needed
- [ ] 🙋 no, I need help

Two cases: a row named while detached, and a table rebuilt into the same
container. Each guard was proven by reverting the code it covers — no
watch fails both; watching the spacer's `style` rather than the arrival
fails both; keying so a rebuild is skipped fails the second.

## 📚 Docs updated?

- [ ] 🔖 README.md
- [x] 🔖 CHANGELOG.md
- [ ] 📖 help site
- [ ] 🧪 Marked any pre-release-only features
- [ ] 🙅 not needed

No entry: this corrects the unreleased Inspector, so it belongs to its
existing entry. Ticked to record that it was considered.

## Anything else we need to know? [optional]

**Why not the signals that look more natural.** Two were tried and
rejected with evidence:

- **A scroll listener.** A structural render — sort, filter, column
show/hide, a tree re-expand of children already built — re-attaches an
initialised row with no formatter run and fires no scroll event at all.
The renderer also documents \`scrollend\` as unreliable: "The RAF
stability check deliberately replaces \`scrollend\`, which never fires
while the scrollbar thumb is held still"
(\`VirtualVerticalRenderer.ts:430\`).
- **The virtual spacer paddings.** Reading their values is unsound,
because \`ScrollAnchor\` writes them too, so a value can return to one
already swept while the window has moved. Observing the write instead is
no better: a CSSOM write of an unchanged value queues no mutation record
at all, so a sort at the top of a table reports nothing.

**Cost.** The observer fires when rows enter or leave, which is exactly
when work is due, and only while a mark is set. A sweep reads what the
table has attached — the viewport plus at most \`OVERSCAN_MAX\` rows
each side — never the row count, and the renderer's idle prewarm builds
cells with \`inFragment: true\` so it does not widen that. The callback
runs in a microtask after the render, and touches only \`classList\`, so
it forces no layout.

**Test plan.**

- \`pnpm lint\` and \`pnpm test\`.
- Call Tree, Bottom Up, Inspector open. Click a caller row so grid rows
mark, then **sort a column while at the top of the table**: the marks
survive. That is the case a value-based signal missed.
- Filter and clear it; collapse and re-expand a marked row's parent.
- Scroll past rows never rendered and back. Then click a row, scroll a
lit row out of view, press \`Escape\`, scroll back: no stale highlight.
- Large log with a mark set: scroll hard and watch for jank.
- Both themes, Inspector docked at the side and at the bottom.

**Known unrelated failure locally.**
\`lana/src/services/__tests__/servicesRuntime.test.ts\` cannot resolve
\`effect\` in a worktree not installed since #951. It passes in CI.

**Follow-up, not in this PR.** The renderer already knows which rows it
attached: \`allAttached\` is built in \`_attachRanges\`. Dispatching
that would make relighting O(rows attached) rather than O(rows
rendered), and passing the \`Tabulator\` to \`mark()\` instead of its
element — every one of the eight owners already holds it — would add
disposal on \`tableDestroyed\` and let \`Find\` drop its own scroll
listener onto the same event.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants