Skip to content

Headless Controller

Use createPivotController to execute a portable report and build an authoring interface without creating a RevoGrid element. The controller owns accepted report state, pending changes, execution, and successful authoring history.

The @revolist/pivot/headless subpath supports ESM imports. It shares the package root’s runtime modules to preserve callback and report identity; it does not promise a smaller dependency footprint. The existing Pivot plugin remains the grid integration API. For a custom constructor on an existing grid, use its getAuthoringController() facade; do not mirror it with another headless owner. See Custom Integrations for complete checked recipes.

For new calculations, use the complete V2 numeric/scalar example, with reportVersion: 2 and a persisted execution pin. The V1 layout example below uses default callbacks. Controller scheduling changes responsiveness, not aggregate eligibility or own-grain totals; synchronous and cooperative execution use the same calculation policies.

Interactive example

Headless controller

Create controller idle/autoLoad, refresh and render model without grid

The example loads when visible.

import { createPivotController, type PivotReport } from '@revolist/pivot/headless';
const report: PivotReport = {
kind: 'revogrid:pivot.report',
version: 1,
fields: [
{ id: 'region', sourceProp: 'region' },
{ id: 'revenue', sourceProp: 'sales' },
],
rows: ['region'],
columns: [],
filters: [],
measures: [{ id: 'revenue-total', fieldId: 'revenue', aggregator: 'sum' }],
};
const controller = createPivotController({
report,
source: [{ region: 'North', sales: 10 }, { region: 'North', sales: 5 }],
});
await controller.refresh();
const snapshot = controller.getSnapshot();
console.log(snapshot.view?.model.source);

By default, creation validates the report and runtime bindings synchronously. It starts idle unless autoLoad: true is supplied. Use the runtime registries described in Portable Reports for custom callbacks, bound sources, and server adapters. There is no public executor override.

Large local reports can opt into cooperative preparation so the browser can process input, cancellation timers and repaint between calculation batches. The controller publishes one completed view; cancellation preserves the last committed view. Default execution retains its existing timing behavior.

let dataRevision = 0;
const controller = createPivotController({
report,
source: records,
localExecution: {
mode: 'cooperative',
budgetMs: 8,
sourceRevision: () => dataRevision,
},
});
const pending = controller.refresh();
// For example, call this from an application's Cancel control:
controller.cancel();
await pending;
// Advance the revision BEFORE modifying accepted source data.
dataRevision++;
records[0].sales = 25;
await controller.refresh();

Keep source membership, order and record values stable until an operation settles. sourceRevision() is checked with Object.is; return a changed scalar or object identity before changing source data. A detected revision change fails the operation without publishing mixed revisions. The engine does not freeze application records or deep-clone arbitrary objects, accessors or runtime callbacks. Unannounced nested changes cannot be reliably detected.

budgetMs is a target between task yields, defaults to 8, and must be greater than zero and at most 50. It is not a deadline or a hard limit on an application callback. Custom aggregators, accessors, parsers and stateful comparators remain opaque synchronous calls; one such call can block longer than the target. Stock calculation kernels, source admission, filtering and result ownership share resumable implementations with the synchronous path.

Report and query metadata are still owned and structurally validated synchronously when submitted. The task budget applies to the admitted operation; it does not make arbitrarily large intent documents or native JavaScript intrinsics preemptible.

With cooperative execution, source-dependent validation is asynchronous and completes before proposal callbacks or publication. Report and runtime metadata are still validated when creating the controller. Observe the operation outcome for source-validation errors. Changing the source revision also prevents reuse of cached calculations from the previous revision.

Starting a cooperative admission retires older pending work immediately. If the new candidate later fails validation or a proposal is prevented, the committed view and successful history remain available; the older operation does not resume. Controllers using synchronous execution retain their existing preflight and publication timing.

For an existing grid, configure the same runtime policy through grid.pivot.engine.localExecution and use pivotPlugin.getAuthoringController().cancel() on the owning plugin instance. Do not create a second controller for the same grid. Scheduling callbacks are runtime authority and are not serialized into portable reports. Remote adapters continue to own remote query scheduling and cancellation.

getSnapshot() returns the same reference until state changes. subscribe() does not call its listener immediately; read the initial snapshot yourself.

During loading, report and view retain the last usable pair. pendingReport exposes the accepted candidate. An execution failure sets status: 'error' and an error diagnostic while preserving that usable pair.

const unsubscribe = controller.subscribe(() => {
const state = controller.getSnapshot();
console.log(state.status, state.pendingReport, state.error);
});
const outcome = await controller.dispatch({
type: 'measure.configure',
measureId: 'revenue-total',
patch: { label: 'Revenue' },
});
if (outcome.status === 'failed') console.error(outcome.error.message);
unsubscribe();

Report data and owned view containers are readonly. Application records, runtime handles, and custom aggregate payloads remain application-owned. Nested opaque payloads are not recursively frozen; do not use them to change report state.

Commands address stable field and measure IDs. Repeated uses of one source field can therefore be configured independently. Moves use the destination index after removing the source item. Filters are independent of row/column assignment.

await controller.transaction([
{ type: 'measure.add', fieldId: 'revenue', measureId: 'average-revenue', aggregator: 'avg' },
{ type: 'measure.configure', measureId: 'average-revenue', patch: { label: 'Average revenue' } },
{ type: 'totals.configure', patch: { grandTotal: true } },
]);
// Delete an optional setting explicitly.
await controller.dispatch({
type: 'measure.configure',
measureId: 'average-revenue',
unset: ['label'],
});
await controller.undo();
await controller.redo();

A transaction validates its final references and commits as one history entry. Accepted edits queued within one microtask compose on pending intent and can share one execution. Superseded operations settle as superseded; invalid commands throw before changing state. A failed operation does not advance the history cursor. historyLimit defaults to 100; zero disables undo history. reset() restores the initial report as an authoring operation.

Call replaceSource(records) when replacing application records, or refresh() after mutating records in place. Source, runtime replacement, and refresh are execution barriers and preserve accepted pending authoring choices.

If new source execution fails, sourceRevision describes the current input intent while viewSourceRevision identifies the input behind the retained view. replaceRuntime(runtime) explicitly replaces execution bindings and executes; this differs from the plugin’s nonexecuting setPivotReportRuntime defaults.

cancel() promptly settles pending work even when a remote adapter ignores its abort signal. Late results cannot publish. dispose() cancels work and removes subscriptions. Call it when the owning component is destroyed.

For persistence, use getReport() or exportReportJson(). Runtime callbacks and source records remain outside the JSON report and must be reattached by the app.

The optional constructor query and replaceQuery(query) describe execution scope separately from the authored report. sourceFilter uses tuple predicates before aggregation; having addresses measure IDs after aggregation. sort uses active axis fields and optional bySummary measure IDs. Bare string/number selectors address physical properties; { kind: 'field', fieldId } addresses a report field. The Portable Reports query example shows the complete typed shape and version admission rules.

rowOffset, rowLimit, columnOffset, and columnLimit are nonnegative safe integers. Zero means an empty analytical window; omitted local windows include the complete result. replaceQuery({}) clears public query intent, preserving report member selections. Query changes are outside authoring undo/redo history, and snapshot.query describes the last committed view.

OperationPurpose
replaceReport(report)Validate and execute another report in the existing owner.
dispatch(command) / transaction(commands)Apply one authoring edit or one atomic batch.
replaceQuery(query)Replace the public execution scope.
replaceSource(records) / replaceRuntime(runtime)Replace application inputs and execute.
refresh()Execute current intent, including in-place record changes.
undo() / redo() / reset()Navigate successful authored changes or restore the initial report.
getReport() / exportReportJson()Read portable authored state for application-owned persistence.
cancel() / dispose()Cancel pending execution or end the owner’s lifetime.

Async execution operations resolve to a PivotControllerOutcome: committed, noop, prevented, superseded, cancelled, or failed. Only committed publishes a new revision. For failed, inspect outcome.error and the retained snapshot. Invalid command/query/report inputs can throw during validation before execution; handle both exceptions and failed outcomes in application controls. Subscriptions take no snapshot argument: read controller.getSnapshot() inside the listener and unsubscribe on cleanup.

Runtime limits, accounting units and recovery.