Repository: primefaces/primefaces (java)

Issue:
MOVE_SCRIPTS_TO_BOTTOM creates invalid JSON
### Describe the bug

Enabling the flag _MOVE_SCRIPTS_TO_BOTTOM_ merges _<script>_ tags with json data into a single script tag which produces invalid JSON since only a single root element is allowed.

JSON data provides important structured data for crawlers like search engines. Also important when using _<p:breadcrumb seo="true">_. 

For all our product pages we lost seo friendly breadcrumb display in google SERPs after moving scripts to bottom.

**Production test 1: single json script tag**

https://search.google.com/test/rich-results/result?id=xW-CNQLnYTbomKc1gvFWzA&hl=de

This product parent page only has a single breadcrumb json and google detects it correctly.

**Production test 2: multiple json script tag**

https://search.google.com/test/rich-results/result?id=762CAHA0q60RGp-ln7Frng&hl=de

This product page has JSON data for its product definition aswell for the breadcrumb. Google only detects the first product json data and breadcrumb json definition is lost.



### Reproducer

_No response_

### Expected behavior

Dont merge script tags with type _"application/ld+json"_ into a single tag. 

Just move them to bottom, but keep them separated.

### PrimeFaces edition

Elite

### PrimeFaces version

12.0.2

### Theme

_No response_

### JSF implementation

Mojarra

### JSF version

2.3.18

### Java version

8

### Browser(s)

_No response_

Pull request:
Fix #10845/#10790: 13.0.3 MoveScriptsToBottom backport
Fix #10845/#10790: 13.0.3 MoveScriptsToBottom backport

Patch:
diff --git a/docs/PrimeFaces.d.ts b/docs/PrimeFaces.d.ts
index 45837ad80b..caeb621f97 100644
--- a/docs/PrimeFaces.d.ts
+++ b/docs/PrimeFaces.d.ts
@@ -26505,6 +26505,10 @@ declare namespace PrimeFaces.widget {
          * The number of rows per page.
          */
         rows: number;
+        /**
+         * The configured number of rows set per page.
+         */
+        rpp: string;
     }
 }
 declare namespace PrimeFaces.widget.Paginator {
diff --git a/primefaces/src/main/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriter.java b/primefaces/src/main/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriter.java
index 3dd0708947..61f2803a8e 100644
--- a/primefaces/src/main/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriter.java
+++ b/primefaces/src/main/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriter.java
@@ -31,6 +31,7 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.UUID;
 
+import javax.faces.FacesException;
 import javax.faces.component.UIComponent;
 import javax.faces.context.FacesContext;
 import javax.faces.context.ResponseWriter;
@@ -56,7 +57,10 @@ public class MoveScriptsToBottomResponseWriter extends ResponseWriterWrapper {
     private StringBuilder inline;
     private boolean scriptsRendered;
 
+    private boolean foundHtmlElement;
+    private boolean foundBodyElement;
     private boolean writeFouc;
+    private int counter = 0; // used to track non-JS files
 
     @SuppressWarnings("deprecation") // the default constructor is deprecated in JSF 2.3
     public MoveScriptsToBottomResponseWriter(ResponseWriter wrapped, MoveScriptsToBottomState state) {
@@ -66,6 +70,8 @@ public class MoveScriptsToBottomResponseWriter extends ResponseWriterWrapper {
         inScript = false;
         scriptsRendered = false;
         writeFouc = false;
+        foundHtmlElement = false;
+        foundBodyElement = false;
 
         includeAttributes = new LinkedHashMap<>(6);
         inline = new StringBuilder(75);
@@ -177,9 +183,24 @@ public class MoveScriptsToBottomResponseWriter extends ResponseWriterWrapper {
 
             getWrapped().startElement(name, component);
 
-            if (BODY_TAG.equalsIgnoreCase(name) && isFirefox()) {
-                writeFouc = true;
+            if (BODY_TAG.equalsIgnoreCase(name)) {
+                if (foundBodyElement) {
+                    throw new FacesException("Duplicate <body> elements were found in the response.");
+                }
+                foundBodyElement = true;
+
+                if (isFirefox()) {
+                    writeFouc = true;
+                }
+            }
+
+            if (HTML_TAG.equalsIgnoreCase(name)) {
+                if (foundHtmlElement) {
+                    throw new FacesException("Duplicate <html> elements were found in the response.");
+                }
+                foundHtmlElement = true;
             }
+
         }
     }
 
@@ -220,7 +241,8 @@ public class MoveScriptsToBottomResponseWriter extends ResponseWriterWrapper {
 
             // write inline scripts
             for (Map.Entry<String, List<String>> entry : state.getInlines().entrySet()) {
-                String type = entry.getKey();
+                // strip tracking _0, _1, _2 etc off the end of the string
+                String type = entry.getKey().replaceAll("_\\d+$", "");
                 List<String> inlines = entry.getValue();
 
                 String id = UUID.randomUUID().toString();
@@ -260,7 +282,11 @@ public class MoveScriptsToBottomResponseWriter extends ResponseWriterWrapper {
                 script.append("\n");
             }
             script.append(inlines.get(i));
-            script.append(";");
+
+            // append ; only if this is JS code
+            if (RendererUtils.SCRIPT_TYPE.equalsIgnoreCase(type)) {
+                script.append(";");
+            }
         }
 
         String minimized = script.toString();
@@ -296,14 +322,13 @@ public class MoveScriptsToBottomResponseWriter extends ResponseWriterWrapper {
     protected void updateAttributes(String name, String value) {
         includeAttributes.put(name, value);
 
-        if (TYPE_ATTRIBUTE.equalsIgnoreCase(name)) {
-            if (LangUtils.isNotBlank(value)) {
-                scriptType = value;
-            }
+        // #10845 look for type attribute NOT equal to text/javascript
+        if (TYPE_ATTRIBUTE.equalsIgnoreCase(name) && !RendererUtils.SCRIPT_TYPE.equalsIgnoreCase(value)) {
+            scriptType = value + "_" + counter++;
         }
     }
 
     protected boolean isFirefox() {
         return AgentUtils.isFirefox(FacesContext.getCurrentInstance());
     }
-}
+}
\ No newline at end of file
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/paginator/paginator.js b/primefaces/src/main/resources/META-INF/resources/primefaces/paginator/paginator.js
index d4d85dfc25..22beb12a24 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/paginator/paginator.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/paginator/paginator.js
@@ -54,6 +54,7 @@
  * @prop {number} cfg.prevRows The number of rows per page for the dropdown.
  * @prop {number} cfg.rowCount Total number of rows (records) to be displayed.
  * @prop {number} cfg.rows The number of rows per page.
+ * @prop {number} cfg.rpp The configured number of rows set per page.
  */
 PrimeFaces.widget.Paginator = PrimeFaces.widget.BaseWidget.extend({
 
@@ -79,6 +80,7 @@ PrimeFaces.widget.Paginator = PrimeFaces.widget.BaseWidget.extend({
 
         //metadata
         this.cfg.rows = this.cfg.rows == 0 ? this.cfg.rowCount : this.cfg.rows;
+        this.cfg.rpp = this.cfg.rows;
         this.cfg.prevRows = this.cfg.rows;
         this.cfg.pageCount = Math.ceil(this.cfg.rowCount / this.cfg.rows)||1;
         this.cfg.pageLinks = this.cfg.pageLinks||10;
@@ -412,7 +414,7 @@ PrimeFaces.widget.Paginator = PrimeFaces.widget.BaseWidget.extend({
     /**
      * Switches this pagination to the given page.
      * @param {number} p 0-based index of the page to switch to.
-     * @param {boolean} [silent=false] `true` to not invoke any event listeners, `false` otherwise. 
+     * @param {boolean} [silent=false] `true` to not invoke any event listeners, `false` otherwise.
      */
     setPage: function(p, silent) {
         if(p >= 0 && p < this.cfg.pageCount && this.cfg.page != p){
@@ -438,6 +440,7 @@ PrimeFaces.widget.Paginator = PrimeFaces.widget.BaseWidget.extend({
      */
     setRowsPerPage: function(rpp) {
         this.rppSelect.find('option').removeAttr('selected');
+        this.cfg.rpp = rpp;
         if (rpp === '*') {
             this.cfg.rows = this.cfg.rowCount;
             this.cfg.pageCount = 1;
@@ -470,6 +473,9 @@ PrimeFaces.widget.Paginator = PrimeFaces.widget.BaseWidget.extend({
      * @param {number} value The total number of items to set.
      */
     setTotalRecords: function(value) {
+        if (this.cfg.rpp === '*') {
+            this.cfg.rows = value;
+        }
         this.cfg.rowCount = value;
         this.cfg.pageCount = Math.ceil(value / this.cfg.rows)||1;
         this.cfg.page = 0;
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js b/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js
index 52b2d8bb25..becb90b7c5 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/tooltip/tooltip.js
@@ -68,7 +68,7 @@ PrimeFaces.widget.Tooltip = PrimeFaces.widget.BaseWidget.extend({
         this.cfg.hideEvent = this.cfg.hideEvent ? this.cfg.hideEvent + '.tooltip' : 'mouseleave.tooltip';
         this.cfg.showEffect = this.cfg.showEffect ? this.cfg.showEffect : 'fade';
         this.cfg.hideEffect = this.cfg.hideEffect ? this.cfg.hideEffect : 'fade';
-        this.cfg.showDelay = PrimeFaces.utils.defaultNumeric(this.cfg.showDelay, 150);
+        this.cfg.showDelay = PrimeFaces.utils.defaultNumeric(this.cfg.showDelay, 400);
         this.cfg.hideDelay = PrimeFaces.utils.defaultNumeric(this.cfg.hideDelay, 0);
         this.cfg.hideEffectDuration = this.cfg.target ? 250 : 1;
         this.cfg.position = this.cfg.position || 'right';
@@ -132,6 +132,7 @@ PrimeFaces.widget.Tooltip = PrimeFaces.widget.BaseWidget.extend({
 
         $(document).off(this.cfg.showEvent + ' ' + this.cfg.hideEvent, this.cfg.globalSelector)
             .on(this.cfg.showEvent, this.cfg.globalSelector, function(e) {
+                $this.hide();
                 var element = $(this);
                 if (element.prop('disabled')) {
                     return;
@@ -163,7 +164,7 @@ PrimeFaces.widget.Tooltip = PrimeFaces.widget.BaseWidget.extend({
                     else
                         $this.jq.children('.ui-tooltip-text').html(text);
 
-                    setTimeout(function() {
+                    $this.timeout = setTimeout(function() {
                         $this.globalTitle = text;
                         $this.target = element;
                         $this._show();


Test patch:
diff --git a/primefaces/src/test/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriterTest.java b/primefaces/src/test/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriterTest.java
index 81a9c76fa7..1d536fd217 100644
--- a/primefaces/src/test/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriterTest.java
+++ b/primefaces/src/test/java/org/primefaces/application/resource/MoveScriptsToBottomResponseWriterTest.java
@@ -24,6 +24,7 @@
 package org.primefaces.application.resource;
 
 
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.contains;
 import static org.mockito.ArgumentMatchers.matches;
@@ -31,6 +32,7 @@ import static org.mockito.Mockito.*;
 
 import java.io.IOException;
 
+import javax.faces.FacesException;
 import javax.faces.context.ResponseWriter;
 
 import org.junit.jupiter.api.Assertions;
@@ -158,6 +160,39 @@ public class MoveScriptsToBottomResponseWriterTest {
         verify(wrappedWriter, times(2)).endElement("script");
     }
 
+    /**
+     * https://github.com/primefaces/primefaces/issues/10845
+     */
+    @Test
+    public void testMultipleInlineScriptsNonJavascript() throws IOException {
+        writer.startElement("body", null);
+        verify(wrappedWriter).startElement("body", null);
+
+        writer.startElement("script", null);
+        writer.writeAttribute("type", "application/ld+json", null);
+        writer.writeText("JSONLinkingData1", null);
+        writer.endElement("script");
+
+        writer.startElement("script", null);
+        // default type is text/javascript
+        writer.writeText("javascript2", null);
+        writer.endElement("script");
+
+        writer.startElement("script", null);
+        writer.writeAttribute("type", "application/ld+json", null);
+        writer.writeText("JSONLinkingData2", null);
+        writer.endElement("script");
+
+        writer.endElement("body");
+
+        // assert both LD files are still in their own inline script
+        verify(wrappedWriter, times(3)).startElement("script", null);
+        verify(wrappedWriter).write(matches("(?s).*JSONLinkingData1(?!.*javascript2.*).*"));
+        verify(wrappedWriter).write(matches("(?s).*javascript2(?!.*JSONLinkingData1.*).*"));
+        verify(wrappedWriter).write(matches("(?s).*JSONLinkingData2(?!.*javascript2.*).*"));
+        verify(wrappedWriter, times(3)).endElement("script");
+    }
+
     @Test
     public void testPassthroughAttributes() throws IOException {
         writer.startElement("body", null);
@@ -249,4 +284,30 @@ public class MoveScriptsToBottomResponseWriterTest {
         verify(wrappedWriter).write(contains("inline"));
     }
 
+    @Test
+    public void testDuplicateHtmlElements() throws IOException {
+        writer.startElement("HTML", null);
+        verify(wrappedWriter).startElement("HTML", null);
+
+
+        FacesException exception = assertThrows(FacesException.class, () -> {
+            writer.startElement("HtmL", null);
+        });
+        Assertions.assertEquals("Duplicate <html> elements were found in the response.", exception.getMessage());
+    }
+
+    @Test
+    public void testDuplicateBodyElements() throws IOException {
+        writer.startElement("HTML", null);
+        verify(wrappedWriter).startElement("HTML", null);
+        writer.startElement("BODY", null);
+        verify(wrappedWriter).startElement("BODY", null);
+
+
+        FacesException exception = assertThrows(FacesException.class, () -> {
+            writer.startElement("BoDy", null);
+        });
+        Assertions.assertEquals("Duplicate <body> elements were found in the response.", exception.getMessage());
+    }
+
 }
\ No newline at end of file