From 129eddcaa604d802d4f885f67f81416613af0a91 Mon Sep 17 00:00:00 2001 From: werpu Date: Thu, 4 Jun 2026 14:43:52 +0200 Subject: [PATCH 1/2] https://issues.apache.org/jira/browse/MYFACES-4753: fixed --- api/src/client/package-lock.json | 30 ++--- api/src/client/package.json | 2 +- .../typescript/faces/api/_api_ae_stub.d.ts | 16 --- .../faces/test/xhrCore/ResponseTest.spec.ts | 115 ++++++++++++++++++ .../client/typescript/mona_dish/DomQuery.ts | 27 ++-- 5 files changed, 150 insertions(+), 40 deletions(-) diff --git a/api/src/client/package-lock.json b/api/src/client/package-lock.json index d07141938..7e937ce84 100644 --- a/api/src/client/package-lock.json +++ b/api/src/client/package-lock.json @@ -24,7 +24,7 @@ "global-jsdom": "^29.0.0", "html-webpack-plugin": "^5.5.1", "jsdom": "^29.1.0", - "jsf.js_next_gen": "4.1.0-beta.16", + "jsf.js_next_gen": "4.1.0-beta.17", "mocha": "^11.7.5", "npm-check-updates": "^22.0.1", "nyc": "^18.0.0", @@ -5354,13 +5354,13 @@ } }, "node_modules/jsf.js_next_gen": { - "version": "4.1.0-beta.16", - "resolved": "https://registry.npmjs.org/jsf.js_next_gen/-/jsf.js_next_gen-4.1.0-beta.16.tgz", - "integrity": "sha512-YxO3gAvPxEG6gHN0S2Yxd1YG7/ULfR7LnED11lEC8pKkALvm6Ez2Ize/P/BGL6lVEJTCx6XkFBcUHeWev3jjmg==", + "version": "4.1.0-beta.17", + "resolved": "https://registry.npmjs.org/jsf.js_next_gen/-/jsf.js_next_gen-4.1.0-beta.17.tgz", + "integrity": "sha512-26ygKAN+yDKmsn3eSg6HNaGX9NygYtetN1Y92rBq2U/79/SF5XpM/H7g4VvzhJbQ2WLRh553aUH1k/EzAl7meg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "mona-dish": "0.50.0-beta.2" + "mona-dish": "0.50.0-beta.3" } }, "node_modules/json-schema-traverse": { @@ -5831,9 +5831,9 @@ } }, "node_modules/mona-dish": { - "version": "0.50.0-beta.2", - "resolved": "https://registry.npmjs.org/mona-dish/-/mona-dish-0.50.0-beta.2.tgz", - "integrity": "sha512-GTn/ZOrGMlemVcq+Q7FOqREs8j5IfuoESqXmi37tPKnV4GkjpXc7cQNELj9ZLWRF0BSKAQ/NrSRJqG2EPAg5yg==", + "version": "0.50.0-beta.3", + "resolved": "https://registry.npmjs.org/mona-dish/-/mona-dish-0.50.0-beta.3.tgz", + "integrity": "sha512-+A60No4/M6bDA+Cq2UkRdRvhY7NfWp2mKs4gfYzlC/HhfM5fEwzY3MHV25L5DEjiuWZV0ADSHd1XZsiukMWtsA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -12726,12 +12726,12 @@ "dev": true }, "jsf.js_next_gen": { - "version": "4.1.0-beta.16", - "resolved": "https://registry.npmjs.org/jsf.js_next_gen/-/jsf.js_next_gen-4.1.0-beta.16.tgz", - "integrity": "sha512-YxO3gAvPxEG6gHN0S2Yxd1YG7/ULfR7LnED11lEC8pKkALvm6Ez2Ize/P/BGL6lVEJTCx6XkFBcUHeWev3jjmg==", + "version": "4.1.0-beta.17", + "resolved": "https://registry.npmjs.org/jsf.js_next_gen/-/jsf.js_next_gen-4.1.0-beta.17.tgz", + "integrity": "sha512-26ygKAN+yDKmsn3eSg6HNaGX9NygYtetN1Y92rBq2U/79/SF5XpM/H7g4VvzhJbQ2WLRh553aUH1k/EzAl7meg==", "dev": true, "requires": { - "mona-dish": "0.50.0-beta.2" + "mona-dish": "0.50.0-beta.3" } }, "json-schema-traverse": { @@ -13074,9 +13074,9 @@ } }, "mona-dish": { - "version": "0.50.0-beta.2", - "resolved": "https://registry.npmjs.org/mona-dish/-/mona-dish-0.50.0-beta.2.tgz", - "integrity": "sha512-GTn/ZOrGMlemVcq+Q7FOqREs8j5IfuoESqXmi37tPKnV4GkjpXc7cQNELj9ZLWRF0BSKAQ/NrSRJqG2EPAg5yg==", + "version": "0.50.0-beta.3", + "resolved": "https://registry.npmjs.org/mona-dish/-/mona-dish-0.50.0-beta.3.tgz", + "integrity": "sha512-+A60No4/M6bDA+Cq2UkRdRvhY7NfWp2mKs4gfYzlC/HhfM5fEwzY3MHV25L5DEjiuWZV0ADSHd1XZsiukMWtsA==", "dev": true, "requires": { "token": "^0.1.0" diff --git a/api/src/client/package.json b/api/src/client/package.json index 64051515e..af8dedd14 100644 --- a/api/src/client/package.json +++ b/api/src/client/package.json @@ -28,7 +28,7 @@ "global-jsdom": "^29.0.0", "html-webpack-plugin": "^5.5.1", "jsdom": "^29.1.0", - "jsf.js_next_gen": "4.1.0-beta.16", + "jsf.js_next_gen": "4.1.0-beta.17", "mocha": "^11.7.5", "npm-check-updates": "^22.0.1", "nyc": "^18.0.0", diff --git a/api/src/client/typescript/faces/api/_api_ae_stub.d.ts b/api/src/client/typescript/faces/api/_api_ae_stub.d.ts index 4ced4c3ef..def487a81 100644 --- a/api/src/client/typescript/faces/api/_api_ae_stub.d.ts +++ b/api/src/client/typescript/faces/api/_api_ae_stub.d.ts @@ -1,19 +1,3 @@ - -/*! Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to you under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ // Stub file consumed only by tsconfig.ae.json (api-extractor's compiler config). // api-extractor uses mainEntryPointFilePath (target/dts/_api.d.ts) as the real // entry — this file exists solely to satisfy TypeScript's "at least one input" diff --git a/api/src/client/typescript/faces/test/xhrCore/ResponseTest.spec.ts b/api/src/client/typescript/faces/test/xhrCore/ResponseTest.spec.ts index 60a07cbc9..726c6ee6b 100644 --- a/api/src/client/typescript/faces/test/xhrCore/ResponseTest.spec.ts +++ b/api/src/client/typescript/faces/test/xhrCore/ResponseTest.spec.ts @@ -865,4 +865,119 @@ describe('Tests of the various aspects of the response protocol functionality', done(); }); + // ── caret preservation on partial updates (Tobago regression) ───────────── + + it('must keep the caret of a focused input when a partial response re-renders only a different component', function (done) { + // Reproduces the Tobago " triggers ajax, renders only " case. + // Every keystroke fires an ajax request that re-renders ONLY the output component, + // not the focused input. The caret of the input must survive each update so the + // next typed digit lands behind the previous one ("123") and not in front ("321"). + const form = document.getElementById("form1"); + const input = document.createElement("input"); + input.setAttribute("type", "text"); + input.id = "inputAjax"; + input.setAttribute("name", "inputAjax"); + form.appendChild(input); + const output = document.createElement("span"); + output.id = "outputAjax"; + form.appendChild(output); + + (input as HTMLInputElement).focus(); + (input as HTMLInputElement).setSelectionRange(0, 0); + + // simulates the browser inserting a character at the current caret position + const typeChar = (ch: string) => { + const el = input as HTMLInputElement; + const pos = el.selectionStart ?? el.value.length; + el.value = el.value.slice(0, pos) + ch + el.value.slice(pos); + el.setSelectionRange(pos + 1, pos + 1); + }; + + // ajax request that re-renders only the output, followed by the matching partial response + const fireAjaxRenderingOnlyOutput = () => { + faces.ajax.request(input, null, {execute: "inputAjax", render: "outputAjax"}); + this.respond(` + + + ${(input as HTMLInputElement).value}]]> + + `); + }; + + typeChar("1"); + fireAjaxRenderingOnlyOutput(); + // the focused input is untouched by the output update -> caret stays behind the "1" + expect(document.activeElement?.id).to.eq("inputAjax"); + expect((input as HTMLInputElement).selectionStart).to.eq(1); + + typeChar("2"); + fireAjaxRenderingOnlyOutput(); + expect((input as HTMLInputElement).selectionStart).to.eq(2); + + typeChar("3"); + fireAjaxRenderingOnlyOutput(); + + expect((input as HTMLInputElement).value).to.eq("123"); + expect((input as HTMLInputElement).selectionStart).to.eq(3); + expect(document.getElementById("outputAjax").innerHTML).to.eq("123"); + done(); + }); + + it('must keep the caret of a focused input when the partial response re-renders the input itself', function (done) { + // Same as above, but now the focused input IS part of the re-rendered markup, so the + // DOM node gets replaced. The caret must be re-applied to the freshly inserted node + // (this is what the getCaretPosition/selectionStart fix guards) so the next typed digit + // still lands behind the previous one ("123") and not in front ("321"). + const form = document.getElementById("form1"); + const input = document.createElement("input"); + input.setAttribute("type", "text"); + input.id = "inputAjax"; + input.setAttribute("name", "inputAjax"); + form.appendChild(input); + + (input as HTMLInputElement).focus(); + (input as HTMLInputElement).setSelectionRange(0, 0); + + // the input node is recreated on every update, so we always re-read the live element + const current = () => document.getElementById("inputAjax") as HTMLInputElement; + + const typeChar = (ch: string) => { + const el = current(); + const pos = el.selectionStart ?? el.value.length; + el.value = el.value.slice(0, pos) + ch + el.value.slice(pos); + el.setSelectionRange(pos + 1, pos + 1); + }; + + // ajax request that re-renders the input itself, echoing back the typed value + const fireAjaxRerenderingInput = () => { + const value = current().value; + faces.ajax.request(current(), null, {execute: "inputAjax", render: "inputAjax"}); + this.respond(` + + + ]]> + + `); + }; + + typeChar("1"); + fireAjaxRerenderingInput(); + // the input node was replaced, but it must still be focused with the caret behind the "1" + expect(document.activeElement?.id).to.eq("inputAjax"); + expect(current().value).to.eq("1"); + expect(current().selectionStart).to.eq(1); + + typeChar("2"); + fireAjaxRerenderingInput(); + expect(current().value).to.eq("12"); + expect(current().selectionStart).to.eq(2); + + typeChar("3"); + fireAjaxRerenderingInput(); + + expect(current().value).to.eq("123"); + expect(current().selectionStart).to.eq(3); + done(); + }); + }); \ No newline at end of file diff --git a/api/src/client/typescript/mona_dish/DomQuery.ts b/api/src/client/typescript/mona_dish/DomQuery.ts index c2dccae1f..6f7ab04f9 100644 --- a/api/src/client/typescript/mona_dish/DomQuery.ts +++ b/api/src/client/typescript/mona_dish/DomQuery.ts @@ -1333,11 +1333,16 @@ export class DomQuery implements IDomQuery, IStreamDataSource, Iterabl return undefined as any; } - let focusElementId = document?.activeElement?.id; - let caretPosition = (focusElementId) ? DomQuery.getCaretPosition(document.activeElement) : null; + let toReplace = this.getAsElem(0).value; + let activeElement = document?.activeElement; + let focusElementId = activeElement?.id; + // only save/restore the caret if the focused element is actually part of the + // subtree that gets replaced. Otherwise updating an unrelated component would + // reset the caret of a different, still focused input field. + let restoreFocus = !!focusElementId && !!(toReplace as any)?.contains?.(activeElement); + let caretPosition = restoreFocus ? DomQuery.getCaretPosition(activeElement) : null; let nodes = DomQuery.fromMarkup(markup); let res: DomQuery[] = []; - let toReplace = this.getAsElem(0).value; let firstInsert = nodes.get(0); let parentNode = toReplace.parentNode; let replaced = firstInsert.getAsElem(0).value; @@ -1362,10 +1367,12 @@ export class DomQuery implements IDomQuery, IStreamDataSource, Iterabl this.runCss(); } - let focusElement = DomQuery.byId(focusElementId as any); - if (focusElementId && focusElement.isPresent() && - caretPosition != null && "undefined" != typeof caretPosition) { - focusElement.eachElem(item => DomQuery.setCaretPosition(item, caretPosition)); + if (restoreFocus) { + let focusElement = DomQuery.byId(focusElementId as any); + if (focusElement.isPresent() && + caretPosition != null && "undefined" != typeof caretPosition) { + focusElement.eachElem(item => DomQuery.setCaretPosition(item, caretPosition)); + } } return nodes; @@ -1890,7 +1897,11 @@ export class DomQuery implements IDomQuery, IStreamDataSource, Iterabl let caretPos = 0; try { - if ((document as any)?.selection) { + if (typeof ctrl?.selectionStart === "number") { + // modern browsers expose the caret position directly via selectionStart + caretPos = ctrl.selectionStart; + } else if ((document as any)?.selection) { + // legacy IE fallback ctrl.focus(); let selection = (document as any).selection.createRange(); // the selection now is start zero From 91e26079f1cbed13412fd9102b708b2038a802a1 Mon Sep 17 00:00:00 2001 From: werpu Date: Thu, 4 Jun 2026 14:45:26 +0200 Subject: [PATCH 2/2] https://issues.apache.org/jira/browse/MYFACES-4753: asf header readded --- .../client/typescript/faces/api/_api_ae_stub.d.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/src/client/typescript/faces/api/_api_ae_stub.d.ts b/api/src/client/typescript/faces/api/_api_ae_stub.d.ts index def487a81..b8ea76faa 100644 --- a/api/src/client/typescript/faces/api/_api_ae_stub.d.ts +++ b/api/src/client/typescript/faces/api/_api_ae_stub.d.ts @@ -1,3 +1,18 @@ +/*! Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ // Stub file consumed only by tsconfig.ae.json (api-extractor's compiler config). // api-extractor uses mainEntryPointFilePath (target/dts/_api.d.ts) as the real // entry — this file exists solely to satisfy TypeScript's "at least one input"