From 41f8544e388a03aebf1d6d243e833eddaae0ed6d Mon Sep 17 00:00:00 2001 From: Oleh Maikovych Date: Thu, 9 Jul 2026 17:16:13 +0300 Subject: [PATCH 1/4] #2790 Guard shadowRoot dereference in EntityEditMaster after-load listener. --- .../web/view/master/api/with_master/impl/EntityEditMaster.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java index f7cf72ee626..18b9150856d 100644 --- a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java +++ b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java @@ -24,7 +24,8 @@ public EntityEditMaster(final Class entityType, final boolean " //Provide custom after load listener\n" + " self._seqEditAfterLoadListener = function (e) {\n" + " this._assignPostSavedHandlersForEmbeddedMaster(e);\n" - + " const saveButton = e.detail.shadowRoot.querySelector(\"tg-action[role='save']\");\n" + + " //'shadowRoot' can be null when this fires before the embedded master is rendered (e.g. heterogenic re-navigation); the save action may also be absent -- guard both.\n" + + " const saveButton = e.detail.shadowRoot && e.detail.shadowRoot.querySelector(\"tg-action[role='save']\");\n" + " if (saveButton) {\n" + " saveButton.closeAfterExecution = false;\n" + " }\n" From 3405d50aa1f4ab359f27c9c32a994f9c55c98015 Mon Sep 17 00:00:00 2001 From: 01es Date: Fri, 10 Jul 2026 13:16:55 +1000 Subject: [PATCH 2/4] #2790 Assign closeAfterExecution in the embedded master's ready(), removing the after-load shadowRoot race. EntityEditMaster set closeAfterExecution = false on the embedded master's SAVE action from its after-load listener, dereferencing e.detail.shadowRoot. tg-element-loader fires after-load synchronously right after appendChild, whereas Polymer attaches shadowRoot in ready(), which runs from connectedCallback. When the loader is detached -- the reload path taken during heterogenic re-navigation, and after tg-custom-action-dialog._removeFromDom offloads a cached master -- the embedded master is appended without being connected, ready() has not run, shadowRoot is null, and querySelector threw an uncaught TypeError. Merely guarding the dereference would only silence the crash. closeAfterExecution would never be assigned, nothing would re-attempt it once the master connected, and the SAVE action would keep its rendered close-after-execution=true, publishing canClose=true and closing the dialog -- precisely what EntityEditMaster exists to prevent. The assignment now happens where it cannot be early. EntityManipulationMaster exposes shouldCloseAfterSave(), whose value reaches the embedded master as the shouldCloseAfterSave property via _calcAttrs; tg-element-loader.insertElement assigns attributes before insert(), hence before connectedCallback and ready(). tg-entity-master-behavior honours the property in ready(), alongside the existing compound-master check, where $._saveAction is guaranteed to exist. EntityEditMaster overrides shouldCloseAfterSave() to false; its _seqEditAfterLoadListener thereby became equivalent to the inherited default and has been removed along with the getAfterLoadListener() override. Nested compound-master sections previously received the same assignment incidentally, because after-load bubbles. They remain covered by the tg-master-menu-item-section check in ready(), which is race-free by construction and was subject to the same null-shadowRoot exposure. tg-entity-master-behavior.js is part of the startup bundle, so resources must be re-vulcanised for the change to reach the client. --- .../with_master/impl/EntityEditMaster.java | 19 +++++++------------ .../impl/EntityManipulationMaster.java | 16 ++++++++++++++++ .../web/master/tg-entity-master-behavior.js | 16 ++++++++++++++-- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java index 18b9150856d..b0149112290 100644 --- a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java +++ b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java @@ -8,7 +8,7 @@ /** * Custom entity master for all edit actions. *

- * Firstly, it ensures that it will not be closed on SAVE / CANCEL buttons of embedded master (see closeAfterExecution property setting). + * Firstly, it ensures that it will not be closed on SAVE / CANCEL buttons of embedded master (see {@link #shouldCloseAfterSave()}). * Secondly, it adds support for navigation between heterogenic entities. Use {@link EntityNavigationPreAction} to enable navigation. * * @author TG Team @@ -21,15 +21,7 @@ public class EntityEditMaster extends EntityManipulationMaster public EntityEditMaster(final Class entityType, final boolean shouldRefreshParentCentreAfterSave) { super(entityType, shouldRefreshParentCentreAfterSave); final String masterTemplate = super.render().render().toString().replace("//@master-is-ready-custom-code", - " //Provide custom after load listener\n" - + " self._seqEditAfterLoadListener = function (e) {\n" - + " this._assignPostSavedHandlersForEmbeddedMaster(e);\n" - + " //'shadowRoot' can be null when this fires before the embedded master is rendered (e.g. heterogenic re-navigation); the save action may also be absent -- guard both.\n" - + " const saveButton = e.detail.shadowRoot && e.detail.shadowRoot.querySelector(\"tg-action[role='save']\");\n" - + " if (saveButton) {\n" - + " saveButton.closeAfterExecution = false;\n" - + " }\n" - + " }.bind(self);\n" + " //Provide custom listeners to support navigation between heterogenic entities\n" + " self._handleBindingEntityChanged = function (e) {\n" + " if (e.detail.value && e.detail.value.entityType) {\n" + " if (this._prevCurrBindingEntity && e.detail.value.entityType !== this._prevCurrBindingEntity.entityType) {\n" @@ -49,9 +41,12 @@ public EntityEditMaster(final Class entityType, final boolean this.renderable = () -> new InnerTextElement(masterTemplate); } + /** + * Closing of the enclosing dialog is governed by this master, and not by the SAVE action of the embedded master. + */ @Override - protected String getAfterLoadListener() { - return "this._seqEditAfterLoadListener"; + protected boolean shouldCloseAfterSave() { + return false; } @Override diff --git a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java index 860e9ac82d1..9d928f3b0a0 100644 --- a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java +++ b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java @@ -9,6 +9,21 @@ protected EntityManipulationMaster(final Class entityType, final boolean shou super(entityType, null, shouldRefreshParentCentreAfterSave); } + /** + * Indicates whether the SAVE action of the embedded master may close the enclosing dialog. + * Masters that govern closing themselves should override this method to return `false`. + * + * The returned value is passed to the embedded master as property `shouldCloseAfterSave` (see `tg-entity-master-behavior`). + * `tg-element-loader` assigns that property before inserting the embedded master into the DOM, that is, before the embedded master gets connected. + * Therefore, the value is guaranteed to be in place by the time the embedded master's `ready` callback runs, which is where it takes effect. + * + * This method is invoked from a superclass constructor by way of `getAttributes`. + * Overriding implementations must return a constant, and must not depend on subclass state. + */ + protected boolean shouldCloseAfterSave() { + return true; + } + @Override protected String getAttributes(final Class> entityType, final String bindingEntityName, final boolean shouldRefreshParentCentreAfterSave) { return "{" + @@ -17,6 +32,7 @@ protected String getAttributes(final Class> entityTy " excludeInsertionPoints: this.excludeInsertionPoints, " + " entityId: " + bindingEntityName + ".entityId, " + " entityType: " + bindingEntityName + ".entityType, " + + " shouldCloseAfterSave: " + shouldCloseAfterSave() + ", " + " shouldRefreshParentCentreAfterSave: " + shouldRefreshParentCentreAfterSave + "};"; } diff --git a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/tg-entity-master-behavior.js b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/tg-entity-master-behavior.js index d1231203556..dd41ddab04e 100644 --- a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/tg-entity-master-behavior.js +++ b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/tg-entity-master-behavior.js @@ -499,6 +499,18 @@ const TgEntityMasterBehaviorImpl = { value: true }, + /** + * Indicates whether the SAVE action of this master may close the enclosing dialog. + * + * A master that embeds this one and governs closing itself (e.g. the master for 'EntityEditAction') assigns 'false'. + * Such an assignment is made by 'tg-element-loader', which applies the loaded element's attributes before inserting + * that element into the DOM -- that is, before this master gets connected and, hence, before 'ready' runs. + */ + shouldCloseAfterSave: { + type: Boolean, + value: true + }, + /** * The map of saved continuation functional entities by their property name identifiers. * @@ -1017,9 +1029,9 @@ const TgEntityMasterBehaviorImpl = { } }); - // Don't close this master on save action if it is a part of compound master. + // Don't close this master on save action if it is a part of compound master, or if an embedding master governs closing itself. const menuSectionParent = getParentAnd(self, element => element.matches('tg-master-menu-item-section')); - if (menuSectionParent) { + if (menuSectionParent || !self.shouldCloseAfterSave) { const saveButton = self.$._saveAction; if (saveButton) { saveButton.closeAfterExecution = false; From 857bca112e139ed9effe81f7d1f1cf11f23f956b Mon Sep 17 00:00:00 2001 From: 01es Date: Fri, 10 Jul 2026 14:07:44 +1000 Subject: [PATCH 3/4] #2790 Hoist _handleBindingEntityChanged from EntityEditMaster into EntityManipulationMaster. The handler announcing an imminent change of the embedded master's type was defined identically by EntityEditMaster and by application masters that had copied it, each registering its own '_curr-binding-entity-changed' listener. It is meaningful for every manipulation master, as entityType is a property of AbstractEntityManipulationAction, and its sole consumer is tg-custom-action-dialog._handleMasterBeforeChange. AbstractMasterWithMaster gains getAdditionalReadyCallbackCode(), a hook appended to the master's ready callback and defaulting to no code, mirroring the adjacent getAdditionalImports(). EntityManipulationMaster overrides it to define _handleBindingEntityChanged and register the listener, leaving EntityEditMaster to contribute only its own _handleBindingEntityRetrievedError. MasterWithMaster is unaffected. Applications still carrying a copy of the handler continue to work without duplicate events. The platform registers its listener from //@ready-callback, which precedes //@master-is-ready-custom-code, so it updates _prevCurrBindingEntity before a stale copy runs, and that copy then observes no type change. EntityNewAction masters, which use EntityManipulationMaster directly, now carry the handler as well. They are bound to a single entity type, so the event never fires for them. --- .../impl/AbstractMasterWithMaster.java | 14 ++++++++++- .../with_master/impl/EntityEditMaster.java | 14 +---------- .../impl/EntityManipulationMaster.java | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/AbstractMasterWithMaster.java b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/AbstractMasterWithMaster.java index c1e26337d6b..10349bdd99e 100644 --- a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/AbstractMasterWithMaster.java +++ b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/AbstractMasterWithMaster.java @@ -64,7 +64,8 @@ public AbstractMasterWithMaster(final Class entityType, final Class getAdditionalImports() { return new ArrayList<>(); }; + /** + * Returns additional JS code to be appended to the `ready` callback of this master. + * Both `this` and `self` refer to the master element at that point. + * + * This method is invoked from the constructor. + * Overriding implementations must return a constant, and must not depend on subclass state. + */ + protected String getAdditionalReadyCallbackCode() { + return ""; + } + /** * Returns the implementation for the after load listener of embedded master. * diff --git a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java index b0149112290..8e94440f73b 100644 --- a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java +++ b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityEditMaster.java @@ -21,22 +21,10 @@ public class EntityEditMaster extends EntityManipulationMaster public EntityEditMaster(final Class entityType, final boolean shouldRefreshParentCentreAfterSave) { super(entityType, shouldRefreshParentCentreAfterSave); final String masterTemplate = super.render().render().toString().replace("//@master-is-ready-custom-code", - " //Provide custom listeners to support navigation between heterogenic entities\n" - + " self._handleBindingEntityChanged = function (e) {\n" - + " if (e.detail.value && e.detail.value.entityType) {\n" - + " if (this._prevCurrBindingEntity && e.detail.value.entityType !== this._prevCurrBindingEntity.entityType) {\n" - + " this.fire('tg-master-type-before-change',{\n" - + " prevType: this._prevCurrBindingEntity.entityType,\n" - + " currType: e.detail.value.entityType\n" - + " });\n" - + " }\n" - + " this._prevCurrBindingEntity = e.detail.value;\n" - + " }\n" - + " }.bind(self);\n" + " //Report a failure to retrieve the entity being navigated to\n" + " self._handleBindingEntityRetrievedError = function (e) {\n" + " this.fire('tg-master-navigation-error');\n" + " }.bind(self);\n" - + " self.addEventListener('_curr-binding-entity-changed', self._handleBindingEntityChanged);\n" + " self.$.loader.addEventListener('binding-entity-retrieved-error', self._handleBindingEntityRetrievedError);\n"); this.renderable = () -> new InnerTextElement(masterTemplate); } diff --git a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java index 9d928f3b0a0..0a3ebc12ece 100644 --- a/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java +++ b/platform-web-ui/src/main/java/ua/com/fielden/platform/web/view/master/api/with_master/impl/EntityManipulationMaster.java @@ -24,6 +24,29 @@ protected boolean shouldCloseAfterSave() { return true; } + /** + * Announces an imminent change of the embedded master's type by firing `tg-master-type-before-change`. + * The enclosing dialog reacts to this event (see `tg-custom-action-dialog._handleMasterBeforeChange`) to update its title and to start the resize animation before the new master is rendered. + * + * This applies to every manipulation master, as `entityType` is a property of {@link AbstractEntityManipulationAction}. + * The event is fired only when the type actually changes, so masters bound to a single entity type never fire it. + */ + @Override + protected String getAdditionalReadyCallbackCode() { + return "this._handleBindingEntityChanged = function (e) {\n" + + " if (e.detail.value && e.detail.value.entityType) {\n" + + " if (this._prevCurrBindingEntity && e.detail.value.entityType !== this._prevCurrBindingEntity.entityType) {\n" + + " this.fire('tg-master-type-before-change', {\n" + + " prevType: this._prevCurrBindingEntity.entityType,\n" + + " currType: e.detail.value.entityType\n" + + " });\n" + + " }\n" + + " this._prevCurrBindingEntity = e.detail.value;\n" + + " }\n" + + "}.bind(this);\n" + + "this.addEventListener('_curr-binding-entity-changed', this._handleBindingEntityChanged);\n"; + } + @Override protected String getAttributes(final Class> entityType, final String bindingEntityName, final boolean shouldRefreshParentCentreAfterSave) { return "{" + From ac51961661d6ea9c9148476f9ec867cbb7c2f1b7 Mon Sep 17 00:00:00 2001 From: 01es Date: Fri, 10 Jul 2026 18:28:51 +1000 Subject: [PATCH 4/4] #2790 Re-assert the enabled state of a user-initiated action before running it. 'tg-action' admits a user-initiated action only while it is enabled, then defers 'run' by 100 ms through '_asyncRun' and '_runOptionAction'. Neither callback re-checked that state, and 'run' does not check it either. A tap or a keyboard shortcut could therefore start an action that had become disabled during the delay. This surfaced while testing sequential creation, where an application master drives 'retrieve' and 'save' on a wrapping master in response to 'save.post.success'. Both functions disable the master for the duration of their request, and retrieval completion re-enables it. Pressing Cmd|Ctrl+S in one of those windows admitted the SAVE action of the embedded master, which then ran after the loop had already begun a request. The wrapping and the embedded masters hold separate 'invoke-saving' debouncers, so the two requests were not merged into one. Two overlapping transactions resulted, and SQL Server chose one of them as a deadlock victim. This defect is independent of the after-load race addressed earlier on this branch, which merely made it reachable by letting the sequential loop run to completion. Both deferred callbacks now re-assert 'action-disabled' immediately before running. That attribute is maintained by '_buttonStateChanged', which resolves '_optionButtonDisabled' or '_disabled' according to the button variant. It also drives ':host([action-disabled]) { pointer-events: none }', which is what 'tg-shortcut-processing-behavior._isEnabled' tests when admitting a shortcut. Admission and execution therefore consult the same source of truth. Programmatic invocations of '_asyncRun', which pass neither an event nor a shortcut, are exempt. Developer-driven code may legitimately run a disabled action, most notably to save an unmodified entity, as '_createSavingPromise' explains. 'tg-egi-master-behavior' and 'tg-test-utils' rely on this, as do several web tests. '_runOptionAction' needs no such exemption, as an option can only be activated by the user. '_asyncRunAfterContinuation' remains unguarded by design, as it calls 'run' directly to resume a save while the action is disabled for the duration of a continuation. A side effect is that two rapid presses now produce a single 'run'. Previously both ran, and only the AJAX requests were merged by the debouncer, which left the saving promise of the first one unresolved. 'tg-action.js' is part of the startup bundle, so resources must be re-vulcanised for the change to reach the client. --- .../platform/web/master/actions/tg-action.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/actions/tg-action.js b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/actions/tg-action.js index f22d27a204a..ef8268b979f 100644 --- a/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/actions/tg-action.js +++ b/platform-web-ui/src/main/web/ua/com/fielden/platform/web/master/actions/tg-action.js @@ -422,6 +422,11 @@ Polymer({ const closeAfterExecution = this._options[itemIdx].closeAfterExecution; const subRole = this._options[itemIdx].subRole; this.async(function () { + // This action could have become disabled during the delay above -- see '_asyncRun' for the rationale. + // No exemption for programmatic invocations is needed here, as an option can only be activated by the user. + if (this.hasAttribute('action-disabled')) { + return; + } this.run(closeAfterExecution, subRole); }, 100); } @@ -600,6 +605,14 @@ Polymer({ // it is critical to execute the actual logic that is intended for an on-tap action in async // with a relatively long delay to make sure that all required changes this.async(function () { + // A user-initiated action gets admitted only while enabled, but it could have become disabled during the delay above. + // For example, a retrieval or a saving, initiated meanwhile, disables the master and, with it, all of its actions. + // Running such an action would issue a request concurrent with the one already in progress. + // Programmatic invocations, which pass neither 'e' nor 'shortcut', are exempt. + // This is because developer-driven code may legitimately run a disabled action, such as saving an unmodified entity (see '_createSavingPromise' in 'tg-entity-master-behavior'). + if ((e || shortcut) && this.hasAttribute('action-disabled')) { + return; + } let subRole = ''; let closeAfterExecution = this.closeAfterExecution; if (shortcut && this._options) {