@1771technologies/lytenyte-pro
Version:
Blazingly fast headless React data grid with 100s of features.
520 lines (519 loc) • 20.9 kB
JavaScript
import { equal } from "@1771technologies/lytenyte-shared";
import { makeAsyncTree } from "./async-tree/make-async-tree.js";
import { RangeTree } from "./range-tree/range-tree.js";
import { getRequestId } from "./utils/get-request-id.js";
import { getNodePath } from "./utils/get-node-path.js";
import { getNodeDepth } from "./utils/get-node-depth.js";
const noopFetcher = async () => [];
export class ServerData {
#tree;
#top = { asOf: 0, rows: [] };
#bottom = { asOf: 0, rows: [] };
#blocksize;
#flat;
#dataFetcher = noopFetcher;
#pivotMode;
#expansions;
#pivotExpansions;
#onResetLoadBegin;
#onResetLoadError;
#onResetLoadEnd;
#onFlatten;
#onInvalidate;
#rowViewBounds = [0, 0];
#seenRequests = new Set();
#loadingRows = new Set();
#loadingGroup = new Set();
#rowsWithError = new Map();
#rowsWithGroupError = new Map();
#controllers = new Set();
#defaultExpansion;
constructor({ blocksize, pivotMode, pivotExpansions, expansions, onResetLoadBegin, onResetLoadEnd, onResetLoadError, onFlatten, onInvalidate, defaultExpansion, }) {
this.#tree = makeAsyncTree();
this.#blocksize = blocksize;
this.#pivotMode = pivotMode;
this.#expansions = expansions;
this.#pivotExpansions = pivotExpansions;
this.#defaultExpansion = defaultExpansion;
this.#onResetLoadBegin = onResetLoadBegin;
this.#onResetLoadEnd = onResetLoadEnd;
this.#onResetLoadError = onResetLoadError;
this.#onFlatten = onFlatten;
this.#onInvalidate = onInvalidate;
}
// Properties
set dataFetcher(d) {
if (this.#dataFetcher === d)
return;
this.#dataFetcher = d;
this.reset();
}
set pivotMode(b) {
if (b === this.#pivotMode)
return;
this.#pivotMode = b;
this.reset();
}
set expansions(d) {
this.#expansions = d;
if (this.#pivotMode)
return;
this.#flatten();
}
set pivotExpansions(d) {
this.#pivotExpansions = d;
if (!this.#pivotMode)
return;
this.#flatten();
}
set defaultExpansion(d) {
this.#defaultExpansion = d;
}
set rowViewBounds(viewBounds) {
if (equal(viewBounds, this.#rowViewBounds))
return;
this.#rowViewBounds = viewBounds;
this.handleViewBoundsChange();
}
// Methods
reset = async () => {
// Abort all the existing requests in flight.
this.#controllers.forEach((c) => c.abort());
this.#tree = makeAsyncTree();
this.#flatten();
this.#rowsWithError.clear();
this.#rowsWithGroupError.clear();
this.#loadingRows.clear();
this.#loadingGroup.clear();
try {
this.#onResetLoadBegin();
this.#seenRequests.clear();
const req = {
rowStartIndex: 0,
rowEndIndex: this.#blocksize,
id: getRequestId([], 0, this.#blocksize),
path: [],
start: 0,
end: this.#blocksize,
};
this.#seenRequests.add(req.id);
const res = await this.#dataFetcher([req], this.#expansions, this.#pivotExpansions);
this.handleResponses(res);
}
catch (e) {
this.#onResetLoadError(e);
}
finally {
this.#onResetLoadEnd();
}
};
requestForGroup(i) {
const ranges = this.#flat.rangeTree.findRangesForRowIndex(i);
const path = ranges.slice(1).map((c) => (c.parent.kind === "parent" ? c.parent.path : null));
const row = this.#flat.rowIndexToRow.get(i);
if (row?.kind !== "branch")
return null;
path.push(row.key);
const r = (ranges.at(-1)?.parent).byPath.get(row.key);
const blocksize = this.#blocksize;
const start = 0;
const end = Math.min(start + blocksize, r.size);
const reqSize = end - start;
const req = {
path,
start: start,
end: end,
id: getRequestId(path, 0, blocksize),
rowStartIndex: i + 1,
rowEndIndex: i + 1 + reqSize,
};
return req;
}
handleRequests = async (requests, opts = {}) => {
const controller = new AbortController();
this.#controllers.add(controller);
const skip = opts.skipState ?? false;
// We need to request our new data now. There are a few scenarios to be aware. Once we are requesting rows,
// we should mark the rows loading. This means we mark the row indices as loading (maybe by range)?
// The load may fail, in which case we should mark the request as an error.
try {
// Mark these rows as loading
requests.forEach((req) => {
if (!skip)
for (let i = req.rowStartIndex; i < req.rowEndIndex; i++)
this.#loadingRows.add(i);
});
const responses = await this.#dataFetcher(requests, this.#expansions, this.#pivotExpansions);
// The request was aborted, so we can ignore it from this point
if (controller.signal.aborted)
return;
this.handleResponses(responses, () => {
if (!skip)
requests.forEach((req) => {
for (let i = req.rowStartIndex; i < req.rowEndIndex; i++)
this.#rowsWithError.delete(i);
for (let i = req.rowStartIndex; i < req.rowEndIndex; i++)
this.#loadingRows.delete(i);
});
});
opts?.onSuccess?.();
}
catch (e) {
if (controller.signal.aborted)
return;
opts?.onError?.(e);
this.#onInvalidate();
if (!skip)
requests.forEach((req) => {
for (let i = req.rowStartIndex; i < req.rowEndIndex; i++)
this.#rowsWithError.set(i, { error: e, request: req });
for (let i = req.rowStartIndex; i < req.rowEndIndex; i++)
this.#loadingRows.delete(i);
});
}
finally {
this.#controllers.delete(controller);
}
};
handleResponses = (data, beforeOnFlat) => {
const pinned = data.filter((c) => c.kind === "top" || c.kind === "bottom");
const center = data.filter((c) => c.kind !== "top" && c.kind !== "bottom");
// handle pinned
for (let i = 0; i < pinned.length; i++) {
const r = pinned[i];
if (r.kind === "top" && r.asOfTime > this.#top.asOf) {
this.#top = {
asOf: r.asOfTime,
rows: r.data.map((c) => ({ id: c.id, data: c.data, kind: "leaf" })),
};
}
else if (r.kind === "bottom" && r.asOfTime > this.#bottom.asOf) {
this.#bottom = {
asOf: r.asOfTime,
rows: r.data.map((c) => ({ id: c.id, data: c.data, kind: "leaf" })),
};
}
}
center.sort((l, r) => l.path.length - r.path.length);
for (let i = 0; i < center.length; i++) {
const r = center[i];
this.#tree.set({
path: r.path,
items: r.data.map((c, i) => {
if (c.kind === "leaf") {
return {
kind: "leaf",
data: {
kind: "leaf",
data: c.data,
id: c.id,
},
relIndex: r.start + i,
};
}
else {
return {
kind: "parent",
data: {
kind: "branch",
data: c.data,
depth: r.path.length,
id: c.id,
key: c.key,
},
path: c.key,
relIndex: r.start + i,
size: c.childCount,
};
}
}),
size: r.size,
asOf: r.asOfTime,
});
}
// Re-flatten our tree once everything has been re-updated.
this.#flatten(beforeOnFlat);
};
requestForNextSlice(req) {
let current = this.#tree;
for (const c of req.path) {
if (current.kind === "leaf")
return null;
const next = current.byPath.get(c);
if (!next)
return null;
current = next;
}
if (current.kind === "leaf")
return null;
const maxSize = current.size;
if (req.end >= maxSize)
return null;
const prevSize = req.end - req.start;
const start = req.end;
const end = Math.min(req.end + this.#blocksize, maxSize);
const size = end - start;
return {
id: getRequestId(req.path, start, start + this.#blocksize),
path: req.path,
start,
end,
rowStartIndex: req.rowStartIndex + prevSize,
rowEndIndex: req.rowStartIndex + prevSize + size,
};
}
requestsForView(start, end) {
const bounds = this.#rowViewBounds;
start = start ?? bounds[0];
end = end ?? bounds[1];
const seen = new Set();
const requests = [];
for (let i = start; i < end; i++) {
const ranges = this.#flat.rangeTree.findRangesForRowIndex(i);
ranges.forEach((c) => {
if (c.parent.kind === "root") {
const blockIndex = Math.floor(i / this.#blocksize);
const start = blockIndex * this.#blocksize;
const end = Math.min(start + this.#blocksize, c.parent.size);
const path = [];
const reqId = getRequestId(path, start, start + this.#blocksize);
if (seen.has(reqId))
return;
seen.add(reqId);
const size = start + this.#blocksize > c.parent.size ? c.parent.size - start : this.#blocksize;
requests.push({ id: reqId, path, start, end, rowStartIndex: i, rowEndIndex: i + size });
}
else {
const blockIndex = Math.floor((i - c.rowStart) / this.#blocksize);
const start = blockIndex * this.#blocksize;
const end = Math.min(start + this.#blocksize, c.parent.size);
const path = getNodePath(c.parent);
const reqId = getRequestId(path, start, start + this.#blocksize);
if (seen.has(reqId))
return;
seen.add(reqId);
const size = start + this.#blocksize > c.parent.size ? c.parent.size - start : this.#blocksize;
requests.push({ id: reqId, path, start, end, rowStartIndex: i, rowEndIndex: i + size });
}
});
}
return requests;
}
async handleViewBoundsChange() {
const requests = this.requestsForView();
const newRequests = requests.filter((c) => !this.#seenRequests.has(c.id));
// We don't have any new requests to make in our view, so we can return
if (!newRequests.length)
return;
for (const n of newRequests)
this.#seenRequests.add(n.id);
await this.handleRequests(newRequests);
}
retry() {
const inViewSet = new Set(this.requestsForView().map((c) => c.id));
const erroredRequests = [...this.#rowsWithError.values()]
.map((c) => c.request)
.filter(Boolean);
const erroredGroups = [...this.#rowsWithGroupError.entries()];
const errors = erroredRequests.filter((x) => inViewSet.has(x.id));
const [start, end] = this.#rowViewBounds;
const groupErrors = erroredGroups
.filter(([index]) => {
return index >= start && index < end;
})
.map(([index, c]) => [index, c.request]);
const seenRequests = this.#seenRequests;
erroredRequests.map((x) => seenRequests.delete(x.id));
erroredGroups.map((x) => seenRequests.delete(x[1].request.id));
this.#rowsWithError.clear();
this.#rowsWithGroupError.clear();
const requests = [];
const seen = new Set();
groupErrors.forEach((x) => {
if (seen.has(x[1].id))
return;
requests.push(x[1]);
seen.add(x[1].id);
});
errors.forEach((x) => {
if (seen.has(x.id))
return;
seen.add(x.id);
requests.push(x);
});
for (const x of groupErrors) {
this.#loadingGroup.add(x[0]);
}
requests.forEach((x) => seenRequests.add(x.id));
const invalidate = this.#onInvalidate;
const withGroupError = this.#rowsWithGroupError;
const loadingGroup = this.#loadingGroup;
this.handleRequests(requests, {
onError: (e) => {
invalidate();
groupErrors.forEach((c) => {
withGroupError.set(c[0], { error: e, request: c[1] });
loadingGroup.delete(c[0]);
});
},
onSuccess: () => {
groupErrors.forEach((c) => {
loadingGroup.delete(c[0]);
});
},
});
}
updateRow(id, data) {
const centerRow = this.#flat.rowIdToTreeNode.get(id);
if (centerRow) {
centerRow.data = { ...centerRow.data, data };
return;
}
// Maybe its a pinned row?
const topIndex = this.#top.rows.findIndex((c) => c.id === id);
if (topIndex != -1) {
this.#top.rows[topIndex] = { ...this.#top.rows[topIndex], data };
}
const botIndex = this.#bottom.rows.findIndex((c) => c.id === id);
if (botIndex != -1) {
this.#top.rows[botIndex] = { ...this.#top.rows[botIndex], data };
}
}
flatten = () => {
this.#flatten();
};
#flatten = (beforeOnFlat) => {
// The mode we are in determines the expansions we will use for the server data.
const mode = this.#pivotMode;
const expansions = mode ? this.#pivotExpansions : this.#expansions;
const t = this.#tree;
// We use these maps to keep track of the current view. These are helpful for
// quick lookup. They are also used to implement many of the data source APIs.
const rowIdToRow = new Map();
const rowIndexToRow = new Map();
const rowIdToRowIndex = new Map();
const rowIdToTreeNode = new Map();
// When flattening the tree we need to keep track of the ranges. The tree itself
// will only have some rows loaded, but the ranges will be fully defined.
const ranges = [];
const blocksize = this.#blocksize;
const seen = this.#seenRequests;
// Tracks the error and loading state of the rows.
const withGroupError = this.#rowsWithGroupError;
const withLoadingGroup = this.#loadingGroup;
const handleRequests = this.handleRequests;
const defaultExpansion = this.#defaultExpansion;
const postFlatRequests = [];
function processParent(node, start) {
const rows = [...node.byIndex.values()].sort((l, r) => l.relIndex - r.relIndex);
let offset = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = row.relIndex + start + offset;
rowIndexToRow.set(rowIndex, row.data);
rowIdToRowIndex.set(row.data.id, rowIndex);
rowIdToRow.set(row.data.id, row.data);
rowIdToTreeNode.set(row.data.id, row);
// If this rows is a parent row, we need to check if it is expanded. There are a couple of
// situations this to consider.
// - the row is not expanded, in which case we only add the row itself to the flat view
// - the row is expanded but it has no data loaded. We should then request data, but not add the rows
// - the row is expanded and there is add. This is the easy case, we simply add the child rows as we flatten
if (row.kind === "parent") {
const expanded = expansions[row.data.id] ??
(typeof defaultExpansion === "number"
? getNodeDepth(row) <= defaultExpansion
: defaultExpansion);
// Expanded but no data. Fetch the child data.
if (expanded && !row.byIndex.size) {
const path = getNodePath(row);
const start = 0;
const end = Math.min(start + blocksize, row.size);
const reqSize = end - start;
const req = {
path,
start: start,
end: end,
id: getRequestId(path, 0, blocksize),
rowStartIndex: rowIndex + 1,
rowEndIndex: rowIndex + 1 + reqSize,
};
// If we haven't already requested the children data for this node, let's request it.
if (!seen.has(req.id)) {
postFlatRequests.push([rowIndex, req]);
}
}
else if (expanded) {
offset += processParent(row, rowIndex + 1);
}
}
}
ranges.push({
rowStart: start,
rowEnd: offset + node.size + start,
parent: node,
});
return offset + node.size;
}
const topCount = this.#top.rows.length;
const bottomCount = this.#bottom.rows.length;
for (let i = 0; i < topCount; i++) {
const row = this.#top.rows[i];
rowIndexToRow.set(i, row);
rowIdToRow.set(row.id, row);
rowIdToRowIndex.set(row.id, i);
}
const size = processParent(t, topCount);
for (let i = 0; i < bottomCount; i++) {
const row = this.#bottom.rows[i];
const rowIndex = i + size;
rowIndexToRow.set(rowIndex, row);
rowIdToRow.set(row.id, row);
rowIdToRowIndex.set(row.id, rowIndex);
}
const rangeTree = new RangeTree(ranges);
if (postFlatRequests.length > 0) {
postFlatRequests.forEach((c) => {
withLoadingGroup.add(c[0]);
seen.add(c[1].id);
});
const invalidate = this.#onInvalidate;
const reqs = postFlatRequests.map((c) => c[1]);
handleRequests(reqs, {
skipState: true,
onError: (e) => {
invalidate();
postFlatRequests.forEach((c) => {
const rowIndex = c[0];
const req = c[1];
withLoadingGroup.delete(rowIndex);
withGroupError.set(rowIndex, { error: e, request: req });
});
},
onSuccess: () => {
postFlatRequests.forEach((c) => {
withLoadingGroup.delete(c[0]);
});
},
});
}
this.#flat = {
tree: t,
top: topCount,
center: size - topCount,
bottom: bottomCount,
rangeTree,
rowIndexToRow,
rowIdToRow,
rowIdToRowIndex,
rowIdToTreeNode,
errored: this.#rowsWithError,
erroredGroup: this.#rowsWithGroupError,
loading: this.#loadingRows,
loadingGroup: this.#loadingGroup,
seenRequests: seen,
};
beforeOnFlat?.();
this.#onFlatten(this.#flat);
};
}