You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@myfaces.apache.org by me...@apache.org on 2026/05/29 19:31:54 UTC

(myfaces) branch main updated: MYFACES-4751 5.0 Destroy @ViewScoped beans of views that are never saved (#995) (#997)

This is an automated email from the ASF dual-hosted git repository.

melloware pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/myfaces.git


The following commit(s) were added to refs/heads/main by this push:
     new b7300d6af MYFACES-4751 5.0 Destroy @ViewScoped beans of views that are never saved (#995) (#997)
b7300d6af is described below

commit b7300d6af0e2ab34f3fbe81113528c062ade8996
Author: Melloware <me...@gmail.com>
AuthorDate: Fri May 29 15:31:48 2026 -0400

    MYFACES-4751 5.0 Destroy @ViewScoped beans of views that are never saved (#995) (#997)
    
    A view rendered without writing its state - e.g. a page with no UIForm, so no
    view state token is emitted - can never be restored, and unlike a saved view it
    is never registered in the session SerializedViewCollection. Consequently the
    normal "evict view -> destroy its view scope" path never ran for such a view,
    and any @ViewScoped beans created while rendering it lingered in the session
    (ViewScopeContextualStorageHolder) until the session expired. Requesting such a
    page repeatedly within one session therefore leaked one view-scope storage per
    request, unbounded.
    
    FaceletViewDeclarationLanguage.renderView now destroys the view scope of a
    non-transient view whose state was not written, at the end of the request,
    publishing PreDestroyViewMapEvent and destroying its (CDI or non-CDI)
    @ViewScoped storage.
    
    Add ViewScopeFormlessLeakTestCase: rendering a formless page bound to a
    @ViewScoped bean N times now destroys the bean N times instead of accumulating
    it in the session.
    
    Co-authored-by: Claude Opus 4.8 (1M context) <no...@anthropic.com>
---
 .../facelets/FaceletViewDeclarationLanguage.java   | 18 ++++++
 .../viewscope/ViewScopeFormlessLeakTestCase.java   | 74 ++++++++++++++++++++++
 .../facelets/viewscope/ViewScopeLeakProbeBean.java | 62 ++++++++++++++++++
 .../facelets/viewscope/formlessViewScoped.xhtml    | 29 +++++++++
 4 files changed, 183 insertions(+)

diff --git a/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java b/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java
index 022a09c5a..107107c91 100644
--- a/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java
+++ b/impl/src/main/java/org/apache/myfaces/view/facelets/FaceletViewDeclarationLanguage.java
@@ -99,6 +99,7 @@
 import org.apache.myfaces.core.api.shared.lang.Assert;
 import org.apache.myfaces.view.ViewDeclarationLanguageStrategy;
 import org.apache.myfaces.view.ViewMetadataBase;
+import org.apache.myfaces.view.ViewScopeProxyMap;
 import org.apache.myfaces.view.facelets.compiler.Compiler;
 import org.apache.myfaces.view.facelets.compiler.SAXCompiler;
 import org.apache.myfaces.view.facelets.el.CompositeComponentELUtils;
@@ -1897,6 +1898,23 @@ else if (stateWriter.isStateWrittenWithoutWrapper())
                                 }
                             }
                         }
+
+                        // This view was rendered without writing its state (no UIForm, hence no
+                        // view state token), so it can never be restored. Any @ViewScoped beans
+                        // created while building it are unreachable once this request ends, but -
+                        // unlike views whose state is saved - it is never registered in the session
+                        // SerializedViewCollection, so the normal "evict view -> destroy its view
+                        // scope" path never runs and the beans would linger until the session
+                        // expires. Destroy the view scope now (publishes PreDestroyViewMapEvent).
+                        if (!view.isTransient())
+                        {
+                            Map<String, Object> viewMap = view.getViewMap(false);
+                            if (viewMap instanceof ViewScopeProxyMap
+                                    && ((ViewScopeProxyMap) viewMap).getViewScopeId() != null)
+                            {
+                                viewMap.clear();
+                            }
+                        }
                     }
                 }
             }
diff --git a/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeFormlessLeakTestCase.java b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeFormlessLeakTestCase.java
new file mode 100644
index 000000000..49dcbf374
--- /dev/null
+++ b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeFormlessLeakTestCase.java
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+package org.apache.myfaces.view.facelets.viewscope;
+
+import jakarta.el.ExpressionFactory;
+import jakarta.faces.application.ProjectStage;
+
+import org.apache.myfaces.test.core.AbstractMyFacesCDIRequestTestCase;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression test for the @ViewScoped leak on formless views.
+ *
+ * <p>A view without any {@code UIForm} never has its state saved, so it can never be restored and
+ * is never registered in the session {@code SerializedViewCollection}. Repeatedly requesting such a
+ * view that references a {@code @ViewScoped} bean must not accumulate view scopes in the session:
+ * the scope has to be destroyed at the end of each request. Before the fix the beans were never
+ * destroyed and piled up in the session (a memory leak), so {@code DESTROYED} stayed at 0.</p>
+ */
+public class ViewScopeFormlessLeakTestCase extends AbstractMyFacesCDIRequestTestCase
+{
+
+    protected ExpressionFactory createExpressionFactory()
+    {
+        return new org.apache.el.ExpressionFactoryImpl();
+    }
+
+    @Override
+    protected void setUpWebConfigParams() throws Exception
+    {
+        super.setUpWebConfigParams();
+        servletContext.addInitParameter("org.apache.myfaces.annotation.SCAN_PACKAGES",
+                "org.apache.myfaces.view.facelets.viewscope");
+        servletContext.addInitParameter(ProjectStage.PROJECT_STAGE_PARAM_NAME, "Production");
+    }
+
+    @Test
+    public void testFormlessViewDoesNotLeakViewScopedBeans() throws Exception
+    {
+        ViewScopeLeakProbeBean.reset();
+
+        int requests = 5;
+        for (int i = 0; i < requests; i++)
+        {
+            startViewRequest("/formlessViewScoped.xhtml");
+            processLifecycleExecute();
+            renderResponse();
+            endRequest();
+        }
+
+        Assertions.assertEquals(requests, ViewScopeLeakProbeBean.CREATED.get(),
+                "the @ViewScoped bean must be created once per request");
+        Assertions.assertEquals(requests, ViewScopeLeakProbeBean.DESTROYED.get(),
+                "the @ViewScoped beans of a formless (never-saved) view must be destroyed each "
+                        + "request, not leaked into the session");
+    }
+}
diff --git a/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeLeakProbeBean.java b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeLeakProbeBean.java
new file mode 100644
index 000000000..0250c1079
--- /dev/null
+++ b/impl/src/test/java/org/apache/myfaces/view/facelets/viewscope/ViewScopeLeakProbeBean.java
@@ -0,0 +1,62 @@
+/*
+ * 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.
+ */
+package org.apache.myfaces.view.facelets.viewscope;
+
+import java.io.Serializable;
+import java.util.concurrent.atomic.AtomicInteger;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.faces.view.ViewScoped;
+import jakarta.inject.Named;
+
+/**
+ * A {@code @ViewScoped} CDI bean that counts its creations and destructions, used to assert that
+ * the view scope of a formless (never-saved) view is destroyed at the end of the request instead
+ * of lingering in the session.
+ */
+@Named("viewScopeLeakProbeBean")
+@ViewScoped
+public class ViewScopeLeakProbeBean implements Serializable
+{
+    public static final AtomicInteger CREATED = new AtomicInteger();
+    public static final AtomicInteger DESTROYED = new AtomicInteger();
+
+    public static void reset()
+    {
+        CREATED.set(0);
+        DESTROYED.set(0);
+    }
+
+    @PostConstruct
+    public void created()
+    {
+        CREATED.incrementAndGet();
+    }
+
+    @PreDestroy
+    public void destroyed()
+    {
+        DESTROYED.incrementAndGet();
+    }
+
+    public String getValue()
+    {
+        return "view-scoped";
+    }
+}
diff --git a/impl/src/test/resources/org/apache/myfaces/view/facelets/viewscope/formlessViewScoped.xhtml b/impl/src/test/resources/org/apache/myfaces/view/facelets/viewscope/formlessViewScoped.xhtml
new file mode 100644
index 000000000..1aa0bf192
--- /dev/null
+++ b/impl/src/test/resources/org/apache/myfaces/view/facelets/viewscope/formlessViewScoped.xhtml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    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.
+-->
+<!DOCTYPE html>
+<html xmlns:h="jakarta.faces.html">
+	<h:head>
+	    <title>Formless page referencing a @ViewScoped bean</title>
+	</h:head>
+	<h:body>
+	    <!-- No h:form: this view's state is never saved, so it can never be restored. -->
+	    <h:outputText value="#{viewScopeLeakProbeBean.value}"/>
+	</h:body>
+</html>