From 2dbc626464374c6d2832d023db3813071845b524 Mon Sep 17 00:00:00 2001 From: Oleh Maikovych Date: Fri, 3 Jul 2026 21:50:27 +0300 Subject: [PATCH 1/5] #2780 Wrapped paper icon button in egi cell into dom-if to increased speed of rendering of EGI. --- .../ua/com/fielden/platform/web/egi/tg-egi-cell.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js index edc93b2714..d2d58e8dae 100644 --- a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js +++ b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js @@ -7,6 +7,8 @@ import '/resources/polymer/@polymer/iron-icons/iron-icons.js'; import '/resources/polymer/@polymer/paper-icon-button/paper-icon-button.js'; import '/resources/polymer/@polymer/paper-styles/color.js'; +import '/resources/polymer/@polymer/polymer/lib/elements/dom-if.js'; + import { Polymer } from '/resources/polymer/@polymer/polymer/lib/legacy/polymer-fn.js'; import { html } from '/resources/polymer/@polymer/polymer/lib/utils/html-tag.js'; @@ -130,7 +132,14 @@ const template = html` [[_value]]
- `; + + `; Polymer({ @@ -344,11 +353,12 @@ Polymer({ /** * Opens the shared EGI dropdown with this column's property-action groups, positioned next to the overflow button. * Stops event propagation so the click does not also fire the cell-tap (which would re-run the first action). + * The overflow button is stamped inside a dom-if, so it is resolved via `$$` rather than the static `this.$` map (dynamic node ids are not registered in `this.$`). */ _openOverflow: function (e) { e.stopPropagation(); if (this._hostComponent && this._entity && this.column) { - this._hostComponent._openDropDownForPropertyActions(this._entity, this.column, this.$.dropdownAction); + this._hostComponent._openDropDownForPropertyActions(this._entity, this.column, this.$$('#dropdownAction')); } } }); \ No newline at end of file From f779dd208c96d43185a8abaf059dc2ba8a448122 Mon Sep 17 00:00:00 2001 From: 01es Date: Sun, 5 Jul 2026 22:43:22 +1000 Subject: [PATCH 2/5] #2780 Deferred tg-fullcalendar event source rebuild while hidden; batched calendar mutations into a single re-render. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event source rebuild ran on every centre run even while the calendar was a hidden alternative view (display:none inside iron-pages), and FullCalendar re-renders synchronously after every single mutation: - clearing the previous run’s events dispatched one remove per event; - each entity added up to three event segments (timed + all-day splitting from #2750), each triggering its own re-render; - gotoDate was invoked inside the entity loop, dispatching up to once per entity; - a trailing render() forced one more full render plus updateSize. For ~300 entities this meant 1000+ synchronous re-renders per run, all wasted while the calendar was not visible — the dominant contributor to the centre slowdown reported in #2780. Changes: - _updateEventSource now skips the rebuild while the calendar is hidden (offsetParent === null), raising a pending flag instead; _resizeEventListener flushes the pending rebuild upon the iron-resize event that accompanies becoming visible (iron-pages performs notifyResize on every selection change, and the calendar already relies on that same event for layout recovery after being shown). - The rebuild moved into _rebuildEventSource with all mutations wrapped in Calendar::batchRendering, so exactly one re-render happens when the batch completes. - removeAllEvents() replaces per-event remove() — a single dispatch instead of one per previous event. - gotoDate is hoisted out of the entity loop and executed once, after the earliest event start across all entities is known. - Redundant trailing render() calls removed — the batch resume performs the single render; iron-resize still triggers render() for genuine resizes and on show. - Entities without a start date no longer poison startTime (previously Infinity > null coerced startTime to null, silently disabling navigate-to-first-event for the whole run). The per-entity event splitting logic is unchanged. Subclasses that override only tooltip logic (e.g. tg-work-activity-calendar in TTGAMS) inherit the optimisation as-is. --- .../fullcalendar/tg-fullcalendar.js | 170 +++++++++++------- 1 file changed, 102 insertions(+), 68 deletions(-) diff --git a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/components/fullcalendar/tg-fullcalendar.js b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/components/fullcalendar/tg-fullcalendar.js index b71d37a806..5dd5298f21 100644 --- a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/components/fullcalendar/tg-fullcalendar.js +++ b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/components/fullcalendar/tg-fullcalendar.js @@ -171,6 +171,8 @@ export class TgFullcalendar extends mixinBehaviors([IronResizableBehavior], Poly super(); this._reflector = new TgReflector(); this._appConfig = new TgAppConfig(); + // Raised when an event source rebuild is requested while the calendar is hidden; flushed by `_resizeEventListener` once the calendar becomes visible. + this._pendingEventSourceUpdate = false; } ready () { @@ -309,94 +311,126 @@ export class TgFullcalendar extends mixinBehaviors([IronResizableBehavior], Poly } /** - * Updates calendar data; moves it to the date of the chronologically first event (if any); re-renders calendar. + * Updates calendar data; moves it to the date of the chronologically first event (if any). + * + * While the calendar is hidden (`offsetParent === null`, e.g. a non-selected alternative view that `iron-pages` hides via `display: none`), + * the rebuild is skipped — a pending flag is raised instead and `_resizeEventListener` flushes it upon the `iron-resize` event that accompanies becoming visible. + * This avoids rebuilding the whole event source on every centre run while the calendar cannot be seen. */ _updateEventSource(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty, _calendar) { + if (!_calendar) { + return; + } + if (this.offsetParent === null) { + this._pendingEventSourceUpdate = true; + return; + } + this._rebuildEventSource(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty, _calendar); + } + + /** + * Rebuilds calendar events from `entities`, navigating to the chronologically first event (if any); the calendar re-renders exactly once. + * + * All mutations are grouped under `batchRendering` because FullCalendar otherwise re-renders synchronously after every single mutation + * (each removed event, each added event, each navigation) — an unbatched rebuild triggers thousands of re-renders for large result sets. + * The single re-render happens when the batch completes, hence no explicit `render()` call. + */ + _rebuildEventSource(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty, _calendar) { // Sentinels for open-ended events. Far enough outside any plausible calendar view to behave like ±infinity. const FAR_PAST = moment('1900-01-01').toDate(); const FAR_FUTURE = moment('2200-01-01').toDate(); - if (allDefined(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty) && _calendar) { - _calendar.getEvents().forEach(event => event.remove()); - let startTime = Infinity; - // Month view shows a single continuous bar per entity; time-grid views split into timed + allDay segments. - const isMonth = this.currentView === 'dayGridMonth'; - entities.forEach(entity => { - if (startTime > entity.get(eventFromProperty)) { - startTime = entity.get(eventFromProperty); - } - const eventColor = this.colorProperty ? entity.get(this.colorProperty) : undefined; - const startVal = entity.get(eventFromProperty); - const endVal = entity.get(eventToProperty); - const common = { - extendedProps: { entity: entity }, - title: entity.get(eventKeyProperty) + (eventDescProperty && entity.get(eventDescProperty) ? " - "+ entity.get(eventDescProperty) : ""), - backgroundColor: eventColor ? '#' + eventColor["hashlessUppercasedColourValue"] : "#3788d8", - groupId: 'evt-' + entity.get('id') - }; - if (isMonth) { - if (!startVal && !endVal) { + if (allDefined(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty)) { + _calendar.batchRendering(() => { + _calendar.removeAllEvents(); + let startTime = Infinity; + // Month view shows a single continuous bar per entity; time-grid views split into timed + allDay segments. + const isMonth = this.currentView === 'dayGridMonth'; + entities.forEach(entity => { + const startVal = entity.get(eventFromProperty); + const endVal = entity.get(eventToProperty); + // Events without a start date must not participate in `startTime`, otherwise a single such event would suppress the navigation below. + if (startVal && startTime > startVal) { + startTime = startVal; + } + const eventColor = this.colorProperty ? entity.get(this.colorProperty) : undefined; + const common = { + extendedProps: { entity: entity }, + title: entity.get(eventKeyProperty) + (eventDescProperty && entity.get(eventDescProperty) ? " - "+ entity.get(eventDescProperty) : ""), + backgroundColor: eventColor ? '#' + eventColor["hashlessUppercasedColourValue"] : "#3788d8", + groupId: 'evt-' + entity.get('id') + }; + if (isMonth) { + if (!startVal && !endVal) { + _calendar.addEvent({ ...common, start: FAR_PAST, end: FAR_FUTURE, allDay: true }); + } else if (!startVal) { + _calendar.addEvent({ ...common, start: FAR_PAST, end: endVal, allDay: true }); + } else if (!endVal) { + _calendar.addEvent({ ...common, start: startVal, end: FAR_FUTURE, allDay: true }); + } else { + _calendar.addEvent({ ...common, start: startVal, end: endVal }); + } + } else if (!startVal && !endVal) { _calendar.addEvent({ ...common, start: FAR_PAST, end: FAR_FUTURE, allDay: true }); } else if (!startVal) { - _calendar.addEvent({ ...common, start: FAR_PAST, end: endVal, allDay: true }); + const end = moment(endVal); + const endDayStart = end.clone().startOf('day'); + _calendar.addEvent({ ...common, start: FAR_PAST, end: endDayStart.toDate(), allDay: true }); + if (endDayStart.isBefore(end)) { + _calendar.addEvent({ ...common, start: endDayStart.toDate(), end: end.toDate() }); + } } else if (!endVal) { - _calendar.addEvent({ ...common, start: startVal, end: FAR_FUTURE, allDay: true }); - } else { - _calendar.addEvent({ ...common, start: startVal, end: endVal }); - } - } else if (!startVal && !endVal) { - _calendar.addEvent({ ...common, start: FAR_PAST, end: FAR_FUTURE, allDay: true }); - } else if (!startVal) { - const end = moment(endVal); - const endDayStart = end.clone().startOf('day'); - _calendar.addEvent({ ...common, start: FAR_PAST, end: endDayStart.toDate(), allDay: true }); - if (endDayStart.isBefore(end)) { - _calendar.addEvent({ ...common, start: endDayStart.toDate(), end: end.toDate() }); - } - } else if (!endVal) { - const start = moment(startVal); - const allDayStart = start.clone().startOf('day'); - if (allDayStart.isBefore(start)) { - allDayStart.add(1, 'day'); - } - if (start.isBefore(allDayStart)) { - _calendar.addEvent({ ...common, start: start.toDate(), end: allDayStart.toDate() }); - } - _calendar.addEvent({ ...common, start: allDayStart.toDate(), end: FAR_FUTURE, allDay: true }); - } else { - const start = moment(startVal); - const end = moment(endVal); - const fullDayStart = start.clone().startOf('day'); - if (fullDayStart.isBefore(start)) { - fullDayStart.add(1, 'day'); - } - const fullDayEnd = end.clone().startOf('day'); - if (!fullDayStart.isBefore(fullDayEnd)) { - _calendar.addEvent({ ...common, start: startVal, end: endVal }); + const start = moment(startVal); + const allDayStart = start.clone().startOf('day'); + if (allDayStart.isBefore(start)) { + allDayStart.add(1, 'day'); + } + if (start.isBefore(allDayStart)) { + _calendar.addEvent({ ...common, start: start.toDate(), end: allDayStart.toDate() }); + } + _calendar.addEvent({ ...common, start: allDayStart.toDate(), end: FAR_FUTURE, allDay: true }); } else { - if (start.isBefore(fullDayStart)) { - _calendar.addEvent({ ...common, start: start.toDate(), end: fullDayStart.toDate() }); + const start = moment(startVal); + const end = moment(endVal); + const fullDayStart = start.clone().startOf('day'); + if (fullDayStart.isBefore(start)) { + fullDayStart.add(1, 'day'); } - // allDay events use exclusive end semantics in FullCalendar. - _calendar.addEvent({ ...common, start: fullDayStart.toDate(), end: fullDayEnd.toDate(), allDay: true }); - if (fullDayEnd.isBefore(end)) { - _calendar.addEvent({ ...common, start: fullDayEnd.toDate(), end: end.toDate() }); + const fullDayEnd = end.clone().startOf('day'); + if (!fullDayStart.isBefore(fullDayEnd)) { + _calendar.addEvent({ ...common, start: startVal, end: endVal }); + } else { + if (start.isBefore(fullDayStart)) { + _calendar.addEvent({ ...common, start: start.toDate(), end: fullDayStart.toDate() }); + } + // allDay events use exclusive end semantics in FullCalendar. + _calendar.addEvent({ ...common, start: fullDayStart.toDate(), end: fullDayEnd.toDate(), allDay: true }); + if (fullDayEnd.isBefore(end)) { + _calendar.addEvent({ ...common, start: fullDayEnd.toDate(), end: end.toDate() }); + } } } - } - if (this.dataChangeReason !== RunActions.refresh && startTime && startTime < Infinity) { + }); + // Navigate once, after the earliest event start across all entities is known. + if (this.dataChangeReason !== RunActions.refresh && startTime < Infinity) { _calendar.gotoDate(startTime); } }); - _calendar.render(); - } else if (_calendar){ - _calendar.gotoDate(new Date()); - _calendar.getEvents().forEach(event => event.remove()); - _calendar.render(); + } else { + _calendar.batchRendering(() => { + _calendar.gotoDate(new Date()); + _calendar.removeAllEvents(); + }); } } _resizeEventListener () { if (this._calendar) { + // Flushes an event source rebuild that was deferred while the calendar was hidden. + // `iron-resize` is guaranteed on becoming visible: `iron-pages` performs `notifyResize` on every selection change and the notification reaches this component through the resizable chain. + if (this._pendingEventSourceUpdate && this.offsetParent !== null) { + this._pendingEventSourceUpdate = false; + this._rebuildEventSource(this.entities, this.eventKeyProperty, this.eventDescProperty, this.eventFromProperty, this.eventToProperty, this._calendar); + } this._calendar.render(); } } From 57381f4d42f04f5334b45f5b018dbf4dfe4a003c Mon Sep 17 00:00:00 2001 From: 01es Date: Sun, 5 Jul 2026 23:21:06 +1000 Subject: [PATCH 3/5] #2780 EGI rendering optimisations: O(1) entity index lookups, on-demand cell tooltips, batched run data assignment. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three client-side costs scaled poorly with result set size on every centre run: - hasAction and cell tooltip bindings called findEntityIndex per cell — a linear scan over entities, making rendering cost quadratic in the number of rows (~1.8M entity comparisons for 300 rows x 20 columns); - tooltip HTML was computed eagerly for every stamped cell, while TgTooltipBehavior reads the tooltip-text attribute only at hover time; - _setPageData pushed run data in five sequential passes: entities first (triggering an egiModel rebuild that read stale rendering hints and action indices), then per-array observers walked all rows performing a per-row set against instances about to be replaced. Changes: - findEntityIndex now resolves through memoised identity / id maps, invalidated on each entities assignment; inputs that miss both maps fall back to the original linear scan. - Body cell tooltips are computed on demand by _provideCellTooltip (delegated mouseover / touchstart on the scroll container) instead of eager tooltip-text$ bindings in the row templates; stale attributes are dropped on model rebuild; totals cells keep their eager _getTotalTooltip binding. - New EGI primeGridData assigns rendering hints and action indices ahead of the entities assignment with per-row refresh suppressed; _setPageData primes first, so the single egiModel rebuild consumes fresh values in one pass and the redundant per-row refresh passes are gone. Public signatures (hasAction, findEntityIndex, _getTooltip) are unchanged — external callers such as tg-gis-component are unaffected. --- .../web/centre/tg-entity-centre-behavior.js | 31 +++-- .../web/egi/tg-entity-grid-inspector.js | 114 +++++++++++++++++- 2 files changed, 128 insertions(+), 17 deletions(-) diff --git a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/centre/tg-entity-centre-behavior.js b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/centre/tg-entity-centre-behavior.js index 55dd8f9491..dd1b1b8be5 100644 --- a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/centre/tg-entity-centre-behavior.js +++ b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/centre/tg-entity-centre-behavior.js @@ -822,20 +822,25 @@ const TgEntityCentreBehaviorImpl = { } }, + /** + * Pushes the current page of run data into the EGI and synchronises centre-level `retrievedEntities` / `renderingHints`. + * + * The EGI side arrays are primed before `retrievedEntities` is assigned, so the single `egiModel` rebuild (triggered by the EGI `entities` assignment inside the `retrievedEntities` observer) consumes fresh values in one pass. + * Assigning them after `retrievedEntities` (as was done previously) made the rebuild read stale values and then triggered a redundant per-row refresh pass for each array. + * `renderingHints` is assigned the same array reference that was primed, so the EGI-forwarding observer dirty-checks it into a no-op. + */ _setPageData: function (startIdx, endIdx) { - if (typeof startIdx === 'undefined') { - this.retrievedEntities = this.allFilteredEntities; - this.renderingHints = this.allFilteredRenderingHints; - this.$.egi.primaryActionIndices = this.allFilteredPrimaryActionIndices; - this.$.egi.secondaryActionIndices = this.allFilteredSecondaryActionIndices; - this.$.egi.propertyActionIndices = this.allFilteredPropertyActionIndices; - } else { - this.retrievedEntities = this.allFilteredEntities.slice(startIdx, endIdx); - this.renderingHints = this.allFilteredRenderingHints.slice(startIdx, endIdx); - this.$.egi.primaryActionIndices = this.allFilteredPrimaryActionIndices.slice(startIdx, endIdx); - this.$.egi.secondaryActionIndices = this.allFilteredSecondaryActionIndices.slice(startIdx, endIdx); - this.$.egi.propertyActionIndices = this.allFilteredPropertyActionIndices.slice(startIdx, endIdx); - } + const page = arr => typeof startIdx === 'undefined' ? arr : arr.slice(startIdx, endIdx); + const entities = page(this.allFilteredEntities); + const renderingHints = page(this.allFilteredRenderingHints); + this.$.egi.primeGridData({ + renderingHints: renderingHints, + primaryActionIndices: page(this.allFilteredPrimaryActionIndices), + secondaryActionIndices: page(this.allFilteredSecondaryActionIndices), + propertyActionIndices: page(this.allFilteredPropertyActionIndices) + }); + this.retrievedEntities = entities; + this.renderingHints = renderingHints; }, _setPageNumber: function (number) { diff --git a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-entity-grid-inspector.js b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-entity-grid-inspector.js index f5f4eb4aac..8c6a007004 100644 --- a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-entity-grid-inspector.js +++ b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-entity-grid-inspector.js @@ -470,16 +470,17 @@ const template = html`
@@ -591,6 +592,21 @@ function updateSelectAll (egi, egiModel) { } }; +function buildEntityIndexMaps (entities) { + const byRef = new Map(); + const byId = new Map(); + (entities || []).forEach((entity, index) => { + if (!byRef.has(entity)) { + byRef.set(entity, index); + } + const id = entity && typeof entity.get === 'function' ? entity.get('id') : null; + if (id && !byId.has(id)) { + byId.set(id, index); + } + }); + return { byRef: byRef, byId: byId }; +}; + function _insertMaster (container, egiMaster, entityIndex) { const row = container.querySelectorAll(".table-data-row")[entityIndex]; container.insertBefore(egiMaster, row.nextSibling); @@ -866,6 +882,13 @@ Polymer({ this._totalsRowCount = 0; this._showProgress = false; + //Memoised entity → index maps used by findEntityIndex; invalidated on each `entities` assignment and rebuilt lazily. + this._entityIndexMaps = null; + //Batch-assignment flag: suppresses the per-row refresh observers (rendering hints / action indices) while primeGridData assigns them ahead of an entities-triggered egiModel rebuild. + this._suppressPerRowRefresh = false; + //The body cell for which a hover tooltip was last computed by _provideCellTooltip. + this._lastTooltipCell = null; + //Initialising shadows this._showTopShadow = false; this._showBottomShadow = false; @@ -908,6 +931,12 @@ Polymer({ //Initialising event listeners. this.addEventListener("iron-resize", this._resizeEventListener.bind(this)); + //Compute body cell tooltips lazily on hover instead of eagerly for every stamped cell. + //`mouseover` / `touchstart` are dispatched before the events that TgTooltipBehavior reacts to on its (ancestor) trigger element, so the `tooltip-text` attribute is in place by the time it is read. + this._provideCellTooltip = this._provideCellTooltip.bind(this); + this.$.baseContainer.addEventListener("mouseover", this._provideCellTooltip); + this.$.baseContainer.addEventListener("touchstart", this._provideCellTooltip, { passive: true }); + //Observe column DOM changes new FlattenedNodesObserver(this.$.column_selector, (info) => { this._columnDomChanged(info.addedNodes, info.removedNodes); @@ -1021,7 +1050,27 @@ Polymer({ } }, + /** + * Returns the index of `entity` in `entities`, resolved through memoised identity / id maps. + * + * This method sits on the per-cell rendering path (`hasAction` bindings, cell tooltips) — a linear `_findEntity` scan here made rendering cost quadratic in the number of rows. + * The maps are rebuilt lazily after each `entities` assignment; inputs that miss both maps (e.g. equal-by-key instances without an id) fall back to the original linear scan. + */ findEntityIndex: function (entity) { + if (!this._entityIndexMaps) { + this._entityIndexMaps = buildEntityIndexMaps(this.entities); + } + const indexByRef = this._entityIndexMaps.byRef.get(entity); + if (typeof indexByRef !== 'undefined') { + return indexByRef; + } + const id = entity && typeof entity.get === 'function' ? entity.get('id') : null; + if (id) { + const indexById = this._entityIndexMaps.byId.get(id); + if (typeof indexById !== 'undefined') { + return indexById; + } + } return this._findEntity(entity, this.entities); }, @@ -1064,6 +1113,25 @@ Polymer({ } }, + /** + * Primes all run-produced row data (rendering hints and action indices) in one batch, ahead of an imminent `entities` assignment. + * + * The arrays are assigned with per-row refresh suppressed: their observers (`_renderingHintsChanged`, `_primaryActionIndicesChanged`, `_secondaryActionIndicesChanged`) + * would otherwise walk the soon-to-be-replaced `egiModel` performing a per-row `set` each, while the `egiModel` rebuild triggered by the subsequent `entities` assignment consumes these arrays anyway. + * Individual property assignments remain fully supported for callers that update one aspect at a time outside of a run. + */ + primeGridData: function (gridData) { + this._suppressPerRowRefresh = true; + try { + this.renderingHints = gridData.renderingHints; + this.primaryActionIndices = gridData.primaryActionIndices; + this.secondaryActionIndices = gridData.secondaryActionIndices; + this.propertyActionIndices = gridData.propertyActionIndices; + } finally { + this._suppressPerRowRefresh = false; + } + }, + //Filtering related functions filter: function () { const tempFilteredEntities = []; @@ -1394,7 +1462,8 @@ Polymer({ //Entities changed related functions _entitiesChanged: function (newEntities, oldEntities) { - this.filter(); + this._entityIndexMaps = null; // rebuilt lazily by findEntityIndex + this.filter(); }, _filteredEntitiesChanged: function (newValue) { @@ -1429,6 +1498,11 @@ Polymer({ }); updateSelectAll(this, tempEgiModel); this.egiModel = tempEgiModel; + // Tooltips computed for the previous model are dropped and recomputed on the next hover; only previously hovered body cells carry the attribute, so this touches a handful of elements at most. + this._lastTooltipCell = null; + if (this.$ && this.$.baseContainer) { + this.$.baseContainer.querySelectorAll(".table-data-row tg-egi-cell[tooltip-text]").forEach(cell => cell.removeAttribute("tooltip-text")); + } this._updateTableSizeAsync(); this.fire("tg-egi-entities-loaded", newValue); }, @@ -2052,6 +2126,9 @@ Polymer({ }, _renderingHintsChanged: function (newValue) { + if (this._suppressPerRowRefresh) { + return; // primed ahead of an entities-triggered rebuild, which consumes the new value itself + } if (this.egiModel) { const noneFilteredOut = this.egiModel.length === this.entities.length; this.egiModel.forEach((egiEntity, egiEntIndex) => { @@ -2063,6 +2140,9 @@ Polymer({ }, _primaryActionIndicesChanged: function (newValue) { + if (this._suppressPerRowRefresh) { + return; // primed ahead of an entities-triggered rebuild, which consumes the new value itself + } if (this.egiModel) { const noneFilteredOut = this.egiModel.length === this.entities.length; this.egiModel.forEach((egiEntity, index) => { @@ -2072,6 +2152,9 @@ Polymer({ }, _secondaryActionIndicesChanged: function (newValue) { + if (this._suppressPerRowRefresh) { + return; // primed ahead of an entities-triggered rebuild, which consumes the new value itself + } if (this.egiModel) { const noneFilteredOut = this.egiModel.length === this.entities.length; this.egiModel.forEach((egiEntity, index) => { @@ -2199,6 +2282,29 @@ Polymer({ }; }, + /** + * Installs the `tooltip-text` attribute on the body cell under the pointer, computing the tooltip on demand. + * + * Cell tooltips used to be bound eagerly in the row templates, which computed rows × columns tooltip HTML strings on every model rebuild, + * while `TgTooltipBehavior` reads the attribute only at hover time (and `tg-tooltip` shows it after a further delay). + * Only body row cells participate — totals cells keep their eager `_getTotalTooltip` binding. + */ + _provideCellTooltip: function (event) { + const cell = event.target && event.target.closest ? event.target.closest("tg-egi-cell") : null; + if (cell === this._lastTooltipCell) { + return; + } + this._lastTooltipCell = cell; + if (cell && cell.egiEntity && cell.column && cell.closest(".table-data-row")) { + const tooltip = this._getTooltip(cell.egiEntity.entity, cell.column, cell.column.customActions); + if (tooltip) { + cell.setAttribute("tooltip-text", tooltip); + } else { + cell.removeAttribute("tooltip-text"); + } + } + }, + _getTooltip: function (entity, column, actions) { try { let tooltip = this.getValueTooltip(entity, column); From 986ceafdb3ed6e5e7e2b73e396d9398bf37b87a6 Mon Sep 17 00:00:00 2001 From: 01es Date: Tue, 7 Jul 2026 17:06:31 +1000 Subject: [PATCH 4/5] #2780 EGI improvements: extracted HTML comments to JS to avoid repetition during DOM stamping, dismissal of stale cell tooltips, cheaper tooltip cleanup and entity index maps. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Cell tooltips are provisioned on mousemove in addition to mouseover / touchstart, covering a pointer already resting inside a cell whose tooltip was invalidated — mouseover fires only on boundary crossings. - The egiModel rebuild, updateEntity and _updateColumns invalidate the hovered cell’s tooltip and dismiss the displayed / pending tooltip via hideTooltip. Refreshing the tooltip-text attribute alone could not prevent stale content: tg-tooltip.show is a no-op while a tooltip is already displayed (its content is frozen) and a pending show timer captures the text at trigger time. The dismissal is guarded by a :hover check on the invalidated cell, leaving unrelated tooltips alone. Efficiency and maintainability: - Model rebuilds clear tooltip-text attributes via the small set of cells that actually received one, instead of a querySelectorAll scan over all stamped cells. - The byId entity index map is built lazily upon the first byRef miss — the dominant rendering path (same references) never needs it; id extraction is shared between the map builder and findEntityIndex via entityIdOf, mirroring _areEqual’s id-first rule. --- .../fielden/platform/web/egi/tg-egi-cell.js | 9 +-- .../web/egi/tg-entity-grid-inspector.js | 79 +++++++++++++++---- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js index d2d58e8dae..03736f3c8c 100644 --- a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js +++ b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/egi/tg-egi-cell.js @@ -19,6 +19,10 @@ import { simplifyRichText } from '/resources/components/rich-text/tg-rich-text-u export const EGI_CELL_PADDING = "0.6rem"; export const EGI_CELL_PADDING_TEMPLATE = html`0.6rem`; +// The overflow button at the bottom of the template is stamped via dom-if (rather than always stamped and toggled with hidden$) so that a cell whose column has no multi-group property actions never instantiates a paper-icon-button. +// An EGI renders as a non-virtualized dom-repeat of tg-egi-cell (one instance per row per column), so an always-stamped per-cell paper-icon-button would boot tens of thousands of elements on a large centre — a significant, purely wasted rendering cost, since almost all cells keep it hidden. +// _hasOverflow depends only on column configuration (stable per column), so dom-if stamps the button once for the columns that need it and never for those that do not. +// This explanation lives outside the html literal deliberately — a comment inside would be cloned as a comment node into every stamped cell instance. const template = html`