Skip to content

Server-Side Tree Data

Configure tree.remote on TreeDataPlugin for viewport-driven blocks of roots and direct children. Remote tree loading follows the same chunkSize, loadData(skip, limit, ...), { data, total } and query conventions as InfinityScrollPlugin. The tree plugin owns expansion and indentation and manages the remote primary row source.

Source code
import { defineCustomElements } from '@revolist/revogrid/loader';
import {
AdvanceFilterPlugin,
TreeDataPlugin,
ColumnStretchPlugin,
RowOddPlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import { createRemoteTreeColumns } from './tree.remote.utils';
import { startRemoteTree } from './tree.remote.bootstrap';
defineCustomElements();
export function load(parentSelector: string) {
const parent = document.querySelector(parentSelector);
if (!parent) throw new Error('Remote tree container not found.');
const shell = document.createElement('section');
shell.className = 'tree-remote-demo';
Object.assign(shell.style, {
minHeight: '400px',
display: 'flex',
flexDirection: 'column',
flex: '1 1 auto',
});
parent.appendChild(shell);
const description = document.createElement('p');
description.textContent =
'Read-only tasks. Expand a project to load its children; scroll, sort, or filter to query the server.';
shell.appendChild(description);
const lifetime = new AbortController();
const status = document.createElement('p');
status.setAttribute('role', 'status');
status.textContent = 'Starting demo backend…';
shell.appendChild(status);
let grid: HTMLRevoGridElement | undefined;
void startRemoteTree(lifetime.signal)
.then(({ tree, filter }) => {
if (lifetime.signal.aborted) return;
grid = document.createElement('revo-grid');
grid.className = 'overflow-hidden cell-border';
grid.style.minHeight = '400px';
grid.columns = createRemoteTreeColumns();
grid.filter = filter;
grid.stretch = 'all';
Object.assign(grid, { tree });
grid.plugins = [
TreeDataPlugin,
AdvanceFilterPlugin,
ColumnStretchPlugin,
RowOddPlugin,
];
grid.theme = currentTheme().isDark() ? 'darkMaterial' : 'material';
grid.hideAttribution = true;
shell.appendChild(grid);
grid.source = [];
status.remove();
})
.catch((error) => {
if (lifetime.signal.aborted) return;
status.setAttribute('role', 'alert');
status.textContent = error.message;
});
return () => {
lifetime.abort();
grid?.remove();
shell.remove();
};
}

The four-framework demo uses native fetch to query an HTTP backend, following the same approach as remote pagination. The backend owns direct-child paging, stable sibling sorting, filtering with retained ancestor paths, child markers, and exact totals. Expand a project to load its 150 tasks, then scroll to fetch more sibling blocks.

The client, bootstrap, contracts, seed, service, and HTTP routes are separate source tabs. Query owns caching, retries, freshness, and cancellation. Point the client’s API URL at your own endpoint to use this approach in an application.

import {
TreeDataPlugin,
createDataQueryContext,
type TreeRemoteConfig,
} from '@revolist/revogrid-pro';
type Row = {
id: string;
parentId: string | null;
hasChildren: boolean;
name: string;
};
const context = createDataQueryContext();
const source = context.source({
sourceId: 'task-tree-api', schemaId: '1',
tenantId: 'example-tenant', authorizationScope: 'read-only',
});
const config: TreeRemoteConfig<Row> = {
chunkSize: 100,
query: {
source,
resource: 'project-task-tree',
refetchInterval: 5_000,
refetchOnWindowFocus: true,
retry: 2,
},
async loadData(skip, limit, order, singleConditionFilters,
multiConditionFilters, quickFilter, temporalContext, filterAst,
executionContext, requestContext) {
const response = await fetch('/api/tree/children', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentId: requestContext?.parentId ?? null,
skip, limit, order, singleConditionFilters, multiConditionFilters,
quickFilter, temporalContext, filterAst, executionContext,
revision: requestContext?.revision,
}),
signal: requestContext?.signal,
});
if (!response.ok) throw new Error(`Tree read failed: ${response.status}`);
return response.json(); // { data: Row[], total: number, revision?: string }
},
};
const grid = document.createElement('revo-grid');
grid.style.height = '400px';
grid.columns = [{ prop: 'name', name: 'Name', tree: true, sortable: true }];
grid.tree = { rootParentId: null, remote: config };
grid.plugins = [TreeDataPlugin];
document.body.append(grid);
// On application teardown: remove the grids, then context.dispose().

Set configuration before mounting. Register only TreeDataPlugin; it manages remote loading when tree.remote is configured. Omit remote or set it to false for local tree data. Replace the tree configuration object to update remote options; mutating a nested field in place does not apply a new configuration. The first root block loads automatically. Expanding a parent loads its first child block; scrolling loads intersecting sibling blocks and one adjacent block on each side. A distant jump does not load intervening blocks.

Positions 1–9 match infinity scroll exactly: skip, limit, order, singleConditionFilters, multiConditionFilters, quickFilter, temporalContext, filterAst, executionContext. Position ten is the optional TreeRemoteLoadContext: { signal, parentId, revision? }. All ten positions are supplied at runtime; callbacks can declare only the arguments they need. The signal belongs to the shared physical read, so pass it to the transport.

parentId: null requests roots. skip is an offset among direct siblings, aligned to chunkSize, rather than a flattened grid index. Return TreeRemoteLoadDataResult<Row>:

{
"data": [
{ "id": "phase-a", "parentId": null, "name": "Phase A", "hasChildren": true },
{ "id": "phase-b", "parentId": null, "name": "Phase B", "hasChildren": false }
],
"total": 2
}

The tree requires an exact total after filtering to place random-access sibling slots. Unlike flat infinity scroll, an unknown total or a bare array is insufficient. hasMore may accompany the result, but does not replace total. Return exactly min(limit, max(0, total - skip)) direct children; exclude grandchildren. Every row needs a globally unique string or finite-number ID, its requested parent, and a boolean child marker. Numeric and string IDs are distinct.

Configure fields with tree.idField, tree.parentIdField and tree.hasChildrenField; the remote default marker is hasChildren. Root rows use tree.rootParentId in their parent field. The root sentinel cannot also be a row ID. Empty successful child responses remove the expand affordance. chunkSize defaults to 100 and maxConcurrentRequests to 4.

query uses the shared LoadQueryOptions<TreeRemoteLoadDataResult<Row>> policy. Use it for native freshness, cache lifetime, polling, retry, focus/reconnect, source, resource, consistency, reconciliation and status. A plain loader should only perform transport; wrapping it in another Query cache is unnecessary. See shared queries and defaults.

Query callbacks receive the loader’s original { data, total, revision? } result. reconcile receives a detached copy before projection; it cannot change the shared cached frame. Results are validated again after reconciliation. query: false bypasses Query caching, automatic refetch and Query callbacks; normal expansion and viewport loading still work. Omitting query creates a private context with the shared defaults.

Shared views use the same source and resource only when their loader semantics are identical. Cache identity also includes parent, sibling offset, size, sort, filters and consistency; cancellation is outside identity. Use actual tenant and authorization scope values and replace the scope when either changes. Detaching one view preserves a request still needed by another. Caller-owned contexts remain caller-owned.

Sorting is server-side within each sibling list, with a stable ID tie-breaker. Both compatibility filter models and the canonical filterAst reach the loader; prefer the AST for nested or structured expressions. Quick search uses quickFilter; with AdvanceFilterPlugin, set grid.quickFilter normally. The server must retain ancestors of matching descendants and return child markers/counts for the filtered hierarchy.

The demo also enables the advanced value-selection filter on Status. Its checklist reads distinct values from GET /filter-items?field=status; these options cover the canonical backend dataset, including unloaded branches. selection.getItems is wrapped by createFilterQueryProvider so option reads use the shared Query cache and cancellation lifecycle. The demo disposes that provider with its backend session.

The checklist uses excelMode: 'windows', remoteSearch: true, and quickSearchFiltering: false: searching sends search to the options endpoint, while rows change only after Apply. Cancel discards staged choices. Applied choices travel in the canonical filterAst as selection exclusions and combine with the Task column’s condition filters. The server retains matching ancestor paths and recalculates totals for the filtered hierarchy.

Sort/filter changes reload roots in a new generation. Expansion IDs are restored as matching ancestors arrive. Old responses cannot publish into that generation, and local transforms of the partial dataset are prevented. Matching loaded record IDs retain their view identity across refetches, including changed field values.

Eventual reads require stable server ordering and do not provide snapshot isolation. Changed accepted blocks invalidate other blocks in the same branch and its descendants. For a stable snapshot, use query.consistency with mode: 'snapshot', a pinned revision and getRevision. The pinned revision is forwarded in the tenth context; mismatching results are rejected.

The transport example above calls an application-owned POST endpoint. A root request uses this JSON shape; child requests put the parent ID in parentId:

{ "parentId": null, "skip": 0, "limit": 100, "order": { "name": "asc" } }

Implement the endpoint against the full authorized tree, not the rows currently loaded by the browser:

  • Authorize the requested project and parent before reading. A client cache scope describes identity; it does not enforce server permissions.
  • Validate offsets and page-size limits, allowlist sortable/filterable fields, and reject unsupported filter operators. Compile the canonical AST to the backend query; do not silently omit filter conditions. Use bound query values.
  • Determine matching nodes and retain their ancestor paths before selecting the requested parent’s direct children. Return a globally unique stable ID for every record and use a deterministic ID tie-breaker for sibling sorting.
  • Calculate total and hasChildren from that same filtered hierarchy. total counts direct siblings, while each row’s hasChildren describes its children. It does not mean the branch’s children are already loaded.
  • Return the complete requested slice and its count from a consistent read. If a pinned revision cannot be served, reject the request instead of mixing snapshots. Transport failures must reject the loader promise so native retry and error policies can act on them.

Existing loadChildren(request, signal) configurations remain supported through TreeRemoteChildrenConfig<Row>. They use TreeRemoteRequest with { parentId, start, limit, ...query, revision? } and return TreeRemoteResult<Row> with { rows, childCount, revision? }. Their query callbacks retain that original result shape. Offset and object-style loaders have separate cache namespaces because their payloads differ.

blockSize remains a compatibility name for chunkSize; conflicting values are rejected. Configure exactly one loader. Tree-specific branch state and refresh methods remain the same for both loader styles.

const plugins = await grid.getPlugins();
const treePlugin = plugins.find(plugin => plugin instanceof TreeDataPlugin)!;
const remoteTree = treePlugin.remote!;
await remoteTree.refresh(); // roots and descendants
await remoteTree.refresh('phase-a'); // one expanded reachable branch
await remoteTree.retry('phase-a', 100); // one sibling block
const state = remoteTree.getBranchState('phase-a');

loadChildren(parentId, start = 0) ensures one block is loaded. refresh(parentId = null) refreshes a branch, and retry(parentId, start = 0) retries a block. Methods reject on failure or detachment. Parents must be expanded and reachable. Background failures appear in branch state and provide retry buttons on failed placeholders. Failed refreshes retain accepted data. While a branch refresh is loading its first page, automatic sibling/descendant loads wait for that page. Concurrent explicit reads of those pages reject with AbortError; retry them after the refresh completes.

tree-remote-state-changed emits TreeRemoteBranchState: parentId, optional childCount, loadedBlocks, loadingBlocks, and { start, error }[] errors.

Collapse detaches hidden reads and retains accepted data by default. Reopening reattaches query observers so native polling/focus/reconnect policies resume; purgeClosedBranches: true also removes hidden data. Expand-all opens only the currently loaded parent set. Removing the grid or replacing configuration releases its subscriptions. Setting grid.tree.remote to false through a new tree configuration object clears its primary projection and returns source ownership to the host. Set grid.source after disabling remote loading to supply local rows. treePlugin.remote is undefined while remote loading is disabled.

V1 blocks editing, paste, structural drag/drop and undo/redo. Activation rejects client-side grouping because it cannot group a partial remote tree. The plugin owns the primary source; setting grid.source does not replace remote data. Do not mutate provider stores directly or combine this plugin with another remote row-source owner.

Only loaded records participate in selection and export. Changing the remote query (sorting or filtering) clears checkbox selection before replacing the partial source. Selecting a parent covers its currently loaded descendants. Children loaded later remain unselected, making the parent mixed. Placeholders cannot be focused, selected or exported as records. Export covers loaded records rather than the complete remote tree.

For Gantt, configure server-side Gantt loading through gantt.remote. Gantt reuses this tree paging contract and projects authoritative server schedules without scheduling a partial graph.