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(); };}<template> <section class="tree-remote-demo" style=" min-height: 400px; display: flex; flex-direction: column; flex: 1 1 auto; " > <p> Read-only tasks. Expand a project to load its children; scroll, sort, or filter to query the server. </p> <p v-if="startupError" role="alert">{{ startupError }}</p> <VGrid v-else-if="tree" class="overflow-hidden cell-border" style="min-height: 400px" :theme="isDark ? 'darkMaterial' : 'material'" :columns="columns" :source="source" :tree.prop="tree" stretch="all" :filter="filter" :plugins="plugins" hide-attribution /> <p v-else role="status">Starting demo backend…</p> </section></template>
<script setup lang="ts">import type { ColumnFilterConfig } from '@revolist/revogrid';import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue';import { VGrid } from '@revolist/vue3-datagrid';import { AdvanceFilterPlugin, TreeDataPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import type { TreeConfig } from '@revolist/revogrid-pro';import { currentThemeVue } from '../composables/useRandomData';import { createRemoteTreeColumns } from './tree.remote.utils';import { startRemoteTree } from './tree.remote.bootstrap';import type { RemoteTreeRow } from './backend/contracts';
const { isDark } = currentThemeVue();const source = ref<RemoteTreeRow[]>([]);const columns = createRemoteTreeColumns();const plugins = [ TreeDataPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin,];const tree = shallowRef<TreeConfig>();const filter = shallowRef<ColumnFilterConfig>();const startupError = ref('');const lifetime = new AbortController();onBeforeUnmount(() => lifetime.abort());onMounted(async () => { try { const config = await startRemoteTree(lifetime.signal); filter.value = config.filter; tree.value = config.tree; } catch (error) { if (!lifetime.signal.aborted) startupError.value = (error as Error).message; }});</script>import type { ColumnFilterConfig } from '@revolist/revogrid';import React, { useEffect, useMemo, useState } from 'react';import { RevoGrid } from '@revolist/react-datagrid';import { AdvanceFilterPlugin, TreeDataPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import type { TreeConfig } from '@revolist/revogrid-pro';import { currentTheme } from '../composables/useRandomData';import { createRemoteTreeColumns } from './tree.remote.utils';import { startRemoteTree } from './tree.remote.bootstrap';import type { RemoteTreeRow } from './backend/contracts';
export default function TreeRemote(_props: { rows?: unknown[] }) { const source = useMemo<RemoteTreeRow[]>(() => [], []); const columns = useMemo(createRemoteTreeColumns, []); const plugins = useMemo( () => [ TreeDataPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin, ], [], ); const [config, setConfig] = useState<{ tree: TreeConfig; filter: ColumnFilterConfig; }>(); const [startupError, setStartupError] = useState(''); useEffect(() => { const lifetime = new AbortController(); void startRemoteTree(lifetime.signal) .then((config) => { if (!lifetime.signal.aborted) setConfig(config); }) .catch((error) => { if (!lifetime.signal.aborted) setStartupError(error.message); }); return () => lifetime.abort(); }, []);
return ( <section className="tree-remote-demo" style={{ minHeight: '400px', display: 'flex', flexDirection: 'column', flex: '1 1 auto', }} > <p> Read-only tasks. Expand a project to load its children; scroll, sort, or filter to query the server. </p> {startupError ? ( <p role="alert">{startupError}</p> ) : !config ? ( <p role="status">Starting demo backend…</p> ) : ( <RevoGrid className="overflow-hidden cell-border" style={{ minHeight: '400px' }} columns={columns} source={source} tree={config.tree} stretch="all" filter={config.filter} hide-attribution theme={currentTheme().isDark() ? 'darkMaterial' : 'material'} plugins={plugins} /> )} </section> );}import type { ColumnFilterConfig } from '@revolist/revogrid';import { Component, ViewEncapsulation, NO_ERRORS_SCHEMA, signal,} from '@angular/core';import type { OnInit, OnDestroy } from '@angular/core';import { RevoGrid } from '@revolist/angular-datagrid';import { AdvanceFilterPlugin, TreeDataPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import type { TreeConfig } from '@revolist/revogrid-pro';import { currentTheme } from '../composables/useRandomData';import { createRemoteTreeColumns } from './tree.remote.utils';import { startRemoteTree } from './tree.remote.bootstrap';import type { RemoteTreeRow } from './backend/contracts';
@Component({ selector: 'tree-remote-grid', standalone: true, imports: [RevoGrid], template: ` <section class="tree-remote-demo" style="min-height: 400px; display: flex; flex-direction: column; flex: 1 1 auto" > <p> Read-only tasks. Expand a project to load its children; scroll, sort, or filter to query the server. </p> @if (startupError()) { <p role="alert">{{ startupError() }}</p> } @else if (config(); as config) { <revo-grid class="overflow-hidden cell-border" [columns]="columns" [source]="source" [filter]="config.filter" stretch="all" [tree]="config.tree" hide-attribution [theme]="theme" [plugins]="plugins" style="min-height: 400px;" ></revo-grid> } @else { <p role="status">Starting demo backend…</p> } </section> `, encapsulation: ViewEncapsulation.None, schemas: [NO_ERRORS_SCHEMA],})export class TreeRemoteGridComponent implements OnInit, OnDestroy { theme = currentTheme().isDark() ? 'darkMaterial' : 'material'; source: RemoteTreeRow[] = []; columns = createRemoteTreeColumns(); plugins = [ TreeDataPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin, ]; config = signal<{ tree: TreeConfig; filter: ColumnFilterConfig } | undefined>( undefined, ); startupError = signal(''); private lifetime = new AbortController();
ngOnInit() { void startRemoteTree(this.lifetime.signal) .then((config) => { if (!this.lifetime.signal.aborted) this.config.set(config); }) .catch((error) => { if (!this.lifetime.signal.aborted) this.startupError.set(error.message); }); } ngOnDestroy() { this.lifetime.abort(); }}import type { ColumnFilterConfig, ColumnRegular } from '@revolist/revogrid';import type { GetItemsFn, TreeRemoteLoadData } from '@revolist/revogrid-pro';import type { RemoteTreeRow } from './backend/contracts';
export function createRemoteTreeColumns(): ColumnRegular[] { return [ { prop: 'name', name: 'Task', tree: true, sortable: true, filter: 'string', size: 340, }, { prop: 'status', name: 'Status', sortable: true, filter: ['selection', 'string'], size: 140, }, { prop: 'effort', name: 'Effort', sortable: true, filter: 'number', size: 100, }, ];}
/** HTTP-only client. Backend services and seed data are never read by the grid. */export function createRemoteTreeLoader( apiUrl: string,): TreeRemoteLoadData<RemoteTreeRow> { return async ( skip, limit, sort, _single, _multi, quick, _temporal, filterAst, _execution, context, ) => { const url = new URL( `${apiUrl}/children`, globalThis.location?.href ?? 'http://localhost', ); url.searchParams.set('parentId', JSON.stringify(context?.parentId ?? null)); url.searchParams.set('skip', String(skip)); url.searchParams.set('limit', String(limit)); if (sort && Object.keys(sort).length) url.searchParams.set('sort', JSON.stringify(sort)); if (filterAst) url.searchParams.set('filterAst', JSON.stringify(filterAst)); if (quick?.text) url.searchParams.set('search', quick.text); const response = await fetch(url, { signal: context?.signal }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw Object.assign( new Error(body.message ?? `HTTP ${response.status}`), { status: response.status }, ); } return response.json(); };}
/** Options come from the full backend dataset, not the loaded sibling pages. */export function createRemoteTreeFilterItems(apiUrl: string): GetItemsFn { return async (prop, request) => { const url = new URL( `${apiUrl}/filter-items`, globalThis.location?.href ?? 'http://localhost', ); url.searchParams.set('field', String(prop)); if (request?.search) url.searchParams.set('search', request.search); const response = await fetch(url, { signal: request?.signal }); if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status, }); return response.json(); };}
export function createRemoteTreeFilter( getItems: GetItemsFn,): ColumnFilterConfig { return { selection: { remoteSearch: true, quickSearchFiltering: false, getItems: { status: getItems }, }, };}import type { ColumnFilterConfig } from '@revolist/revogrid';import { startDemoBackend } from '@revolist/revogrid-examples/demo-api-mocks';import { createFilterQueryProvider, type TreeConfig,} from '@revolist/revogrid-pro';import { createRemoteTreeLoader, createRemoteTreeFilterItems, createRemoteTreeFilter,} from './tree.remote.utils';
export async function startRemoteTree( signal: AbortSignal,): Promise<{ tree: TreeConfig; filter: ColumnFilterConfig }> { signal.throwIfAborted(); const { remoteTreeBackend } = await import('./backend/module'); signal.throwIfAborted(); const session = await startDemoBackend(remoteTreeBackend); if (signal.aborted) { session.dispose(); signal.throwIfAborted(); } const options = createFilterQueryProvider( createRemoteTreeFilterItems(session.apiUrl), { resource: session.apiUrl, staleTime: 30_000 }, ); signal.addEventListener( 'abort', () => { options.dispose(); session.dispose(); }, { once: true }, ); return { filter: createRemoteTreeFilter(options.getItems), tree: { rootParentId: null, remote: { chunkSize: 20, loadData: createRemoteTreeLoader(session.apiUrl), query: { retry: 1, staleTime: 30_000 }, }, }, };}import { http, HttpResponse } from 'msw';import type { DemoBackendModule } from '@revolist/revogrid-examples/demo-api-mocks';import { createTreeSeed } from './seed';import { queryTree, queryTreeFilterItems, TreeQueryError } from './service';
export const remoteTreeBackend: DemoBackendModule = { id: 'tree-remote', create({ apiUrl }) { const rows = createTreeSeed(); return { handlers: [ http.get(`${apiUrl}/filter-items`, ({ request }) => { try { return HttpResponse.json( queryTreeFilterItems(rows, new URL(request.url).searchParams), ); } catch (error) { if (error instanceof TreeQueryError) return HttpResponse.json( { message: error.message }, { status: 400 }, ); throw error; } }), http.get(`${apiUrl}/children`, ({ request }) => { try { return HttpResponse.json( queryTree(rows, new URL(request.url).searchParams), ); } catch (error) { if (error instanceof TreeQueryError) return HttpResponse.json( { message: error.message }, { status: 400 }, ); throw error; } }), ], }; },};import { filterCoreFunctionsIndexedByType } from '@revolist/revogrid';import { collectFilterAstFields, compileFilterAst, validateFilterAst,} from '@revolist/revogrid-pro';import type { FilterAst } from '@revolist/revogrid-pro';import { TREE_FIELDS } from './contracts';import type { RemoteTreeRow, TreePage } from './contracts';
export class TreeQueryError extends Error {}function json(search: URLSearchParams, key: string): unknown { try { return search.has(key) ? JSON.parse(search.get(key)!) : undefined; } catch { throw new TreeQueryError(`Invalid ${key} JSON.`); }}function offset(search: URLSearchParams, key: string, fallback: number) { const raw = search.get(key); if (raw === null) return fallback; const value = Number(raw); if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value)) throw new TreeQueryError(`Invalid ${key}.`); return value;}
/** Server owns filtering, retained ancestor paths, sibling ordering and exact totals. */export function queryTree<T extends Pick<RemoteTreeRow, 'id' | 'parentId' | 'name' | 'hasChildren'> & { status: string }>( rows: readonly T[], search: URLSearchParams, allowedFields: readonly string[] = TREE_FIELDS,): { data: T[]; total: number } { const fields = new Set(allowedFields); const skip = offset(search, 'skip', 0), limit = offset(search, 'limit', 20); if (limit < 1 || limit > 1000) throw new TreeQueryError('limit must be between 1 and 1000.'); const parentId = json(search, 'parentId') ?? null; if (parentId !== null && typeof parentId !== 'string') throw new TreeQueryError('Invalid parentId.'); const sort = json(search, 'sort') ?? {}; if ( !sort || typeof sort !== 'object' || Array.isArray(sort) || Object.entries(sort).some( ([field, direction]) => !fields.has(field) || !['asc', 'desc'].includes(direction), ) ) throw new TreeQueryError('Invalid sort.'); const ast = json(search, 'filterAst'); if ( ast !== undefined && (!validateFilterAst(ast).valid || [...collectFilterAstFields(ast as FilterAst)].some( (field) => !fields.has(field), )) ) throw new TreeQueryError('Invalid filter expression.'); const predicate = ast === undefined ? undefined : compileFilterAst(ast as FilterAst, { columns: allowedFields.map((prop) => ({ prop })), operators: filterCoreFunctionsIndexedByType, }); const text = (search.get('search') ?? '').toLowerCase(); const byId = new Map(rows.map((row) => [row.id, row])); const visible = new Set<string>(); for (const row of rows) { if ( (predicate && !predicate.matches(row)) || (text && !`${row.name} ${row.status}`.toLowerCase().includes(text)) ) continue; let current: T | undefined = row; while (current && !visible.has(current.id)) { visible.add(current.id); current = current.parentId === null ? undefined : byId.get(current.parentId); } } const retained = rows.filter((row) => visible.has(row.id)); const parents = new Set(retained.map((row) => row.parentId)); const siblings = retained .filter((row) => row.parentId === parentId) .sort((a, b) => { for (const [field, direction] of Object.entries(sort)) { const left = a[field as keyof T], right = b[field as keyof T]; const comparison = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right)); if (comparison) return direction === 'desc' ? -comparison : comparison; } return a.id.localeCompare(b.id, undefined, { numeric: true }); }); return { data: siblings .slice(skip, skip + limit) .map((row) => ({ ...row, hasChildren: parents.has(row.id) })), total: siblings.length, };}
/** Distinct values over canonical records, independent of tree paging. */export function queryTreeFilterItems( rows: readonly RemoteTreeRow[], search: URLSearchParams,) { if (search.get('field') !== 'status') throw new TreeQueryError('Invalid selection field.'); const text = (search.get('search') ?? '').trim().toLowerCase(); return [...new Set(rows.map((row) => row.status))] .filter((label) => label.toLowerCase().includes(text)) .sort((a, b) => a.localeCompare(b)) .map((label) => ({ value: label.toLowerCase(), label }));}import type { RemoteTreeRow } from './contracts';
export function createTreeSeed(): RemoteTreeRow[] { const rows: RemoteTreeRow[] = []; for (let project = 1; project <= 12; project++) { const parentId = `project-${project}`; rows.push({ id: parentId, parentId: null, name: `Project ${String(project).padStart(2, '0')}`, status: 'Active', effort: 0, hasChildren: true, }); for (let task = 0; task < 150; task++) { const id = `${parentId}-task-${task}`; const hasChildren = task % 50 === 0; rows.push({ id, parentId, name: `Task ${String(task).padStart(3, '0')}`, status: ['Planned', 'Active', 'Done'][ task % 3 ] as RemoteTreeRow['status'], effort: (task % 8) + 1, hasChildren, }); if (hasChildren) for (let step = 1; step <= 3; step++) rows.push({ id: `${id}-step-${step}`, parentId: id, name: `Step ${step}`, status: 'Planned', effort: 1, hasChildren: false, }); } } return rows;}export interface RemoteTreeRow { id: string; parentId: string | null; name: string; status: 'Planned' | 'Active' | 'Done'; effort: number; hasChildren: boolean;}export interface TreePage { data: RemoteTreeRow[]; total: number;}export const TREE_FIELDS = ['id', 'name', 'status', 'effort'] as const;HTTP backend
Section titled “HTTP backend”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.
Connect a loader
Section titled “Connect a loader”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.
Loader contract
Section titled “Loader contract”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 usage, sorting and filtering
Section titled “Query usage, sorting and filtering”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.
Server endpoint responsibilities
Section titled “Server endpoint responsibilities”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
totalandhasChildrenfrom that same filtered hierarchy.totalcounts direct siblings, while each row’shasChildrendescribes 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.
Object-style loader compatibility
Section titled “Object-style loader compatibility”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.
Retry, refresh and lifecycle
Section titled “Retry, refresh and lifecycle”const plugins = await grid.getPlugins();const treePlugin = plugins.find(plugin => plugin instanceof TreeDataPlugin)!;const remoteTree = treePlugin.remote!;await remoteTree.refresh(); // roots and descendantsawait remoteTree.refresh('phase-a'); // one expanded reachable branchawait remoteTree.retry('phase-a', 100); // one sibling blockconst 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.
Read-only boundaries
Section titled “Read-only boundaries”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.