Server-Side Loading
Live example
Section titled “Live example”Expand a project to fetch its direct children, then scroll, sort, or filter. All four framework versions use HTTP requests and the same MSW demo API mock runner as the pagination example. The simulated backend schedules the complete project once per revision and serves slices of that schedule. This backend demonstrates name/progress filters and ISO date sorting.
Source code
import { defineCustomElements } from '@revolist/revogrid/loader';import { AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import { GanttPlugin } from '@revolist/gantt';import { currentTheme } from '../composables/useRandomData';import { createRemoteGanttColumns } from './gantt.remote.utils';import { startRemoteGantt } from './gantt.remote.bootstrap';
defineCustomElements();
export function load(parentSelector: string) { const parent = document.querySelector(parentSelector); if (!parent) throw new Error('Remote gantt container not found.'); const shell = document.createElement('section'); shell.className = 'gantt-remote-demo'; Object.assign(shell.style, { height: '420px', minHeight: '420px', minWidth: '0', display: 'flex', flexDirection: 'column', flex: '1 1 auto', }); parent.appendChild(shell); 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 startRemoteGantt(lifetime.signal) .then((gantt) => { if (lifetime.signal.aborted) return; grid = document.createElement('revo-grid'); grid.className = 'overflow-hidden cell-border'; Object.assign(grid.style, { height: '100%', minHeight: '0', minWidth: '0', flex: '1 1 0' }); grid.columns = createRemoteGanttColumns(); grid.filter = true; grid.stretch = 'all'; Object.assign(grid, { gantt }); grid.plugins = [ GanttPlugin, 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="gantt-remote-demo" style=" height: 420px; min-height: 420px; min-width: 0; display: flex; flex-direction: column; flex: 1 1 auto; " > <p v-if="startupError" role="alert">{{ startupError }}</p> <VGrid v-else-if="gantt" class="overflow-hidden cell-border" style="height: 100%; min-height: 0; min-width: 0; flex: 1 1 0" :theme="isDark ? 'darkMaterial' : 'material'" :columns="columns" :source="source" :gantt.prop="gantt" stretch="all" filter :plugins="plugins" hide-attribution /> <p v-else role="status">Starting demo backend…</p> </section></template>
<script setup lang="ts">import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue';import { VGrid } from '@revolist/vue3-datagrid';import { AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import { GanttPlugin, type GanttPluginConfig } from '@revolist/gantt';import { currentThemeVue } from '../composables/useRandomData';import { createRemoteGanttColumns } from './gantt.remote.utils';import { startRemoteGantt } from './gantt.remote.bootstrap';
const { isDark } = currentThemeVue();const source = ref<Record<string, unknown>[]>([]);const columns = createRemoteGanttColumns();const plugins = [ GanttPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin,];const gantt = shallowRef<GanttPluginConfig>();const startupError = ref('');const lifetime = new AbortController();onBeforeUnmount(() => lifetime.abort());onMounted(async () => { try { gantt.value = await startRemoteGantt(lifetime.signal); } catch (error) { if (!lifetime.signal.aborted) startupError.value = (error as Error).message; }});</script>import React, { useEffect, useMemo, useState } from 'react';import { RevoGrid } from '@revolist/react-datagrid';import { AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import { GanttPlugin, type GanttPluginConfig } from '@revolist/gantt';import { currentTheme } from '../composables/useRandomData';import { createRemoteGanttColumns } from './gantt.remote.utils';import { startRemoteGantt } from './gantt.remote.bootstrap';
export default function GanttRemote(_props: { rows?: unknown[] }) { const source = useMemo<Record<string, unknown>[]>(() => [], []); const columns = useMemo(createRemoteGanttColumns, []); const plugins = useMemo( () => [GanttPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin], [], ); const [gantt, setGantt] = useState<GanttPluginConfig>(); const [startupError, setStartupError] = useState(''); useEffect(() => { const lifetime = new AbortController(); void startRemoteGantt(lifetime.signal) .then((config) => { if (!lifetime.signal.aborted) setGantt(config); }) .catch((error) => { if (!lifetime.signal.aborted) setStartupError(error.message); }); return () => lifetime.abort(); }, []);
return ( <section className="gantt-remote-demo" style={{ height: '420px', minHeight: '420px', minWidth: 0, display: 'flex', flexDirection: 'column', flex: '1 1 auto', }} > {startupError ? ( <p role="alert">{startupError}</p> ) : !gantt ? ( <p role="status">Starting demo backend…</p> ) : ( <RevoGrid className="overflow-hidden cell-border" style={{ height: '100%', minHeight: 0, minWidth: 0, flex: '1 1 0' }} columns={columns} source={source} gantt={gantt} stretch="all" filter hide-attribution theme={currentTheme().isDark() ? 'darkMaterial' : 'material'} plugins={plugins} /> )} </section> );}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, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import { GanttPlugin, type GanttPluginConfig } from '@revolist/gantt';import { currentTheme } from '../composables/useRandomData';import { createRemoteGanttColumns } from './gantt.remote.utils';import { startRemoteGantt } from './gantt.remote.bootstrap';
@Component({ selector: 'gantt-remote-grid', standalone: true, imports: [RevoGrid], template: ` <section class="gantt-remote-demo" style="height: 420px; min-height: 420px; min-width: 0; display: flex; flex-direction: column; flex: 1 1 auto" > @if (startupError()) { <p role="alert">{{ startupError() }}</p> } @else if (gantt(); as config) { <revo-grid class="overflow-hidden cell-border" [columns]="columns" [source]="source" [filter]="true" stretch="all" [gantt]="config" hide-attribution [theme]="theme" [plugins]="plugins" style="height: 100%; min-height: 0; min-width: 0; flex: 1 1 0" ></revo-grid> } @else { <p role="status">Starting demo backend…</p> } </section> `, encapsulation: ViewEncapsulation.None, schemas: [NO_ERRORS_SCHEMA],})export class GanttRemoteGridComponent implements OnInit, OnDestroy { theme = currentTheme().isDark() ? 'darkMaterial' : 'material'; source: Record<string, unknown>[] = []; columns = createRemoteGanttColumns(); plugins = [ GanttPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin, ]; gantt = signal<GanttPluginConfig | undefined>(undefined); startupError = signal(''); private lifetime = new AbortController();
ngOnInit() { void startRemoteGantt(this.lifetime.signal) .then((config) => { if (!this.lifetime.signal.aborted) this.gantt.set(config); }) .catch((error) => { if (!this.lifetime.signal.aborted) this.startupError.set(error.message); }); } ngOnDestroy() { this.lifetime.abort(); }}import { createDefaultTaskTableColumn, type GanttRemoteConfig,} from '@revolist/gantt';
export function createRemoteGanttColumns() { return (['name', 'startDate', 'endDate', 'percentDone'] as const).map( (prop) => ({ ...createDefaultTaskTableColumn(prop), ...(prop === 'name' ? { filter: 'string' } : {}), ...(['startDate', 'endDate'].includes(prop) ? { filter: false } : {}), }), );}export function createRemoteGanttTransport(apiUrl: string): GanttRemoteConfig { async function get( path: string, values: Record<string, unknown>, signal?: AbortSignal, ) { const url = new URL( `${apiUrl}/${path}`, globalThis.location?.href ?? 'http://localhost', ); for (const [key, value] of Object.entries(values)) if (value !== undefined) url.searchParams.set( key, typeof value === 'object' ? JSON.stringify(value) : String(value), ); const response = await fetch(url, { 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(); } return { chunkSize: 20, query: { retry: false, staleTime: 30_000 }, loadContext: (_request, signal) => get('context', {}, signal), loadData: ( skip, limit, sort, _single, _multi, quick, _temporal, filterAst, _execution, context, ) => get( 'children', { parentId: JSON.stringify(context?.parentId ?? null), skip, limit, sort, search: quick?.text, filterAst, revision: context?.revision, }, context?.signal, ), loadDependencies: (request, signal) => get( 'dependencies', { taskIds: request.taskIds, revision: request.revision }, signal, ), };}import { startDemoBackend } from '@revolist/revogrid-examples/demo-api-mocks';import type { GanttPluginConfig } from '@revolist/gantt';import { createRemoteGanttTransport } from './gantt.remote.utils';
export async function startRemoteGantt( signal: AbortSignal,): Promise<GanttPluginConfig> { signal.throwIfAborted(); const { remoteGanttBackend } = await import('./backend/module'); signal.throwIfAborted(); const session = await startDemoBackend(remoteGanttBackend); if (signal.aborted) { session.dispose(); signal.throwIfAborted(); } signal.addEventListener('abort', () => session.dispose(), { once: true }); return { id: 'remote-project', name: 'Remote Gantt', version: '1', currency: 'USD', timeZone: 'UTC', primaryCalendarId: 'standard', updatedAt: '2026-05-01T00:00:00Z', readOnly: true, zoomPreset: 'week-month', remote: createRemoteGanttTransport(session.apiUrl), };}import { http, HttpResponse } from 'msw';import type { DemoBackendModule } from '@revolist/revogrid-examples/demo-api-mocks';import { createRemoteGanttService } from './service';
export const remoteGanttBackend: DemoBackendModule = { id: 'gantt-remote', create({ apiUrl }) { const service = createRemoteGanttService(); return { handlers: [ http.get(`${apiUrl}/context`, () => HttpResponse.json(service.context)), http.get(`${apiUrl}/children`, ({ request }) => { try { return HttpResponse.json( service.children(new URL(request.url).searchParams), ); } catch (error) { return HttpResponse.json( { message: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 }, ); } }), http.get(`${apiUrl}/dependencies`, ({ request }) => { try { const search = new URL(request.url).searchParams; const ids: unknown = JSON.parse(search.get('taskIds') ?? '[]'); if ( !Array.isArray(ids) || !ids.every((id) => typeof id === 'string') ) throw new Error('Invalid task IDs.'); return HttpResponse.json( service.dependencies(ids, search.get('revision') ?? ''), ); } catch (error) { return HttpResponse.json( { message: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 }, ); } }), ], }; },};import { createSchedulerEngine, type ProjectSnapshot, type TaskEntity, type GanttRemoteTaskRow,} from '@revolist/gantt';import { addDays } from '@revolist/dates';import { createRemoteGanttSeed } from './seed';import { queryTree } from '../../tree-remote/backend/service';
/** The complete graph is scheduled once on the backend, never once per page. */export function createRemoteGanttService() { const seed = createRemoteGanttSeed(); const wbs = new Map<string, string>(), counts = new Map<string | null, number>(); const tasks: TaskEntity[] = seed.map((row, index) => { const sibling = (counts.get(row.parentId) ?? 0) + 1; counts.set(row.parentId, sibling); const code = row.parentId === null ? String(sibling) : `${wbs.get(row.parentId)}.${sibling}`; wbs.set(row.id, code); return { id: row.id, parentId: row.parentId, projectId: 'remote-project', name: row.name, wbsCode: code, type: row.hasChildren ? 'summary' : 'task', startDate: addDays('2026-05-04', index % 45), endDate: addDays('2026-05-04', (index % 45) + 1), duration: Math.max(1, row.effort) * 8, durationUnit: 'day', progressPercent: (index % 3) * 30, calendarId: 'standard', isCritical: false, tags: [], }; }); const project: ProjectSnapshot = { id: 'remote-project', name: 'Server-scheduled program', version: '1', currency: 'USD', timeZone: 'UTC', primaryCalendarId: 'standard', updatedAt: '2026-05-01T00:00:00Z', statusDate: '2026-05-10', tasks, dependencies: Array.from({ length: 12 }, (_, index) => ({ id: `edge-${index + 1}`, predecessorTaskId: `project-${index + 1}-task-0`, successorTaskId: `project-${index + 1}-task-1`, type: 'finish-to-start', lagDays: 0, })), calendars: [ { id: 'standard', name: 'Standard', timeZone: 'UTC', workingDays: [1, 2, 3, 4, 5], holidays: [], hoursPerDay: 8, }, ], resources: [], assignments: [], baselines: [], }; const schedule = createSchedulerEngine().recalculate(project); if (!schedule.ok) throw new Error('Invalid demo project.'); const childIds = new Set(tasks.map((task) => task.parentId)); const rows: GanttRemoteTaskRow[] = schedule.tasks.map((task) => ({ ...task, hasChildren: childIds.has(task.id), percentDone: task.progressPercent, })); const { tasks: _tasks, dependencies, resources: _resources, assignments: _assignments, baselines: _baselines, ...metadata } = project; const revision = 'schedule-1'; return { context: { capabilities: { dependencies: true, criticalPath: true }, scheduling: { excludeHolidaysFromDuration: false }, project: metadata, revision, timelineRange: { startDate: '2026-05-01' as const, endDate: '2026-08-31' as const, }, }, children(search: URLSearchParams) { if (search.get('revision') !== revision) throw Object.assign(new Error('Project revision expired.'), { status: 409, }); return { ...queryTree(rows, search, [ 'id', 'name', 'status', 'startDate', 'endDate', 'duration', 'progressPercent', 'percentDone', 'wbsCode', ]), revision, }; }, dependencies(taskIds: readonly string[], requestedRevision: string) { if (requestedRevision !== revision) throw Object.assign(new Error('Project revision expired.'), { status: 409, }); const ids = new Set(taskIds); return { data: dependencies.filter( (edge) => ids.has(edge.predecessorTaskId) && ids.has(edge.successorTaskId), ), revision, }; }, };}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;Configure remote loading
Section titled “Configure remote loading”Register GanttPlugin and set gantt.remote before mounting. Gantt manages tree loading internally; do not also configure tree.remote. Keep source empty. The backend supplies metadata, calendars, a global timeline range and scheduled task pages.
import { GanttPlugin, type GanttRemoteConfig } from '@revolist/gantt';import { createDataQueryContext } from '@revolist/revogrid-pro';
const context = createDataQueryContext();const source = context.source({ sourceId: 'projects', schemaId: 'schedule-v1', tenantId: 'actual-tenant', authorizationScope: 'actual-read-scope',});async function post(path: string, body: unknown, signal?: AbortSignal) { const response = await fetch(`/api/projects/p/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal, }); if (!response.ok) throw Object.assign(new Error('Project read failed'), { status: response.status, }); return response.json();}const remote: GanttRemoteConfig = { chunkSize: 100, query: { source, staleTime: 30_000, retry: 2 }, contextQuery: { staleTime: 30_000 }, dependencyQuery: { staleTime: 30_000 }, loadContext: (request, signal) => post('context', request, signal), loadData: (skip, limit, order, singleConditionFilters, multiConditionFilters, quickFilter, temporalContext, filterAst, executionContext, requestContext) => post('children', { parentId: requestContext?.parentId ?? null, revision: requestContext?.revision, skip, limit, order, singleConditionFilters, multiConditionFilters, quickFilter, temporalContext, filterAst, executionContext, }, requestContext?.signal), loadDependencies: (request, signal) => post('dependencies', request, signal),};const grid = document.createElement('revo-grid');grid.style.height = '500px';grid.source = [];grid.gantt = { id: 'p', name: 'Project', version: '1', currency: 'USD', timeZone: 'UTC', primaryCalendarId: 'standard', updatedAt: '2026-05-01T00:00:00Z', remote, readOnly: true, // This example opts out of editing; remote loading does not force it.};grid.plugins = [GanttPlugin];document.body.append(grid);// Teardown: remove grids first, then dispose the caller-owned context.The query, contextQuery and dependencyQuery options use native shared Query policies for cache freshness, retries, polling, focus/reconnect, reconciliation and status. Context and dependency readers inherit query.source unless overridden. Other policies are configured separately for each read. Setting query: false also disables Query for context and dependencies unless their policies are explicitly supplied. See shared queries.
Replace the gantt configuration object and its remote object to change loaders or policies. A new visual configuration with the same remote object keeps its observers. The application owns any supplied Query context; removing the grid releases only its subscriptions.
Context contract
Section titled “Context contract”loadContext({ projectId }, signal) returns GanttRemoteContext:
{ project: { id: 'p', name: 'Program', version: '1', currency: 'USD', timeZone: 'UTC', primaryCalendarId: 'standard', updatedAt: '2026-05-01T00:00:00Z', calendars: [{ id: 'standard', name: 'Standard', timeZone: 'UTC', workingDays: [1, 2, 3, 4, 5], holidays: [], hoursPerDay: 8 }], }, revision: 'schedule-42', timelineRange: { startDate: '2026-05-01', endDate: '2026-08-31' }, scheduling: { excludeHolidaysFromDuration: false }, capabilities: { dependencies: true, criticalPath: true },}project contains project metadata and calendars, without tasks, dependencies, resources, assignments or baselines. revision is a nonempty opaque string identifying the entire immutable server schedule. Supply the global project extent in timelineRange; fit-to-project uses it even when only roots are loaded. The optional scheduling value describes the server’s duration conversion basis; it does not enable local scheduling. Critical-path highlighting requires capabilities.criticalPath: true and the corresponding visual option.
Task page contract
Section titled “Task page contract”loadData has the same first nine positional arguments as infinity scroll. Its tenth context supplies { signal, parentId, revision }. parentId: null requests roots. skip counts direct siblings, not flattened rows. The response is:
{ data: scheduledRows, total: exactDirectChildCount, revision: 'schedule-42' }Every row is a GanttRemoteTaskRow: the public semantic ScheduledTask plus boolean hasChildren. IDs are globally unique strings, root parentId is null, and projectId matches the context. Return schedule fields including dates, duration, progress, WBS, calendar, status, schedule origin, critical-path dates and slack. Use the exported type when implementing the service. Do not return pixel positions or timeline bar geometry.
Compute summary dates/progress, dependencies, WBS and critical-path values against the complete authorized project, then page the resulting rows. A summary row must retain those values before and after its children load. ScheduledTask.duration uses the scheduler’s day-based result; explicit duration units are converted for display using the supplied calendars and scheduling basis.
The tree paging rules apply: return exactly min(limit, max(0, total - skip)) direct children and an exact filtered total; stable sibling sorting needs an ID tie-breaker. Preserve ancestor paths of matches and calculate hasChildren from that filtered hierarchy. Both compatibility filters and canonical filterAst reach the loader. See the tree endpoint responsibilities for validation, authorization, filter compilation and consistent counts.
Dependencies and revisions
Section titled “Dependencies and revisions”The optional loadDependencies({ projectId, revision, taskIds }, signal) returns { data: DependencyEntity[], revision }. It is called for loaded task IDs. Only edges whose two endpoints are loaded are drawn. Set capabilities.dependencies: false to suppress these reads. Omitting the loader displays no dependency edges; it does not change the server’s schedule.
All pages and edges must carry the pinned context revision. Reject expired revisions with an error whose status is 409 or 412; HTTP loaders must propagate that status. A mismatching response is rejected. Gantt attempts one context refresh per failed revision, disposes old observers and replaces branches together when a new revision arrives. Failed same-revision refreshes retain accepted data.
const plugins = await grid.getPlugins();const gantt = plugins.find(plugin => plugin instanceof GanttPlugin)!;await gantt.remote!.ready; // context ready; roots may still be loadingawait gantt.remote!.tree!.loadChildren(null); // wait for root blockawait gantt.remote!.refresh(); // context, then task rootsawait gantt.remote!.tree!.retry('phase-a', 100); // retry a sibling blockObserve gantt-remote-state-changed for { phase: 'context' | 'dependencies', status } using the native read status. Tree block loading/errors and retry placeholders use tree-remote-state-changed. Expansion, viewport reads, collapse caching, sorting/filtering generations and cancellation follow the tree lifecycle.
Editing and server persistence
Section titled “Editing and server persistence”Remote loading preserves your gantt configuration and respects the application’s gantt.readOnly setting. Set readOnly: true for a read-only view, or omit it / set it to false when the app handles edits. The live example explicitly chooses readOnly: true.
The application owns edit permissions, validation, sending commands to the backend, save errors, and revision conflicts. gantt.remote supplies read loaders; it does not automatically persist edits. Remote Gantt renders authoritative schedules and does not run the client scheduler over partial data.
For server-authoritative editing, intercept the cancelable gantt-before-task-change, gantt-before-dependency-change, and gantt-before-assignment-change events for the interactions your app enables. Call preventDefault() synchronously, before awaiting a server response, to stop the local mutation. Send the proposed change with the current revision to your application endpoint, let the server validate and schedule the complete project, then call gantt.remote.refresh() to load the accepted schedule. Keep failed edits out of the loaded schedule and show the error in your app. Handle paste, custom columns, and custom controls at their corresponding grid or application event boundary.
// Application-owned command endpoint; not a built-in Gantt remote write API.grid.addEventListener('gantt-before-task-change', event => { event.preventDefault(); const revision = gantt.remote?.context?.revision; if (!revision) return; const command = { operationId: crypto.randomUUID(), projectId: grid.gantt!.id, revision, action: event.detail.action, taskId: event.detail.taskId, changes: event.detail.changes, }; void post('commands', command) .then(() => gantt.remote!.refresh()) .catch(error => { // Display the failure and resolve revision conflicts in your application. console.error('Task change was not applied', error); });});Install these handlers before enabling editing. Serialize conflicting saves or disable the affected controls while a save is pending. Use equivalent handlers for dependency and assignment changes. See task editing for event cancellation and payloads. The whole-project gantt-project-commit persistence flow requires a complete local project and is not emitted for paged snapshots.
The remote loading contract does not provide task creation, updateTask() / updateTasks() writes, baseline capture, resource planning, local task windows, or date-window paging. Provide application controls and server commands for operations that require unloaded records. Undo/redo of server commands also belongs to the application; local history cannot restore the complete server project.
Selection and grid export cover loaded records only; placeholders are excluded. getProjectSnapshot() and getProjectData() return null because loaded rows cannot represent a complete project. Complete-project loading/import methods reject while remote mode is active. For a complete export, provide an application-owned backend endpoint.
To return to local scheduling, replace gantt with a local configuration (remote: false or omitted), then supply the complete authored task and linked-entity collections. Local scheduling and the whole-project Query adapter remain available for projects loaded in full.