@bendyline/squisq-grid-react 2.11.12 → 2.11.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -202,10 +202,12 @@ declare function buildKernelSource(): string;
202
202
  interface KernelHost {
203
203
  post(message: KernelRequest, transfer?: Transferable[]): void;
204
204
  onResponse: (message: KernelResponse) => void;
205
+ onFailure: (error: Error) => void;
205
206
  terminate(): void;
206
207
  }
207
208
  /** In-process host: drives `tableKernel` directly (SSR/tests/parity). */
208
209
  declare class LocalKernelHost implements KernelHost {
210
+ onFailure: (error: Error) => void;
209
211
  onResponse: (message: KernelResponse) => void;
210
212
  private readonly scope;
211
213
  constructor();
@@ -215,6 +217,8 @@ declare class LocalKernelHost implements KernelHost {
215
217
  interface TableStoreClientOptions {
216
218
  /** Force the in-process host (tests, SSR probes). */
217
219
  forceLocal?: boolean;
220
+ /** Maximum wait for a worker response; default 30 seconds. */
221
+ requestTimeoutMs?: number;
218
222
  }
219
223
  declare class TableStoreClient implements TableQueryProvider {
220
224
  private readonly host;
@@ -222,8 +226,11 @@ declare class TableStoreClient implements TableQueryProvider {
222
226
  private seq;
223
227
  private viewRowCount;
224
228
  private readonly pending;
229
+ private failure;
230
+ private readonly requestTimeoutMs;
225
231
  private ready;
226
232
  constructor(table: IngestTable, options?: TableStoreClientOptions);
233
+ private fail;
227
234
  private request;
228
235
  describe(): Promise<TableSchema>;
229
236
  setView(view: TableViewState): Promise<TableViewResult>;
@@ -244,10 +251,9 @@ declare class TableStoreClient implements TableQueryProvider {
244
251
  * identity was never derived from values.
245
252
  *
246
253
  * Batches are the undo unit (one commit = one batch). The journal is
247
- * additionally cached module-level by `(cacheKey)` — conventionally
248
- * `${path}@${mediaRevision}` — so a widget unmount/remount (ProseMirror
249
- * re-decoration) does not lose unsaved edits; a revision bump (save or
250
- * re-upload) naturally misses the cache and starts clean.
254
+ * additionally cached by storage owner, path, and region so a widget
255
+ * unmount/remount does not lose unsaved edits. Scoped journals survive
256
+ * revision bumps; saving acknowledges only the persisted snapshot.
251
257
  */
252
258
 
253
259
  interface JournalEntry {
@@ -273,17 +279,25 @@ declare class EditJournal {
273
279
  redo(): TableCellEdit[];
274
280
  /** Net outstanding edits (what a Save must persist). */
275
281
  entries(): JournalEntry[];
282
+ /** Advance the save baseline without losing edits (or undo) made during I/O. */
283
+ acknowledge(saved: readonly JournalEntry[]): void;
276
284
  clear(): void;
277
285
  private applyLatest;
278
286
  private rebuildLatest;
279
287
  }
288
+ interface JournalScope {
289
+ /** Container or provider that owns the bytes; never a relative file path. */
290
+ owner: object;
291
+ /** Worksheet, region anchor, and header interpretation. */
292
+ region: string;
293
+ }
280
294
  /**
281
- * Get (or create) the journal for a cache key. Any entry for the same path
282
- * at a DIFFERENT revision is discarded — a revision bump means the bytes
283
- * changed underneath the edits.
295
+ * Get the journal for an owner/path/region. Scoped edits survive a revision
296
+ * change elsewhere in the container. Legacy unscoped callers start clean
297
+ * at each revision, preserving the original two-argument API.
284
298
  */
285
- declare function journalFor(path: string, revision: number): EditJournal;
286
- declare function discardJournal(path: string): void;
299
+ declare function journalFor(path: string, revision: number, scope?: JournalScope): EditJournal;
300
+ declare function discardJournal(path: string, scope?: JournalScope): void;
287
301
 
288
302
  /**
289
303
  * DataGrid — the virtualized grid over a `TableQueryProvider`.
package/dist/index.js CHANGED
@@ -399,6 +399,8 @@ function buildKernelSource() {
399
399
  import { parseTableViewState, serializeTableViewState } from "@bendyline/squisq/table";
400
400
  var LocalKernelHost = class {
401
401
  constructor() {
402
+ this.onFailure = () => {
403
+ };
402
404
  this.onResponse = () => {
403
405
  };
404
406
  const scope = {
@@ -417,6 +419,8 @@ var LocalKernelHost = class {
417
419
  };
418
420
  var BlobWorkerHost = class {
419
421
  constructor() {
422
+ this.onFailure = () => {
423
+ };
420
424
  this.onResponse = () => {
421
425
  };
422
426
  const url = URL.createObjectURL(new Blob([buildKernelSource()], { type: "text/javascript" }));
@@ -428,6 +432,11 @@ var BlobWorkerHost = class {
428
432
  this.worker.onmessage = (event) => {
429
433
  this.onResponse(event.data);
430
434
  };
435
+ this.worker.onerror = (event) => {
436
+ event.preventDefault();
437
+ this.onFailure(new Error(event.message || "Table worker failed"));
438
+ };
439
+ this.worker.onmessageerror = () => this.onFailure(new Error("Table worker response could not be decoded"));
431
440
  }
432
441
  post(message, transfer) {
433
442
  this.worker.postMessage(message, transfer ?? []);
@@ -452,6 +461,8 @@ var TableStoreClient = class {
452
461
  constructor(table, options = {}) {
453
462
  this.seq = 0;
454
463
  this.pending = /* @__PURE__ */ new Map();
464
+ this.failure = null;
465
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
455
466
  const columnar = buildColumnarTable(table);
456
467
  this.schema = {
457
468
  columns: columnar.columns.map((column) => ({ name: column.name, kind: column.kind })),
@@ -460,12 +471,14 @@ var TableStoreClient = class {
460
471
  this.viewRowCount = columnar.rowCount;
461
472
  this.host = options.forceLocal || typeof Worker === "undefined" ? new LocalKernelHost() : new BlobWorkerHost();
462
473
  this.host.onResponse = (message) => {
463
- const resolve = this.pending.get(message.seq);
464
- if (resolve) {
474
+ const waiter = this.pending.get(message.seq);
475
+ if (waiter) {
465
476
  this.pending.delete(message.seq);
466
- resolve(message);
477
+ clearTimeout(waiter.timer);
478
+ waiter.respond(message);
467
479
  }
468
480
  };
481
+ this.host.onFailure = (error) => this.fail(error);
469
482
  const payloads = columnar.columns.map(toPayload);
470
483
  this.ready = this.request(
471
484
  (seq) => ({
@@ -476,19 +489,43 @@ var TableStoreClient = class {
476
489
  }),
477
490
  payloads.flatMap((entry) => entry.transfer)
478
491
  ).then(() => void 0);
492
+ void this.ready.catch(() => void 0);
493
+ }
494
+ fail(error) {
495
+ if (this.failure) return;
496
+ this.failure = error;
497
+ for (const waiter of this.pending.values()) {
498
+ clearTimeout(waiter.timer);
499
+ waiter.reject(error);
500
+ }
501
+ this.pending.clear();
502
+ this.host.terminate();
479
503
  }
480
504
  request(build, transfer) {
505
+ if (this.failure) return Promise.reject(this.failure);
481
506
  const seq = ++this.seq;
482
507
  return new Promise((resolve, reject) => {
483
- this.pending.set(seq, (message) => {
484
- if (message.type === "error") reject(new Error(message.message));
485
- else resolve(message);
508
+ this.pending.set(seq, {
509
+ respond: (message) => {
510
+ if (message.type === "error") reject(new Error(message.message));
511
+ else resolve(message);
512
+ },
513
+ reject,
514
+ timer: setTimeout(
515
+ () => this.fail(new Error("Table worker request timed out")),
516
+ this.requestTimeoutMs
517
+ )
486
518
  });
487
- this.host.post(build(seq), transfer);
519
+ try {
520
+ this.host.post(build(seq), transfer);
521
+ } catch (error) {
522
+ this.fail(error instanceof Error ? error : new Error(String(error)));
523
+ }
488
524
  });
489
525
  }
490
526
  async describe() {
491
527
  await this.ready;
528
+ if (this.failure) throw this.failure;
492
529
  return this.schema;
493
530
  }
494
531
  async setView(view) {
@@ -546,9 +583,12 @@ var TableStoreClient = class {
546
583
  return this.viewRowCount;
547
584
  }
548
585
  dispose() {
549
- this.host.post({ type: "dispose" });
550
- this.host.terminate();
551
- this.pending.clear();
586
+ if (this.failure) return;
587
+ try {
588
+ this.host.post({ type: "dispose" });
589
+ } finally {
590
+ this.fail(new Error("Table store disposed"));
591
+ }
552
592
  }
553
593
  };
554
594
 
@@ -602,7 +642,22 @@ var EditJournal = class {
602
642
  }
603
643
  /** Net outstanding edits (what a Save must persist). */
604
644
  entries() {
605
- return [...this.latest.values()];
645
+ return [...this.latest.values()].map((entry) => ({ ...entry }));
646
+ }
647
+ /** Advance the save baseline without losing edits (or undo) made during I/O. */
648
+ acknowledge(saved) {
649
+ const remaining = new Map(
650
+ this.entries().map((entry) => [`${entry.rowId}:${entry.col}`, entry])
651
+ );
652
+ for (const entry of saved) {
653
+ const key = `${entry.rowId}:${entry.col}`;
654
+ const current = remaining.get(key);
655
+ const next = current ? current.next : entry.prev;
656
+ if (next === entry.next) remaining.delete(key);
657
+ else remaining.set(key, { ...entry, prev: entry.next, next });
658
+ }
659
+ this.clear();
660
+ this.commit([...remaining.values()]);
606
661
  }
607
662
  clear() {
608
663
  this.latest.clear();
@@ -624,21 +679,38 @@ var EditJournal = class {
624
679
  }
625
680
  };
626
681
  var journalCache = /* @__PURE__ */ new Map();
627
- function journalFor(path, revision) {
628
- const key = `${revision} ${path}`;
629
- let journal = journalCache.get(key);
682
+ var scopedCaches = /* @__PURE__ */ new WeakMap();
683
+ function cacheFor(scope) {
684
+ if (!scope) return journalCache;
685
+ let cache = scopedCaches.get(scope.owner);
686
+ if (!cache) {
687
+ cache = /* @__PURE__ */ new Map();
688
+ scopedCaches.set(scope.owner, cache);
689
+ }
690
+ return cache;
691
+ }
692
+ function journalFor(path, revision, scope) {
693
+ const cache = cacheFor(scope);
694
+ const identity = JSON.stringify([path, scope?.region ?? ""]);
695
+ const key = `${revision} ${identity}`;
696
+ let journal = cache.get(key);
630
697
  if (!journal) {
631
- for (const existing of journalCache.keys()) {
632
- if (existing.endsWith(` ${path}`) && existing !== key) journalCache.delete(existing);
698
+ for (const [existing, previous] of cache) {
699
+ if (existing.endsWith(` ${identity}`) && existing !== key) {
700
+ if (scope) journal = previous;
701
+ cache.delete(existing);
702
+ }
633
703
  }
634
- journal = new EditJournal();
635
- journalCache.set(key, journal);
704
+ journal ?? (journal = new EditJournal());
705
+ cache.set(key, journal);
636
706
  }
637
707
  return journal;
638
708
  }
639
- function discardJournal(path) {
640
- for (const existing of [...journalCache.keys()]) {
641
- if (existing.endsWith(` ${path}`)) journalCache.delete(existing);
709
+ function discardJournal(path, scope) {
710
+ const cache = cacheFor(scope);
711
+ const identity = JSON.stringify([path, scope?.region ?? ""]);
712
+ for (const existing of [...cache.keys()]) {
713
+ if (existing.endsWith(` ${identity}`)) cache.delete(existing);
642
714
  }
643
715
  }
644
716
 
@@ -783,13 +855,32 @@ function DataGrid({
783
855
  const selectionCells = useRef(/* @__PURE__ */ new Map());
784
856
  const viewRef = useRef(view);
785
857
  viewRef.current = view;
786
- const editable = Boolean(journal && provider.applyEdits && !readOnlyReason);
858
+ const editCallbacksRef = useRef({ formulaSupport, onCellEdited });
859
+ editCallbacksRef.current = { formulaSupport, onCellEdited };
860
+ const restoreRef = useRef(Promise.resolve());
861
+ const writable = Boolean(journal && provider.applyEdits && !readOnlyReason);
862
+ const editable = writable && !saving;
787
863
  const dirtyCount = journal?.dirtyCount ?? 0;
788
864
  void dirtyTick;
789
865
  void fetchTick;
790
866
  useEffect(() => {
791
867
  let cancelled = false;
792
- provider.describe().then(
868
+ const restore = async () => {
869
+ const edits = journal?.entries().filter(
870
+ ({ rowId, col }) => !editCallbacksRef.current.formulaSupport?.getFormula(rowId, col)
871
+ ).map(({ rowId, col, next }) => ({ rowId, col, value: next })) ?? [];
872
+ if (edits.length && provider.applyEdits) {
873
+ await provider.applyEdits(edits);
874
+ for (const edit of edits) {
875
+ if (cancelled) break;
876
+ const updates = await editCallbacksRef.current.onCellEdited?.(edit);
877
+ if (updates?.length) await provider.applyEdits(updates);
878
+ }
879
+ }
880
+ return provider.describe();
881
+ };
882
+ restoreRef.current = restore();
883
+ restoreRef.current.then(
793
884
  (described) => {
794
885
  if (!cancelled) setSchema(described);
795
886
  },
@@ -798,9 +889,10 @@ function DataGrid({
798
889
  return () => {
799
890
  cancelled = true;
800
891
  };
801
- }, [provider]);
892
+ }, [provider, journal]);
802
893
  const applyView = useCallback(
803
894
  async (next) => {
895
+ await restoreRef.current;
804
896
  const result = await provider.setView(next);
805
897
  cache.current = { version: cache.current.version + 1, pages: /* @__PURE__ */ new Map() };
806
898
  inFlight.current.clear();
@@ -814,7 +906,7 @@ function DataGrid({
814
906
  [provider]
815
907
  );
816
908
  useEffect(() => {
817
- void applyView(view);
909
+ void applyView(view).catch(() => void 0);
818
910
  }, [applyView, JSON.stringify(serializeTableViewState2(view))]);
819
911
  const virtualizer = useVirtualizer({
820
912
  count: viewRowCount,
@@ -909,14 +1001,15 @@ function DataGrid({
909
1001
  (pos, seed) => {
910
1002
  if (!editable || !schema) return;
911
1003
  const current = cellAt(pos.row);
912
- if (current && isCellLocked?.(current.rowId, pos.col)) {
1004
+ if (!current) return;
1005
+ if (isCellLocked?.(current.rowId, pos.col)) {
913
1006
  setAnnounce(lockedReason ?? "This cell is locked");
914
1007
  return;
915
1008
  }
916
- const formula = current ? formulaSupport?.getFormula(current.rowId, pos.col) : void 0;
1009
+ const formula = formulaSupport?.getFormula(current.rowId, pos.col);
917
1010
  setEditing({
918
1011
  ...pos,
919
- draft: seed ?? (formula !== void 0 ? `=${formula}` : cellDisplay(current?.cells[pos.col] ?? null))
1012
+ draft: seed ?? (formula !== void 0 ? `=${formula}` : cellDisplay(current.cells[pos.col] ?? null))
920
1013
  });
921
1014
  },
922
1015
  [cellAt, editable, schema, isCellLocked, lockedReason, formulaSupport]
@@ -941,6 +1034,7 @@ function DataGrid({
941
1034
  setFetchTick((t) => t + 1);
942
1035
  }, []);
943
1036
  const commitEdit = useCallback(async () => {
1037
+ if (!editable) return false;
944
1038
  if (!editing || !schema || !journal || !provider.applyEdits) return true;
945
1039
  if (formulaSupport && editing.draft.trim().startsWith("=")) {
946
1040
  const located2 = cellAt(editing.row);
@@ -999,10 +1093,20 @@ function DataGrid({
999
1093
  }
1000
1094
  setDirtyTick((t) => t + 1);
1001
1095
  return true;
1002
- }, [applyCacheUpdates, cellAt, editing, formulaSupport, journal, onCellEdited, provider, schema]);
1096
+ }, [
1097
+ applyCacheUpdates,
1098
+ cellAt,
1099
+ editable,
1100
+ editing,
1101
+ formulaSupport,
1102
+ journal,
1103
+ onCellEdited,
1104
+ provider,
1105
+ schema
1106
+ ]);
1003
1107
  const handlePaste = useCallback(
1004
1108
  (event) => {
1005
- if (!selection || !schema || !journal || !provider.applyEdits || editing) return;
1109
+ if (!editable || !selection || !schema || !journal || !provider.applyEdits || editing) return;
1006
1110
  const target = event.target;
1007
1111
  if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
1008
1112
  const text = event.clipboardData.getData("text/plain");
@@ -1073,6 +1177,7 @@ function DataGrid({
1073
1177
  [
1074
1178
  applyCacheUpdates,
1075
1179
  cellAt,
1180
+ editable,
1076
1181
  editing,
1077
1182
  isCellLocked,
1078
1183
  journal,
@@ -1085,16 +1190,23 @@ function DataGrid({
1085
1190
  );
1086
1191
  const runJournal = useCallback(
1087
1192
  async (direction) => {
1088
- if (!journal || !provider.applyEdits) return;
1193
+ if (!editable || !journal || !provider.applyEdits) return;
1089
1194
  const edits = direction === "undo" ? journal.undo() : journal.redo();
1090
1195
  if (edits.length === 0) return;
1091
1196
  const result = await provider.applyEdits(edits);
1092
1197
  if (result.staleView) setStaleView(true);
1198
+ for (const edit of edits) {
1199
+ const dependents = await onCellEdited?.(edit);
1200
+ if (dependents?.length) {
1201
+ const applied = await provider.applyEdits(dependents);
1202
+ if (applied.staleView) setStaleView(true);
1203
+ }
1204
+ }
1093
1205
  cache.current.pages.clear();
1094
1206
  setDirtyTick((t) => t + 1);
1095
1207
  setFetchTick((t) => t + 1);
1096
1208
  },
1097
- [journal, provider]
1209
+ [editable, journal, provider, onCellEdited]
1098
1210
  );
1099
1211
  const cycleSort = useCallback(
1100
1212
  (columnName, additive) => {
@@ -1728,7 +1840,7 @@ function DataGrid({
1728
1840
  }
1729
1841
  ),
1730
1842
  readOnlyReason && /* @__PURE__ */ jsx("span", { className: "squisq-grid-readonly", children: readOnlyReason }),
1731
- editable && dirtyCount + extraDirtyCount > 0 && /* @__PURE__ */ jsxs("span", { className: "squisq-grid-dirtybar", children: [
1843
+ writable && dirtyCount + extraDirtyCount > 0 && /* @__PURE__ */ jsxs("span", { className: "squisq-grid-dirtybar", children: [
1732
1844
  (dirtyCount + extraDirtyCount).toLocaleString(),
1733
1845
  " unsaved edit",
1734
1846
  dirtyCount + extraDirtyCount === 1 ? "" : "s",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-grid-react",
3
- "version": "2.11.12",
3
+ "version": "2.11.13",
4
4
  "description": "Virtualized data grid for squisq data sidecars: columnar worker store, TableQueryProvider, and a TanStack-Virtual React renderer",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -53,7 +53,7 @@
53
53
  "typecheck": "tsc --noEmit"
54
54
  },
55
55
  "dependencies": {
56
- "@bendyline/squisq": "2.11.11",
56
+ "@bendyline/squisq": "2.11.12",
57
57
  "@tanstack/react-virtual": "3.14.10"
58
58
  },
59
59
  "peerDependencies": {