Skip to content

Detached Data view: keep the committed result visible during streaming reruns #198

Description

@BorisTyshkevich

Problem

The detached Data view is already dynamic: changing a filter or clicking Refresh reruns the captured query through the shared streaming read seam.

During that rerun, the current implementation paints the in-flight result on every network chunk:

onChunk: () => {
  if (myGen === gen && !closed) paint(result);
}

paint(result) rebuilds the complete Table / JSON / Panel surface.

This causes two visible problems.

Empty-result flash

A chunk may contain result metadata before it contains any data rows.

At that moment:

result.columns.length > 0
result.rows.length === 0

The shared result renderer displays:

Query returned 0 rows.

Rows may arrive in the next chunk, so this message is temporarily false.

Chart churn

When Panel is selected, every chunk:

  1. destroys the current Chart.js instance;
  2. rebuilds the Panel view;
  3. constructs a new Chart.js instance.

The final result is correct, but a long streaming query can repeatedly recreate the chart while data is still arriving.

Relationship to the dashboard

This is the detached-view counterpart of the streaming UI decision already implemented for dashboard tiles.

Dashboard tiles now use the same app.runReadInto() transport, but their onChunk handler updates only the loading label:

onChunk: () => {
  label.textContent =
    'Loading… ' + formatRows(result.progress.rows) + ' rows';
}

Panel classification and rendering happen once, after the request completes successfully.

The detached view should follow the same commit-on-success UI policy:

stream transport progressively
→ update lightweight progress only
→ keep the previous committed result visible
→ render the new result once on success

This issue does not change dashboard behavior.

Goal

While a detached query reruns:

  • keep the previous committed Table / JSON / Panel visible;
  • show a lightweight running/progress status;
  • do not render the in-flight result;
  • do not destroy or recreate the current chart per chunk;
  • replace the visible result once, only after a successful current-generation completion.

On failure, cancellation, supersession, or close, keep the previous committed result.

UX

Start

Before Refresh:

42 rows                         Copy

[previous committed result]

After Refresh starts:

42 rows                         Copy

Filters…   Refresh disabled   Running…

[previous committed result remains visible]

Progress

As streaming progress arrives:

Running… 12,400 rows read

Use the streamed progress counter only as progress text. It is not the committed result row count and must not replace the toolbar's existing committed-row statistic.

If no useful count has arrived, display:

Running…

Success

After a successful completion:

  • assign the new result as current;
  • update the committed row statistic;
  • repaint the selected Table / JSON / Panel once;
  • record the winning request's bound parameters once;
  • clear the running status;
  • re-enable Refresh.

Error

On error:

  • leave current unchanged;
  • leave its rendered view unchanged;
  • show the error in the status line;
  • record no recent values;
  • re-enable Refresh.

There is no need to repaint current after the error because the in-flight result was never painted.

Superseded request

When a newer rerun supersedes an older one:

  • abort the older request;
  • ignore every late chunk and completion from it;
  • let only the newest request control the status, Refresh button, commit, and recent-value recording.

Close

Closing the detached view aborts the active request and prevents all later UI writes.

Implementation

src/ui/results.js

Keep the existing:

  • shared parameter analysis;
  • prepared batch;
  • token preflight;
  • captured session;
  • generation guard;
  • AbortController;
  • app.runReadInto() call;
  • success-only recent-value recording.

Change only the in-flight presentation policy.

Do not paint in-flight results

Replace:

onChunk: () => {
  if (myGen === gen && !closed) paint(result);
}

with a progress-only callback:

onChunk: () => {
  if (myGen !== gen || closed) return;

  const rowsRead = Number(result.progress?.rows) || 0;
  setStatus(
    rowsRead > 0
      ? `Running… ${formatRows(rowsRead)} rows read`
      : 'Running…',
  );
}

Equivalent wording and factoring are acceptable.

The callback must not call:

paint(result)

and must not modify:

current
statEl
view.current
sort
widths
panelState
chartInstance

Start state

When the request begins:

running = true;
refreshBtn.disabled = true;
setStatus('Running…');

The already-rendered current result stays in the DOM.

Completion

After runReadInto():

if (myGen !== gen || closed) return;

Then handle outcomes in this order:

if (result.cancelled) {
  settle('');
  return;
}

if (result.error) {
  settle(result.error);
  return;
}

current = result;
app.recordBoundParams(
  src.statements.flatMap((statement) => statement.boundParams),
);
settle('');
paint();

A superseded or close-triggered abort normally fails the generation/closed guard first. The explicit result.cancelled branch is still required as defensive behavior for a current-generation cancellation.

Do not call:

paint(current)

on failure or cancellation.

Toolbar row statistic

The toolbar statistic describes the committed current result.

It must not change during streaming.

It updates only from the final:

paint()

after current = result.

Chart ownership

The existing paint() function remains responsible for destroying the previous committed chart before replacing the result view.

Because paint() now runs only on:

  • view changes;
  • local Table sorting;
  • initial render;
  • successful result commit;

the active chart is not churned by network chunks.

Shared-code boundary

The following layers are already correctly shared between workbench, detached view, and dashboard:

  • app.runReadInto() — request, streaming parse, and normalized error/cancel result;
  • parameter analysis and preparation;
  • buildFilterBar();
  • renderResultView() for Table / JSON / Panel dispatch;
  • renderResolvedPanel() and the Panel registry.

Do not introduce a generic “refreshable query surface” abstraction in this issue.

Detached and dashboard presentation semantics differ:

  • detached keeps the previous committed result visible;
  • dashboard replaces each slot with a loading placeholder;
  • dashboard participates in a bounded multi-tile wave and reserves generations before queueing;
  • detached is one locally owned request with captured session state.

A narrow reusable latest-request controller is tracked separately if desired; it is not required for this fix.

Tests

Previous result remains during chunks

Configure runReadInto() so the test can:

  1. emit a metadata-only chunk;
  2. emit a row chunk;
  3. resolve later.

After each chunk, verify:

  • the previous committed table remains visible;
  • its row count remains unchanged;
  • Query returned 0 rows. never appears;
  • Refresh remains disabled;
  • the status says Running… or reports rows read.

Commit once on success

Before resolving, place new rows in the in-flight result.

Verify before completion:

  • old rows remain visible;
  • Copy still targets the old committed result;
  • no recent values are recorded.

Resolve the request.

Verify:

  • new rows become visible;
  • Copy now targets the new committed result;
  • the committed row statistic updates;
  • recent values are recorded exactly once;
  • Refresh is re-enabled;
  • status is cleared.

Panel chart does not churn

Open the detached view on Panel and record the current Chart.js instance.

Emit multiple chunks without resolving.

Verify:

  • the original chart is not destroyed;
  • no additional chart instances are created.

Resolve successfully.

Verify:

  • the old chart is destroyed once;
  • one replacement chart is created for the committed result.

Error

Emit any number of chunks, then complete with:

result.error = 'Boom';

Verify:

  • the previous committed result remains visible throughout;
  • no repaint is required to restore it;
  • the status displays Boom;
  • recent values are not recorded;
  • Refresh is re-enabled.

Stale and cancellation behavior

Verify:

  • a newer rerun aborts the previous signal;
  • late chunks from the previous run do not update status;
  • late completion from the previous run does not commit or record recents;
  • a current-generation cancelled result does not replace the committed result;
  • closing aborts the request and prevents later status or paint calls.

Regression

Verify:

  • opening the detached view still issues no request;
  • filter gating still prevents invalid requests;
  • originating session_id is still passed;
  • token-preflight failure leaves the previous result and re-enables Refresh;
  • switching Table / JSON / Panel remains local and executes no SQL;
  • dashboard progress-only rendering remains unchanged;
  • workbench streaming behavior is not changed by this issue.

Files

Expected changes:

  • src/ui/results.js
    • replace per-chunk surface repaint with status-only progress;
    • commit and paint only after successful completion;
    • remove failure-time restore repaint.
  • tests/unit/results.test.js
    • metadata-only chunk;
    • previous-result retention;
    • success commit;
    • chart-instance churn;
    • error, stale, cancel, and close coverage.

No new runtime dependency.

Acceptance criteria

  • Streaming chunks never render the detached in-flight result.
  • A metadata-only chunk never flashes Query returned 0 rows.
  • The previous committed result remains visible until success.
  • The committed toolbar row count does not change during streaming.
  • Panel charts are not destroyed or recreated per chunk.
  • A successful current-generation request commits and repaints exactly once.
  • Failed, cancelled, stale, and closed requests never replace the committed result.
  • Winning bound parameters are recorded once; all other outcomes record nothing.
  • Refresh and status state settle correctly on every path.
  • Dashboard and workbench behavior remain unchanged.
  • Coverage gates hold; no new runtime dependency.

Non-goals

  • Progressive Table, JSON, or Panel rendering.
  • Changing workbench streaming behavior.
  • Changing dashboard tile streaming behavior.
  • Moving Refresh outside the detached filter row.
  • Changing result caps or session behavior.
  • A broad generic query-surface framework.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions