@ornery/ui-grid-vanilla 1.0.4 → 1.0.6

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/src/patch.ts CHANGED
@@ -450,6 +450,22 @@ export function patchBodyRegion(
450
450
  bodyContainer.innerHTML = bodyContent;
451
451
  }
452
452
  el.lastItemsFingerprint = itemsFingerprint;
453
+ // Body just rebuilt — old per-row fingerprints reference cells that no
454
+ // longer exist in the DOM. Re-seed from the freshly-rendered rows so the
455
+ // next patch pass sees them as already-up-to-date.
456
+ el.lastRowStateFingerprints.clear();
457
+ for (let i = 0; i < itemsToRender.length; i++) {
458
+ const item = itemsToRender[i]!;
459
+ if (!isRowItem(item)) continue;
460
+ el.lastRowStateFingerprints.set(item.row.id, {
461
+ fingerprint: buildRowFingerprint(item.row, startIndex + i, snapshot.visibleColumns),
462
+ entity: item.row.entity,
463
+ });
464
+ }
465
+ el.lastEditingCellKey = el.controller?.getEditingCellKey() ?? null;
466
+ el.lastFocusedCellKey = el.focusedCell
467
+ ? `${el.focusedCell.rowId} ${el.focusedCell.columnName}`
468
+ : null;
453
469
  }
454
470
 
455
471
  /**
@@ -486,7 +502,6 @@ export function patchExistingRows(
486
502
  startIndex: number,
487
503
  ): void {
488
504
  const controller = el.controller!;
489
- void controller;
490
505
  const columns = snapshot.visibleColumns;
491
506
 
492
507
  // Build O(1) lookup maps from a single pass over bodyContainer.children.
@@ -521,6 +536,18 @@ export function patchExistingRows(
521
536
  templateMarkupMap.set(col.name, getTemplateMarkup(el, `cell-${col.name}`));
522
537
  }
523
538
 
539
+ // Track which row IDs we visited so we can prune stale fingerprint entries
540
+ // (rows that scrolled out of the rendered window keep their last fingerprint
541
+ // around so when they scroll back in we still detect changes — but rows
542
+ // that are gone for good shouldn't grow the map indefinitely).
543
+ const visitedRowIds = new Set<string>();
544
+ const editingCellKey = controller.getEditingCellKey();
545
+ const focusedCellKey = el.focusedCell
546
+ ? `${el.focusedCell.rowId} ${el.focusedCell.columnName}`
547
+ : null;
548
+ const editingMoved = editingCellKey !== el.lastEditingCellKey;
549
+ const focusedMoved = focusedCellKey !== el.lastFocusedCellKey;
550
+
524
551
  let expandableIndex = 0;
525
552
  for (let i = 0; i < itemsToRender.length; i++) {
526
553
  const item = itemsToRender[i]!;
@@ -552,14 +579,104 @@ export function patchExistingRows(
552
579
  if (!isRowItem(item)) continue;
553
580
 
554
581
  const row = item.row;
582
+ visitedRowIds.add(row.id);
555
583
  const rowCells = cellEls.get(row.id);
556
584
  if (!rowCells) continue;
585
+
586
+ // Per-row visual fingerprint — captures every input that drives
587
+ // patchBodyCell's class / attrs / shell content. When this matches the
588
+ // previous render's fingerprint AND the focused/editing cell hasn't
589
+ // crossed in or out of this row, we can skip the per-cell patch entirely.
590
+ // This is the hot path for selection / focus / expand toggles, where
591
+ // most rows are visually unchanged but still walk the entire columns
592
+ // loop in the previous implementation.
593
+ const rowFingerprint = buildRowFingerprint(row, displayIndex, columns);
594
+ const lastEntry = el.lastRowStateFingerprints.get(row.id);
595
+ const editingTouchesRow =
596
+ (editingCellKey !== null && editingCellKey.startsWith(`${row.id} `)) ||
597
+ (el.lastEditingCellKey !== null && el.lastEditingCellKey.startsWith(`${row.id} `));
598
+ const focusedTouchesRow =
599
+ (focusedCellKey !== null && focusedCellKey.startsWith(`${row.id} `)) ||
600
+ (el.lastFocusedCellKey !== null && el.lastFocusedCellKey.startsWith(`${row.id} `));
601
+
602
+ // Skip when row identity (entity reference), visual fingerprint, and
603
+ // the focused/editing cell positions are all unchanged. The entity
604
+ // reference guard catches data-array swaps that produce a fresh row
605
+ // instance with potentially new field values.
606
+ const canSkip =
607
+ lastEntry !== undefined &&
608
+ lastEntry.fingerprint === rowFingerprint &&
609
+ lastEntry.entity === row.entity &&
610
+ !(editingMoved && editingTouchesRow) &&
611
+ !(focusedMoved && focusedTouchesRow);
612
+
613
+ if (canSkip) {
614
+ // Even though we're skipping the per-cell DOM patch, the row's
615
+ // framework-rendered slots must persist past the upcoming `flush()`.
616
+ // `flush()` diffs `pendingCellSlots` against `lastCellSlots`; without
617
+ // carrying the row's slots forward, every unchanged row in a structural
618
+ // patch pass would emit a spurious `cellSlotsChanged removed` event,
619
+ // tearing down framework wrappers' projected views (Angular embedded
620
+ // views, React portals) that already match the DOM.
621
+ el.frameworkSlots.carryRowCells(row.id);
622
+ continue;
623
+ }
624
+
625
+ el.lastRowStateFingerprints.set(row.id, { fingerprint: rowFingerprint, entity: row.entity });
626
+
557
627
  for (const column of columns) {
558
628
  const target = rowCells.get(column.name);
559
629
  if (!target) continue;
560
630
  patchBodyCell(el, target, row, column, displayIndex, templateMarkupMap);
561
631
  }
562
632
  }
633
+
634
+ // Prune fingerprint entries for rows that are no longer in the rendered
635
+ // window. The map otherwise grows without bound across scroll passes.
636
+ if (el.lastRowStateFingerprints.size > visitedRowIds.size) {
637
+ for (const id of el.lastRowStateFingerprints.keys()) {
638
+ if (!visitedRowIds.has(id)) el.lastRowStateFingerprints.delete(id);
639
+ }
640
+ }
641
+
642
+ el.lastEditingCellKey = editingCellKey;
643
+ el.lastFocusedCellKey = focusedCellKey;
644
+ }
645
+
646
+ /**
647
+ * Per-row fingerprint of every input patchBodyCell reads off the row.
648
+ * Stable across renders when nothing visual has changed; flips when
649
+ * selection, focus, expand, dirty/saving/error, tree-expand, indent, or
650
+ * the entity reference moves. Used as the skip key in patchExistingRows.
651
+ *
652
+ * Also samples per-column validation state — `validate.runValidators`
653
+ * mutates fields directly on the row entity (`$$invalid<col>`), so we
654
+ * read those keys here to detect validity flips even though the entity
655
+ * reference is unchanged.
656
+ */
657
+ function buildRowFingerprint(
658
+ row: GridRow,
659
+ displayIndex: number,
660
+ visibleColumns: readonly GridColumnDef[],
661
+ ): string {
662
+ // Bit-packing flags into a single number keeps the fingerprint short.
663
+ let flags = 0;
664
+ if (row.isSelected) flags |= 1;
665
+ if (row.isFocused) flags |= 2;
666
+ if (row.expanded) flags |= 4;
667
+ if (row.isDirty) flags |= 8;
668
+ if (row.isSaving) flags |= 16;
669
+ if (row.isError) flags |= 32;
670
+ if (row.visible) flags |= 64;
671
+ // Per-column validity bitmap. The validate module flags cells via
672
+ // `$$invalid<col>` keys on the entity; without sampling them here a
673
+ // selection-only refresh would skip cells whose validity just flipped.
674
+ let invalidBits = '';
675
+ const entity = row.entity as Record<string, unknown>;
676
+ for (const col of visibleColumns) {
677
+ invalidBits += entity[`$$invalid${col.name}`] ? '1' : '0';
678
+ }
679
+ return `${displayIndex}|${flags}|${row.treeLevel}|${row.height}|${row.expandedRowHeight}|${invalidBits}`;
563
680
  }
564
681
 
565
682
  /** Patch a group-row element's data-attrs. */
@@ -105,7 +105,7 @@ describe('vanilla grid row-edit', () => {
105
105
  const options = baseOptions();
106
106
  await mountGrid(options);
107
107
  const api = getApi(options);
108
- const handler = vi.fn<[Record<string, unknown>], void>();
108
+ const handler = vi.fn<(row: Record<string, unknown>) => void>();
109
109
  api.rowEdit.on.saveRow(handler);
110
110
  const row = options.data[0]! as unknown as Record<string, unknown>;
111
111
  api.edit.raise.afterCellEdit(row, options.columnDefs[0]!, 'Alpha!', 'Alpha');
@@ -111,6 +111,12 @@ export type VanillaUiGridElement = HTMLElement & {
111
111
  * remains completely smooth while data ticks in the background.
112
112
  */
113
113
  setData(rows: GridRecord[]): void;
114
+ /**
115
+ * Declare which slots the grid should emit as `<slot>` placeholders for a
116
+ * framework wrapper to project into. See `FrameworkSlotBridge` for the
117
+ * full contract.
118
+ */
119
+ setFrameworkRenderedSlots(config: FrameworkRenderedSlotsConfig): void;
114
120
  };
115
121
 
116
122
  type VanillaGridOptions = GridOptions & {
@@ -170,6 +176,15 @@ export class UiGridStandaloneElement extends HTMLElement {
170
176
  /** @internal */ lastItemsFingerprint: string | null = null;
171
177
  /** @internal */ lastVirtualOffset = 0;
172
178
  /** @internal */ lastTotalVirtualHeight = 0;
179
+ /**
180
+ * @internal Per-row visual-state fingerprint from the previous patch pass.
181
+ * The patch path uses this to skip cells whose row state hasn't changed
182
+ * (selection, expand, dirty/saving/error flags) when the structure
183
+ * fingerprint matches. Cleared on full re-render.
184
+ */
185
+ lastRowStateFingerprints = new Map<string, { fingerprint: string; entity: unknown }>();
186
+ /** @internal */ lastEditingCellKey: string | null = null;
187
+ /** @internal */ lastFocusedCellKey: string | null = null;
173
188
 
174
189
  /**
175
190
  * @internal Framework-rendered slot bridge. See `./framework-slots.ts`.
@@ -220,7 +235,11 @@ export class UiGridStandaloneElement extends HTMLElement {
220
235
  if (this.frameworkSlots.configure(config)) {
221
236
  // The set of slot placeholders changed — force a structural re-render
222
237
  // so framework-rendered slots transition to `<slot>` nodes (or the
223
- // reverse, when a slot is revoked).
238
+ // reverse, when a slot is revoked). Also bust the per-row patch
239
+ // fingerprint cache, because the cell-shell content depends on
240
+ // hasCell() / hasHeader() / etc. — values not captured by the
241
+ // row-level fingerprint.
242
+ this.lastRowStateFingerprints.clear();
224
243
  this.render();
225
244
  }
226
245
  }
@@ -692,11 +711,58 @@ export class UiGridStandaloneElement extends HTMLElement {
692
711
  cancelAnimationFrame(this.scrollFrame);
693
712
  this.scrollFrame = null;
694
713
  }
714
+ this.controller?.setBenchmarkHandler(null);
695
715
  // Emit `removed` events for every framework-rendered slot so wrappers
696
716
  // can destroy their views.
697
717
  this.frameworkSlots.flushRemovals();
698
718
  }
699
719
 
720
+ private benchmarkRender(iterations?: number) {
721
+ const controller = this.controller;
722
+ const snapshot = this.snapshot;
723
+ if (!controller || !snapshot) {
724
+ return Promise.resolve({
725
+ iterations: 0,
726
+ totalMs: 0,
727
+ averageMs: 0,
728
+ visibleRows: 0,
729
+ renderedItems: 0,
730
+ });
731
+ }
732
+
733
+ const now = () => (typeof performance === 'undefined' ? Date.now() : performance.now());
734
+ const nextFrame = () =>
735
+ new Promise<void>((resolve) => {
736
+ if (typeof requestAnimationFrame === 'undefined') {
737
+ setTimeout(resolve, 0);
738
+ return;
739
+ }
740
+ requestAnimationFrame(() => resolve());
741
+ });
742
+ const loops = Math.max(1, iterations ?? controller.getOptions().benchmark?.iterations ?? 25);
743
+ return (async () => {
744
+ const started = now();
745
+
746
+ for (let index = 0; index < loops; index += 1) {
747
+ this.render();
748
+ await nextFrame();
749
+ }
750
+
751
+ const currentSnapshot = this.snapshot ?? snapshot;
752
+ const totalMs = now() - started;
753
+ const result = {
754
+ iterations: loops,
755
+ totalMs,
756
+ averageMs: totalMs / loops,
757
+ visibleRows: currentSnapshot.pipeline.visibleRows.length,
758
+ renderedItems: currentSnapshot.pipeline.displayItems.length,
759
+ };
760
+
761
+ controller.gridApi.core.raise.benchmarkComplete(result);
762
+ return result;
763
+ })();
764
+ }
765
+
700
766
  /** @internal */
701
767
  ensureShadowRoot(): ShadowRoot {
702
768
  if (!this.shadowRoot) {
@@ -709,6 +775,7 @@ export class UiGridStandaloneElement extends HTMLElement {
709
775
  /** @internal */
710
776
  ensureController(options: VanillaGridOptions): void {
711
777
  if (this.controller) {
778
+ this.controller.setBenchmarkHandler((iterations) => this.benchmarkRender(iterations));
712
779
  if (this.clientWidth > 0) {
713
780
  this.controller.setViewportWidth(this.clientWidth);
714
781
  }
@@ -717,6 +784,7 @@ export class UiGridStandaloneElement extends HTMLElement {
717
784
  }
718
785
 
719
786
  this.controller = createVanillaGridController(options);
787
+ this.controller.setBenchmarkHandler((iterations) => this.benchmarkRender(iterations));
720
788
  if (this.clientWidth > 0) {
721
789
  this.controller.setViewportWidth(this.clientWidth);
722
790
  }
@@ -919,6 +987,9 @@ export class UiGridStandaloneElement extends HTMLElement {
919
987
  if (!snapshot) {
920
988
  this.lastStructureKey = null;
921
989
  this.lastItemsFingerprint = null;
990
+ this.lastRowStateFingerprints.clear();
991
+ this.lastEditingCellKey = null;
992
+ this.lastFocusedCellKey = null;
922
993
  emptyTemplate({ message: 'No grid options provided.' }).connect(root);
923
994
  return;
924
995
  }
@@ -1223,6 +1294,13 @@ export class UiGridStandaloneElement extends HTMLElement {
1223
1294
  this.lastItemsFingerprint = this.fingerprintItems(plan.itemsToRender);
1224
1295
  this.lastVirtualOffset = plan.virtualOffset;
1225
1296
  this.lastTotalVirtualHeight = plan.totalVirtualHeight;
1297
+ // The row-state cache is keyed against the patched DOM. A full remount
1298
+ // wipes it; the next patch pass will repopulate as it walks rows.
1299
+ this.lastRowStateFingerprints.clear();
1300
+ this.lastEditingCellKey = this.controller?.getEditingCellKey() ?? null;
1301
+ this.lastFocusedCellKey = this.focusedCell
1302
+ ? `${this.focusedCell.rowId} ${this.focusedCell.columnName}`
1303
+ : null;
1226
1304
  }
1227
1305
 
1228
1306
  private renderPatch(
@@ -0,0 +1,30 @@
1
+ import path from 'path';
2
+ import { defineConfig } from 'vitest/config';
3
+ import { scssInlinePlugin } from './scss-plugin';
4
+
5
+ export default defineConfig({
6
+ plugins: [scssInlinePlugin()],
7
+ resolve: {
8
+ alias: {
9
+ '@ornery/ui-grid-core': path.resolve(__dirname, '../ui-grid-core/src/index.ts'),
10
+ },
11
+ },
12
+ css: {
13
+ preprocessorOptions: {
14
+ scss: {
15
+ loadPaths: [path.resolve(__dirname, 'node_modules')],
16
+ },
17
+ },
18
+ },
19
+ test: {
20
+ dir: path.resolve(__dirname, 'src'),
21
+ environment: 'happy-dom',
22
+ coverage: {
23
+ provider: 'v8',
24
+ include: ['src/**/*.ts'],
25
+ exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'src/test-setup.ts'],
26
+ reporter: ['text', 'lcov', 'html'],
27
+ reportsDirectory: './coverage',
28
+ },
29
+ },
30
+ });