From 789d0f01b5fb2b1879516921b6f362916f90846e Mon Sep 17 00:00:00 2001 From: Ton Sharp <45160296+66Ton99@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:49:01 +0300 Subject: [PATCH 1/2] feat(js): take a form registration back A page that swaps rendered forms in and out - a single page CRUD, a modal that loads its form - registered a model per render and had no way to remove one. The registry kept every element, including the ones whose markup was already gone, and "getFormInstances" answered with them. "removeModel", "removeForm" and "removeDetachedForms" remove a registration, detach the model from its DOM nodes and take the submit listener off the form. Initializing the same markup again now replaces that listener instead of stacking a second one, which used to run the whole validation twice for one submit. --- README.md | 1 + src/Resources/doc/3_24.md | 44 +++++ .../public/js/SvarohJsFormValidator.js | 152 +++++++++++++++++- .../public/js/SvarohJsFormValidator.test.js | 123 ++++++++++++++ 4 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 src/Resources/doc/3_24.md diff --git a/README.md b/README.md index a8c52c4..850f157 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ If your form rendering is customized, start with 21. [Validation events](src/Resources/doc/3_21.md) 22. [File uploads](src/Resources/doc/3_22.md) 23. [Pluralized messages](src/Resources/doc/3_23.md) +24. [Forms that come and go](src/Resources/doc/3_24.md) ## Development diff --git a/src/Resources/doc/3_24.md b/src/Resources/doc/3_24.md new file mode 100644 index 0000000..11d4862 --- /dev/null +++ b/src/Resources/doc/3_24.md @@ -0,0 +1,44 @@ +### 3.24 Forms that come and go + +A page that fetches rendered forms and swaps them into the document - a single +page CRUD, a modal that loads its form, a wizard step - initializes a model per +render. `addModel()` keeps every render in the registry, so the ones whose +markup the application has removed have to be taken back out, otherwise the +registry keeps growing and `getFormInstances()` keeps answering with elements +of nodes that are gone. + +Three methods take a registration back. Each one detaches the model from the +DOM nodes it was attached to and removes the submit listener this library put +on the form, so a node that is dropped afterwards leaves nothing behind. + +```js +// Every render of one model id +SvarohJsFormValidator.removeModel('user'); + +// One rendered form, when the same model is on the page more than once +SvarohJsFormValidator.removeForm(document.getElementById('user')); + +// Every registration whose form is no longer in the document +SvarohJsFormValidator.removeDetachedForms(); +``` + +`removeModel()` answers with the number of removed registrations, +`removeForm()` with whether it removed one, and `removeDetachedForms()` with +the number it removed. + +A typical swap removes what the old markup registered, replaces the markup, and +initializes the new render: + +```js +SvarohJsFormValidator.removeModel('user'); +panel.innerHTML = html; +// The script the fragment carries calls addModel() for the new render +``` + +`removeDetachedForms()` is the variant for code that does not know which model +ids it just dropped: replace the markup first, then call it. + +Initializing the same markup again without removing it first is safe: the +second initialization replaces the registration of that node instead of adding +one next to it, and the form is still validated once per submit. It does leave +the previous element attached to nothing, so prefer removing it. diff --git a/src/Resources/public/js/SvarohJsFormValidator.js b/src/Resources/public/js/SvarohJsFormValidator.js index 8e4adc0..be7876e 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.js +++ b/src/Resources/public/js/SvarohJsFormValidator.js @@ -677,6 +677,143 @@ var SvarohJsFormValidator = new function () { return this.formInstances[id] ? this.formInstances[id] : []; }; + /** + * Undoes what "attachElement" and "attachDefaultEvent" did to the DOM + * nodes of an element and of its children, so a node an application + * dropped from the document keeps no reference back to the model and no + * listener of this library + * + * @param {SvarohJsFormElement} element + */ + this.detachElement = function (element) { + if (!element) { + return; + } + + for (var name in element.children) { + this.detachElement(element.children[name]); + } + + var domNode = element.domNode; + if (!domNode) { + return; + } + + if (domNode.jsFormValidator === element) { + delete domNode.jsFormValidator; + } + + if (domNode.__svarohJsFormValidatorSubmitListener) { + domNode.removeEventListener('submit', domNode.__svarohJsFormValidatorSubmitListener); + delete domNode.__svarohJsFormValidatorSubmitListener; + } + }; + + /** + * Keeps the given registrations of a model id and drops the id entirely + * when none are left, so "forms" never answers with an element the + * registry no longer holds + * + * @param {String} id + * @param {Array} instances + */ + this.keepFormInstances = function (id, instances) { + if (!instances.length) { + delete this.formInstances[id]; + delete this.forms[id]; + + return; + } + + this.formInstances[id] = instances; + for (var i = 0; i < instances.length; i++) { + if (instances[i] === this.forms[id]) { + return; + } + } + + this.forms[id] = instances[instances.length - 1]; + }; + + /** + * Removes every registration of a model id + * + * @param {String} id + * + * @return {Number} the number of removed registrations + */ + this.removeModel = function (id) { + var instances = this.getFormInstances(id); + for (var i = 0; i < instances.length; i++) { + this.detachElement(instances[i]); + } + + this.keepFormInstances(id, []); + + return instances.length; + }; + + /** + * Removes the registration attached to one form node, which is what an + * application replacing a single rendered form needs + * + * @param {HTMLElement} domNode + * + * @return {Boolean} whether a registration was removed + */ + this.removeForm = function (domNode) { + if (!domNode) { + return false; + } + + var removed = false; + for (var id in this.formInstances) { + var kept = []; + var instances = this.formInstances[id]; + for (var i = 0; i < instances.length; i++) { + if (instances[i].domNode === domNode) { + this.detachElement(instances[i]); + removed = true; + } else { + kept.push(instances[i]); + } + } + + this.keepFormInstances(id, kept); + } + + return removed; + }; + + /** + * Removes every registration whose form is no longer in the document. An + * application that swaps rendered forms in and out without naming them + * calls this after a swap, otherwise the registry grows with elements of + * nodes that are gone + * + * @return {Number} the number of removed registrations + */ + this.removeDetachedForms = function () { + var removed = 0; + for (var id in this.formInstances) { + var kept = []; + var instances = this.formInstances[id]; + for (var i = 0; i < instances.length; i++) { + var domNode = instances[i].domNode; + if (domNode && document.contains(domNode)) { + kept.push(instances[i]); + } else { + this.detachElement(instances[i]); + removed++; + } + } + + this.keepFormInstances(id, kept); + } + + return removed; + }; + this.onDocumentReady = function (callback) { var addListener = document.addEventListener || document.attachEvent; var removeListener = document.removeEventListener || document.detachEvent; @@ -1391,9 +1528,20 @@ var SvarohJsFormValidator = new function () { * @param {HTMLFormElement} form */ this.attachDefaultEvent = function (element, form) { - form.addEventListener('submit', function (event) { + // The same markup can be initialized more than once - an application + // that renders a form fragment again - and a second listener would run + // the whole validation twice for one submit, so the listener is kept + // on the node and replaced instead of stacked + if (form.__svarohJsFormValidatorSubmitListener) { + form.removeEventListener('submit', form.__svarohJsFormValidatorSubmitListener); + } + + var listener = function (event) { SvarohJsFormValidator.customize(form, 'submitForm', event); - }); + }; + + form.__svarohJsFormValidatorSubmitListener = listener; + form.addEventListener('submit', listener); }; /** diff --git a/src/Resources/public/js/SvarohJsFormValidator.test.js b/src/Resources/public/js/SvarohJsFormValidator.test.js index c36ba02..7ffab0a 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.test.js +++ b/src/Resources/public/js/SvarohJsFormValidator.test.js @@ -1278,6 +1278,129 @@ describe('SvarohJsFormValidator model registration', () => { }); }); +// An application that swaps rendered forms in and out of one page - a single +// page CRUD - registers a model per render, and nothing used to take one back +describe('SvarohJsFormValidator model teardown', () => { + afterEach(() => { + document.body.innerHTML = ''; + window.SvarohJsFormValidator.forms = {}; + window.SvarohJsFormValidator.formInstances = {}; + window.SvarohJsFormValidator.constraintsCounter = 0; + jest.restoreAllMocks(); + }); + + function buildModel(id, name, children) { + return { + id: id, + name: name, + type: '', + invalidMessage: '', + bubbling: false, + disabled: false, + transformers: [], + data: {}, + children: children === undefined ? [] : children, + }; + } + + function profileModel() { + return buildModel('profile', 'profile', { + email: buildModel('profile_email', 'profile[email]'), + }); + } + + function renderProfile() { + document.body.innerHTML = '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + } + + test('removeModel forgets the registration and the nodes it was attached to', () => { + renderProfile(); + const form = document.getElementById('profile'); + const input = document.getElementById('profile_email'); + + expect(window.SvarohJsFormValidator.removeModel('profile')).toBe(1); + + expect(window.SvarohJsFormValidator.forms.profile).toBeUndefined(); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toEqual([]); + expect(form.jsFormValidator).toBeUndefined(); + expect(input.jsFormValidator).toBeUndefined(); + }); + + test('removeModel of an unknown id removes nothing', () => { + renderProfile(); + + expect(window.SvarohJsFormValidator.removeModel('ghost')).toBe(0); + expect(window.SvarohJsFormValidator.forms.profile).toBeDefined(); + }); + + test('a removed form no longer runs validation on submit', () => { + renderProfile(); + const form = document.getElementById('profile'); + window.SvarohJsFormValidator.removeModel('profile'); + + const customize = jest.spyOn(window.SvarohJsFormValidator, 'customize'); + form.dispatchEvent(new Event('submit', { cancelable: true })); + + expect(customize).not.toHaveBeenCalled(); + }); + + test('removeForm keeps the other render of the same form', () => { + document.body.innerHTML = + '' + + ''; + window.SvarohJsFormValidator.addModel(profileModel(), false); + window.SvarohJsFormValidator.addModel(profileModel(), false); + + const [first, second] = Array.from(document.querySelectorAll('form')); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(2); + + expect(window.SvarohJsFormValidator.removeForm(first)).toBe(true); + + const instances = window.SvarohJsFormValidator.getFormInstances('profile'); + expect(instances).toHaveLength(1); + expect(instances[0].domNode).toBe(second); + expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(second); + expect(first.jsFormValidator).toBeUndefined(); + expect(second.jsFormValidator).toBeDefined(); + }); + + test('removeForm answers false for a node it holds no registration for', () => { + renderProfile(); + + expect(window.SvarohJsFormValidator.removeForm(document.createElement('form'))).toBe(false); + expect(window.SvarohJsFormValidator.removeForm(null)).toBe(false); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(1); + }); + + test('removeDetachedForms drops the registrations of nodes that left the document', () => { + renderProfile(); + const gone = document.getElementById('profile'); + gone.remove(); + renderProfile(); + const kept = document.getElementById('profile'); + + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(2); + expect(window.SvarohJsFormValidator.removeDetachedForms()).toBe(1); + + const instances = window.SvarohJsFormValidator.getFormInstances('profile'); + expect(instances).toHaveLength(1); + expect(instances[0].domNode).toBe(kept); + expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(kept); + }); + + test('initializing the same markup again does not validate the form twice', () => { + renderProfile(); + const form = document.getElementById('profile'); + window.SvarohJsFormValidator.addModel(profileModel(), false); + + const customize = jest.spyOn(window.SvarohJsFormValidator, 'customize'); + form.dispatchEvent(new Event('submit', { cancelable: true })); + + expect(customize).toHaveBeenCalledTimes(1); + }); +}); + describe('SvarohJsFormValidator property paths', () => { beforeEach(() => { document.body.innerHTML = ''; From e75a67a5f36aeedd65c99b972f8dd374f7c3354f Mon Sep 17 00:00:00 2001 From: 66Ton99 <66ton99@gmail.com> Date: Fri, 28 Aug 2026 00:28:21 +0300 Subject: [PATCH 2/2] fix(js): detach a registration from every node it was attached to An element is attached to two nodes, not one: "createElement" attaches it to the node the model id matched, and "initModel" moves the root element to the form it resolved afterwards. The default rendering makes that the common case - "form_start" writes "