Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions api/src/client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/src/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion api/src/client/typescript/faces/api/_api_ae_stub.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +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.
Expand Down
115 changes: 115 additions & 0 deletions api/src/client/typescript/faces/test/xhrCore/ResponseTest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<tc:in> triggers ajax, renders only <tc:out>" 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(`<?xml version="1.0" encoding="UTF-8"?>
<partial-response>
<changes>
<update id="outputAjax"><![CDATA[<span id='outputAjax'>${(input as HTMLInputElement).value}</span>]]></update>
</changes>
</partial-response>`);
};

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(`<?xml version="1.0" encoding="UTF-8"?>
<partial-response>
<changes>
<update id="inputAjax"><![CDATA[<input type='text' id='inputAjax' name='inputAjax' value='${value}'>]]></update>
</changes>
</partial-response>`);
};

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();
});

});
27 changes: 19 additions & 8 deletions api/src/client/typescript/mona_dish/DomQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1333,11 +1333,16 @@ export class DomQuery implements IDomQuery, IStreamDataSource<DomQuery>, 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;
Expand All @@ -1362,10 +1367,12 @@ export class DomQuery implements IDomQuery, IStreamDataSource<DomQuery>, 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;
Expand Down Expand Up @@ -1890,7 +1897,11 @@ export class DomQuery implements IDomQuery, IStreamDataSource<DomQuery>, 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
Expand Down
Loading