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

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


The following commit(s) were added to refs/heads/main by this push:
     new 12d194b66 [#2931] - Rebuild expired Faces views during form replay 
without a preliminary GET (#2930)
12d194b66 is described below

commit 12d194b66eb692c368f69519f5bcc06893540136
Author: Lenny Primak <[email protected]>
AuthorDate: Tue Oct 27 16:27:11 2026 -0400

    [#2931] - Rebuild expired Faces views during form replay without a 
preliminary GET (#2930)
    
    * enh: simplify code and process by implementing FormResubmitViewHandler 
instead of scraping the view state via extra GET request during rebuilding of 
expired views during form data saving replay
    
    * code review
    
    * refactor: form replay handling by removing unused AjaxReplay logic and 
improving view rebuilding during expired session handling
    addresses all valid review comments
---
 support/jakarta-ee/README.adoc                     |  71 +++++----
 support/jakarta-ee/pom.xml                         |   5 -
 .../shiro/ee/filters/FormResubmitRequest.java      |  15 +-
 .../shiro/ee/filters/FormResubmitResponse.java     |   2 +-
 .../shiro/ee/filters/FormResubmitSupport.java      | 177 ++++-----------------
 .../shiro/ee/filters/FormResubmitViewHandler.java  |  57 +++++++
 .../org/apache/shiro/ee/filters/ShiroFilter.java   |  46 +-----
 .../src/main/resources/META-INF/faces-config.xml   |   3 +
 .../shiro/ee/filters/FormResubmitDispatchTest.java | 100 ++++++++++++
 .../shiro/ee/filters/FormResubmitRequestTest.java  |  14 +-
 .../ee/filters/FormResubmitViewHandlerTest.java    | 177 +++++++++++++++++++++
 .../apache/shiro/ee/filters/FormSupportTest.java   |  80 ++--------
 ...dateTest.java => ViewRebuildCandidateTest.java} |  50 +++---
 13 files changed, 460 insertions(+), 337 deletions(-)

diff --git a/support/jakarta-ee/README.adoc b/support/jakarta-ee/README.adoc
index 936fbbee5..056b1ded6 100644
--- a/support/jakarta-ee/README.adoc
+++ b/support/jakarta-ee/README.adoc
@@ -33,50 +33,56 @@ opposed to changing the way Shiro works by integrating CDI
 
 == Form resubmission
 
-Saved forms are replayed within the current web application using
-`RequestDispatcher.forward`, without an outbound HTTP connection. The replay
-uses the current Shiro subject, session, and browser response. Saved form
+After a login flow, saved forms are replayed within the current web application
+using `RequestDispatcher.forward`, without an outbound HTTP connection. The
+replay uses the current Shiro subject, session, and browser response. Saved 
form
 parameters replace those of the login request. Replay targets must read form
 data through the parameter accessors; the raw request body is not 
reconstructed.
 
-For server-side Faces state saving, a buffered GET obtains a new view state
-before the POST. A calling Faces context is restored after each dispatch.
+For server-side Faces state saving, Shiro's `FormResubmitViewHandler` rebuilds
+an expired view during a marked POST using OmniFaces' view builder, like
+`o:enableRestorableView`, without requiring the tag on each page. Expired
+same-origin POSTs that do not require a login continue through the normal 
filter
+chain; Shiro does not consume their body or dispatch them a second time. After
+login, the same view handler rebuilds the view during the forwarded replay.
+Existing views are restored normally, and unmarked requests and client-side
+state saving are unaffected.
 
 Each replay's status, headers, cookies and body are captured rather than 
written
 to the browser response, which is also shielded from `reset()`, `resetBuffer()`
 and `flushBuffer()`. Only a successful replay is applied, with its headers and
 cookies. Response metadata setters (such as content type and encoding) and
 generic header read-back retain the wrapped response's behavior.
-Captured state from view-state GETs and failed attempts is discarded instead
+Captured state from failed attempts is discarded instead
 of applied to the login response, preserving its session cookies.
 Saved form data is decoded with the request's character encoding, falling back
 to the servlet context's and then UTF-8.
 
-A Faces Ajax submission replayed in place (see below) stays an Ajax request:
-the saved partial request is replayed with the refreshed view state, and its
-partial response is passed through to the waiting Ajax client, without a full
-page refresh. Since the server-side view is rebuilt from scratch, the replay
-renders the whole view (`@all`) by default, which resynchronizes the page and
-the view state of all its forms with the server. Set the
+A Faces Ajax request with an expired server-side view stays an Ajax request and
+continues through the normal lifecycle without a full page refresh. The rebuilt
+view renders the whole view (`@all`) by default, which resynchronizes the page
+and the view state of all its forms with the server. Set the
 `org.apache.shiro.form-resubmit.ajax-render-all.disabled` context parameter to
 `true` to keep the form's own render targets instead; components outside them
-then keep their pre-expiry client state, and with Mojarra, other forms on the
-page keep their expired view state. A partial response that reports an
-unhandled Faces error is treated as a failed replay and falls back to a 
redirect
-to the saved request, as a failed full-page replay does. After a login flow,
-the browser is on the login page instead, so the submission is replayed as a
-full-page action and the browser is redirected to the saved request.
+then keep their pre-expiry client state. After a login flow, the browser is on
+the login page instead, so the submission is replayed as a full-page action and
+the browser is redirected to the saved request. Stateful saved forms lose their
+Ajax fields so the complete form is processed; stateless views and client-side
+state saving retain the fields to preserve partial execution. The forwarded
+request always hides the `Faces-Request` header.
 
 === Replay without a login flow
 
 When a POST arrives with a session id that no longer resolves to a session,
-and the subject is either remembered or anonymous, the form is replayed in
-place as soon as Shiro's security chain permits the request, with no
-"session expired" login page in between. For a page that requires login,
-the authentication filter still runs first and saves the form for replay
-after login instead. Only same-origin, `application/x-www-form-urlencoded`
-submissions are replayed, and never with client-side Faces state saving,
-where no view state is lost with the session.
+and the subject is either remembered or anonymous, the request continues in
+place as soon as Shiro's security chain permits it, with no "session expired"
+login page in between. Shiro marks the request so an expired server-side Faces
+view can be rebuilt, without consuming the request body or dispatching it
+again. For a page that requires login, the authentication filter still runs
+first and saves the form for replay after login instead. Only same-origin,
+`application/x-www-form-urlencoded` submissions are eligible, and client-side
+Faces state saving is unaffected because its view state is not lost with the
+session.
 
 Set the `org.apache.shiro.form-resubmit.anonymous.disabled` context parameter
 to `true` to limit this to remembered subjects, or
@@ -114,15 +120,16 @@ to the saved page.
 
 === Application filter configuration
 
-Shiro's Jakarta EE filter is mapped to `DispatcherType.FORWARD`, so the 
forwarded
-target's security chain runs again. Application filters needed during replay
-must also be mapped to `FORWARD`, not only `REQUEST`. Leave Shiro's
+Shiro's Jakarta EE filter is mapped to `DispatcherType.FORWARD`, so an
+after-login forwarded target's security chain runs again. Application filters
+needed during this replay must also be mapped to `FORWARD`, not only `REQUEST`.
+Leave Shiro's
 `filterOncePerRequest` disabled when using form resubmission.
 
-Replay remains in the same servlet request lifecycle. Application filters and
-request-scoped components should not assume that a replay starts a new external
-request. Saved targets must be within the current context; servlet-private
-`WEB-INF` and `META-INF` resources cannot be replay targets.
+After-login replay remains in the same servlet request lifecycle. Application
+filters and request-scoped components should not assume that a replay starts a
+new external request. Saved targets must be within the current context;
+servlet-private `WEB-INF` and `META-INF` resources cannot be replay targets.
 Response `reset()` does not support switching between a writer and an output 
stream.
 
 Resubmission is best-effort. Replays are buffered, so if the forward fails or
diff --git a/support/jakarta-ee/pom.xml b/support/jakarta-ee/pom.xml
index 51530ef0d..8eca548e3 100644
--- a/support/jakarta-ee/pom.xml
+++ b/support/jakarta-ee/pom.xml
@@ -75,11 +75,6 @@
             <artifactId>omnifaces</artifactId>
             <version>5.5.6</version>
         </dependency>
-        <dependency>
-            <groupId>org.jsoup</groupId>
-            <artifactId>jsoup</artifactId>
-            <version>1.23.2</version>
-        </dependency>
 
         <!-- Test Dependencies -->
         <dependency>
diff --git 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
index 16fca2903..5120ff78a 100644
--- 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
+++ 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
@@ -24,7 +24,6 @@ import java.util.List;
 import java.util.Map;
 import lombok.Getter;
 import lombok.experimental.Delegate;
-import org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay;
 import org.apache.shiro.web.util.WebUtils;
 import org.omnifaces.filter.MutableRequestFilter.MutableRequest;
 
@@ -37,10 +36,9 @@ import 
org.omnifaces.filter.MutableRequestFilter.MutableRequest;
 final class FormResubmitRequest extends HttpServletRequestWrapper {
     private static final List<String> DISPATCH_SCOPED_PREFIXES = 
List.of("jakarta.faces.", "com.sun.faces.",
             "org.apache.myfaces.", "org.omnifaces.", 
"jakarta.servlet.forward.", "jakarta.servlet.include.",
-            FormResubmitSupport.FORM_IS_RESUBMITTED);
+            FormResubmitSupport.REBUILD_EXPIRED_VIEW);
     private static final String FACES_REQUEST_HEADER = "Faces-Request";
-    private final @Getter(onMethod_ = @Override) String method;
-    private final AjaxReplay ajaxReplay;
+    private final @Getter(onMethod_ = @Override) String method = 
FormResubmitSupport.HttpMethod.POST;
     private final @Delegate(types = Parameters.class) MutableRequest 
parameters;
     private final Map<String, Object> attributes = new HashMap<>();
 
@@ -52,11 +50,8 @@ final class FormResubmitRequest extends 
HttpServletRequestWrapper {
         Map<String, String[]> getParameterMap();
     }
 
-    FormResubmitRequest(HttpServletRequest request, String method, Map<String, 
List<String>> formFields,
-            AjaxReplay ajaxReplay) {
+    FormResubmitRequest(HttpServletRequest request, Map<String, List<String>> 
formFields) {
         super(unwrap(request));
-        this.method = method;
-        this.ajaxReplay = ajaxReplay;
         parameters = new MutableRequest(request) {
             @Override
             public Map<String, List<String>> getMutableParameterMap() {
@@ -82,8 +77,8 @@ final class FormResubmitRequest extends 
HttpServletRequestWrapper {
      * A full-page replay's response is translated for the original Ajax 
client by the caller,
      * so the target must not see the Ajax header through any accessor
      */
-    private boolean isHidden(String header) {
-        return !ajaxReplay.isPassThrough() && 
FACES_REQUEST_HEADER.equalsIgnoreCase(header);
+    private static boolean isHidden(String header) {
+        return FACES_REQUEST_HEADER.equalsIgnoreCase(header);
     }
 
     @Override
diff --git 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
index c73ad3615..37d0edc7d 100644
--- 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
+++ 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
@@ -36,7 +36,7 @@ import org.omnifaces.servlet.BufferedHttpServletResponse;
 /**
  * Captures a replay's status, headers, cookies and body instead of committing 
them to the browser.
  * Nothing reaches the browser response until {@link #applyTo} is called for a 
successful replay,
- * so view-state GETs, failed attempts and Faces error handling (which resets 
the response)
+ * so failed attempts and Faces error handling (which resets the response)
  * cannot disturb what the login request has already written, such as session 
cookies.
  */
 final class FormResubmitResponse extends BufferedHttpServletResponse {
diff --git 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
index 7e343f4c3..78d23725c 100644
--- 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
+++ 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
@@ -20,8 +20,6 @@ import static 
org.apache.shiro.SecurityUtils.isSecurityManagerTypeOf;
 import static org.apache.shiro.SecurityUtils.unwrapSecurityManager;
 import static 
org.apache.shiro.ee.filters.FormAuthenticationFilter.LOGIN_URL_ATTR_NAME;
 import static 
jakarta.faces.component.behavior.ClientBehaviorContext.BEHAVIOR_SOURCE_PARAM_NAME;
-import static 
jakarta.faces.context.PartialViewContext.ALL_PARTIAL_PHASE_CLIENT_IDS;
-import static 
jakarta.faces.context.PartialViewContext.PARTIAL_RENDER_PARAM_NAME;
 import static jakarta.servlet.http.HttpServletResponse.SC_FOUND;
 import static jakarta.servlet.http.HttpServletResponse.SC_OK;
 import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.addCookie;
@@ -31,7 +29,6 @@ import static 
org.apache.shiro.ee.filters.FormResubmitSupportCookies.getCookieAg
 import org.apache.shiro.crypto.CryptoException;
 import org.apache.shiro.ee.filters.Forms.FallbackPredicate;
 import static 
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isAnonymousFormResubmitDisabled;
-import static 
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitAjaxRenderAllDisabled;
 import static 
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitDisabled;
 import java.io.IOException;
 import java.net.URI;
@@ -53,7 +50,6 @@ import static 
org.apache.shiro.web.filter.authz.SslFilter.DEFAULT_HTTPS_PORT;
 import static org.apache.shiro.web.filter.authz.SslFilter.HTTPS_SCHEME;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
-import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import jakarta.faces.context.FacesContext;
 import jakarta.servlet.ServletContext;
@@ -75,9 +71,6 @@ import org.apache.shiro.mgt.SessionsSecurityManager;
 import org.apache.shiro.subject.Subject;
 import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
 import org.apache.shiro.web.util.WebUtils;
-import org.jsoup.Jsoup;
-import org.jsoup.parser.Parser;
-import org.jsoup.select.Elements;
 import org.omnifaces.util.Faces;
 import org.omnifaces.util.ResourcePaths;
 import org.omnifaces.util.Servlets;
@@ -92,11 +85,10 @@ import org.omnifaces.util.Utils;
 public class FormResubmitSupport {
     static final String SHIRO_FORM_DATA_KEY = "org.apache.shiro.form-data-key";
     static final String SESSION_EXPIRED_PARAMETER = 
"org.apache.shiro.sessionExpired";
-    static final String FORM_IS_RESUBMITTED = 
"org.apache.shiro.form-is-resubmitted";
+    static final String REBUILD_EXPIRED_VIEW = 
"org.apache.shiro.rebuild-expired-view";
     static final String FORM_DATA_CACHE = "org.apache.shiro.form-data-cache";
     // encoded view state
     private static final String FACES_VIEW_STATE = "jakarta.faces.ViewState";
-    private static final Pattern STATEFUL_VIEW_STATE_PATTERN = 
Pattern.compile("-?\\d+:-?\\d+");
     private static final String FACES_PARTIAL_PREFIX = 
"jakarta.faces.partial.";
     private static final String FACES_BEHAVIOR_PREFIX = 
"jakarta.faces.behavior.";
     private static final String SEC_FETCH_SITE = "Sec-Fetch-Site";
@@ -112,62 +104,13 @@ public class FormResubmitSupport {
         static final String LOCATION = "Location";
     }
 
-    /**
-     * Where a saved form's replay is triggered from
-     */
-    enum ReplayFlow {
-        /** the browser is still on the saved form's page, whose submission 
found the session expired */
-        IN_PLACE,
-        /** the browser is on the login page, having just authenticated */
-        AFTER_LOGIN
-    }
-
-    /**
-     * How a saved Faces Ajax submission is replayed
-     */
-    enum AjaxReplay {
-        /** as a full-page submission of the same command, for a browser that 
isn't awaiting the partial response */
-        FULL_PAGE,
-        /** as-is, with its partial response passed through to the waiting 
Ajax client */
-        PASS_THROUGH,
-        /** passed through, re-rendering the whole rebuilt view so the page 
resynchronizes with the server */
-        RENDER_ALL;
-
-        /**
-         * @return the replay the original request calls for, before the saved 
form itself is considered
-         */
-        static AjaxReplay of(ReplayFlow flow, HttpServletRequest request) {
-            if (flow != ReplayFlow.IN_PLACE || 
!Servlets.isFacesAjaxRequest(request)) {
-                return FULL_PAGE;
-            }
-            return 
isFormResubmitAjaxRenderAllDisabled(request.getServletContext()) ? PASS_THROUGH 
: RENDER_ALL;
-        }
-
-        /**
-         * @return this replay narrowed to the saved form: only a Faces Ajax 
submission can be passed through,
-         * and a stateless view isn't rebuilt, so there is nothing to 
resynchronize
-         */
-        AjaxReplay forForm(boolean isPartialAjaxRequest, boolean isStateless) {
-            if (!isPartialAjaxRequest) {
-                return FULL_PAGE;
-            }
-            return isStateless && this == RENDER_ALL ? PASS_THROUGH : this;
-        }
-
-        boolean isPassThrough() {
-            return this != FULL_PAGE;
-        }
-    }
-
     /**
      * Form fields prepared for replay
      *
      * @param result decoded form fields, by name
      * @param isPartialAjaxRequest whether the saved form was submitted via 
Faces Ajax
-     * @param ajaxReplay how the form is replayed
      */
-    record PartialAjaxResult(Map<String, List<String>> result, boolean 
isPartialAjaxRequest,
-                             AjaxReplay ajaxReplay) { }
+    record PartialAjaxResult(Map<String, List<String>> result, boolean 
isPartialAjaxRequest) { }
 
     static void savePostDataForResubmit(HttpServletRequest request, 
HttpServletResponse response, @NonNull String loginUrl) {
         if (isSavableForm(request) && 
isSecurityManagerTypeOf(getSecurityManager(), DefaultSecurityManager.class)) {
@@ -215,17 +158,17 @@ public class FormResubmitSupport {
     }
 
     /**
-     * Whether a POST that arrived without a session should be replayed in 
place, without a login flow,
-     * once the security chain permits it: for a remembered subject, or for an 
anonymous subject whose
-     * session has expired, i.e. the browser presented a session id that no 
longer resolves to a session.
-     * Only same-origin, form-encoded submissions are replayed, and only when 
server-side Faces view state
+     * Whether a POST that arrived without a session should rebuild its 
server-side Faces view during
+     * the request: for a remembered subject, or for an anonymous subject 
whose session has expired,
+     * i.e. the browser presented a session id that no longer resolves to a 
session.
+     * Only same-origin, form-encoded submissions are eligible, and only when 
server-side Faces view state
      * could have been lost with the session.
      *
      * @param subject the subject created for the request, without a session
      * @param request the current request
-     * @return true if the form should be replayed
+     * @return true if an expired server-side Faces view may be rebuilt
      */
-    static boolean isDirectResubmitCandidate(@NonNull Subject subject, 
ServletRequest request) {
+    static boolean isViewRebuildCandidate(@NonNull Subject subject, 
ServletRequest request) {
         if (!(request instanceof HttpServletRequest httpRequest) || 
!isSavableForm(httpRequest)) {
             return false;
         }
@@ -433,7 +376,7 @@ public class FormResubmitSupport {
             String formData = getSavedFormDataFromKey(savedFormDataKey, 
cache::set);
             try {
                 if (formData != null && resubmit && 
!isFormDataDiscarded(request)) {
-                    resubmitSavedForm(formData, savedRequest, request, 
response, ReplayFlow.AFTER_LOGIN);
+                    resubmitSavedForm(formData, savedRequest, request, 
response);
                     doRedirectAtEnd = false;
                 } else {
                     // nothing to submit, or the user chose to discard it: 
forget it either way
@@ -516,8 +459,8 @@ public class FormResubmitSupport {
      * @param savedRequest already validated by {@link #normalizeSavedRequest}
      */
     static void resubmitSavedForm(@NonNull String savedFormData, @NonNull 
String savedRequest,
-            HttpServletRequest originalRequest, HttpServletResponse 
originalResponse, ReplayFlow flow) {
-        if (!replaySavedForm(savedFormData, savedRequest, originalRequest, 
originalResponse, flow)) {
+            HttpServletRequest originalRequest, HttpServletResponse 
originalResponse) {
+        if (!replaySavedForm(savedFormData, savedRequest, originalRequest, 
originalResponse)) {
             doFacesRedirect(originalRequest, originalResponse, savedRequest);
         }
     }
@@ -526,7 +469,7 @@ public class FormResubmitSupport {
      * @return whether the response has been fully written
      */
     private static boolean replaySavedForm(String savedFormData, String 
savedRequest,
-            HttpServletRequest originalRequest, HttpServletResponse 
originalResponse, ReplayFlow flow) {
+            HttpServletRequest originalRequest, HttpServletResponse 
originalResponse) {
         if (FormResubmitRequest.isResubmit(originalRequest)) {
             log.debug("Recursive form resubmission, skipping replay");
             return false;
@@ -542,20 +485,16 @@ public class FormResubmitSupport {
         Servlets.setNoCacheHeaders(originalRequest, originalResponse);
         try {
             var savedFormFields = parseFormData(savedFormData, 
getFormCharset(originalRequest, servletContext));
-            PartialAjaxResult formData = prepareFormData(savedFormFields, 
dispatchPath, originalRequest,
-                    originalResponse, servletContext, AjaxReplay.of(flow, 
originalRequest));
+            boolean isStateless = isJSFClientStateSavingMethod(servletContext) 
|| !isJSFStatefulForm(savedFormFields);
+            PartialAjaxResult formData = noJSFAjaxRequests(savedFormFields, 
isStateless);
             var response = new FormResubmitResponse(originalResponse);
-            forward(dispatchPath, originalRequest, response, HttpMethod.POST, 
formData.result, formData.ajaxReplay);
+            forward(dispatchPath, originalRequest, response, formData.result);
             if (isFailed(response.getStatus())) {
                 log.debug("Form resubmit to {} failed with status {}", 
dispatchPath, response.getStatus());
                 return false;
             }
-            if (formData.ajaxReplay.isPassThrough() && 
isPartialResponseError(response.getBufferAsString())) {
-                log.debug("Form resubmit to {} failed with a Faces Ajax 
error", dispatchPath);
-                return false;
-            }
             processResubmitResponse(response, originalRequest, 
originalResponse, savedRequest,
-                    formData.isPartialAjaxRequest, flow);
+                    formData.isPartialAjaxRequest);
         } catch (ServletException | IOException | RuntimeException e) {
             log.warn("Unable to resubmit form to {}", dispatchPath, e);
             return false;
@@ -570,15 +509,6 @@ public class FormResubmitSupport {
         return status != SC_OK && status != SC_FOUND;
     }
 
-    /**
-     * Faces reports an unhandled exception in an Ajax request as a successful 
partial response that carries
-     * an error element, which would reach only the Ajax client's error 
callback.
-     * Such a replay is considered failed, so that it falls back to a redirect 
like a full-page one.
-     */
-    static boolean isPartialResponseError(@NonNull String responseBody) {
-        return Jsoup.parse(responseBody, 
Parser.xmlParser()).selectFirst("partial-response > error") != null;
-    }
-
     /**
      * Derives the dispatcher path from a saved request that is already 
verified to be within the context path.
      * The container's dispatcher strips path parameters, decodes and 
normalizes the path before resolving it,
@@ -605,9 +535,9 @@ public class FormResubmitSupport {
      * @param path dispatch path already verified by {@link #replaySavedForm} 
to resolve to a dispatcher
      */
     private static void forward(String path, HttpServletRequest 
originalRequest, HttpServletResponse response,
-            String method, Map<String, List<String>> formFields, AjaxReplay 
ajaxReplay)
+            Map<String, List<String>> formFields)
             throws ServletException, IOException {
-        var request = new FormResubmitRequest(originalRequest, method, 
formFields, ajaxReplay);
+        var request = new FormResubmitRequest(originalRequest, formFields);
         var dispatcher = 
originalRequest.getServletContext().getRequestDispatcher(path);
         if (!hasFacesContext()) {
             dispatcher.forward(request, response);
@@ -656,29 +586,17 @@ public class FormResubmitSupport {
                 }).orElse(StandardCharsets.UTF_8);
     }
 
-    private static PartialAjaxResult prepareFormData(Map<String, List<String>> 
savedFormFields, String path,
-            HttpServletRequest request, HttpServletResponse response, 
ServletContext servletContext,
-            AjaxReplay ajaxReplay) throws IOException, ServletException {
-        boolean isStateless = isJSFClientStateSavingMethod(servletContext) || 
!isJSFStatefulForm(savedFormFields);
-        var formFields = new LinkedHashMap<>(savedFormFields);
-        if (!isStateless) {
-            refreshJSFViewState(path, request, response, formFields);
-        }
-        return noJSFAjaxRequests(formFields, isStateless, ajaxReplay);
-    }
-
     /**
      * Only a successful replay reaches the browser, with its headers and 
cookies.
-     * An Ajax replay's partial response is passed through to the Ajax client 
that submitted the form.
-     * Otherwise, the Ajax client is redirected to see the full-page replay's 
outcome.
+     * A saved Ajax submission redirects to the saved page after its action is 
replayed.
+     * Otherwise, the full-page replay's response body is returned.
      */
     private static void processResubmitResponse(FormResubmitResponse response, 
HttpServletRequest originalRequest,
-            HttpServletResponse originalResponse, String savedRequest, boolean 
isPartialAjaxRequest,
-            ReplayFlow flow) throws IOException {
+            HttpServletResponse originalResponse, String savedRequest, boolean 
isPartialAjaxRequest) throws IOException {
         int status = response.getStatus();
         response.applyTo(originalResponse);
         originalResponse.setStatus(status);
-        if (isPartialAjaxRequest && (status == SC_FOUND || flow == 
ReplayFlow.AFTER_LOGIN)) {
+        if (isPartialAjaxRequest) {
             doFacesRedirect(originalRequest, originalResponse, savedRequest);
         } else {
             originalResponse.getOutputStream().write(response.getBuffer());
@@ -705,59 +623,24 @@ public class FormResubmitSupport {
         return null;
     }
 
-    private static void refreshJSFViewState(String path, HttpServletRequest 
request,
-            HttpServletResponse response, Map<String, List<String>> 
formFields) throws IOException, ServletException {
-        // view-state GET headers and cookies stay captured and are never 
applied to the browser response
-        var htmlResponse = new FormResubmitResponse(response);
-        forward(path, request, htmlResponse, HttpMethod.GET, Map.of(), 
AjaxReplay.FULL_PAGE);
-        if (htmlResponse.getStatus() == SC_OK) {
-            
Optional.ofNullable(extractJSFNewViewState(htmlResponse.getBufferAsString())).ifPresent(viewState
 -> {
-                log.debug("Replaced ViewState: {}", viewState);
-                formFields.put(FACES_VIEW_STATE, List.of(viewState));
-            });
-        }
-    }
-
-    /**
-     * @return view state of the first form in the rendered view, or null if 
there is none
-     */
-    static String extractJSFNewViewState(@NonNull String responseBody) {
-        Elements elts = 
Jsoup.parse(responseBody).select("input[name=%s]".formatted(FACES_VIEW_STATE));
-        return elts.isEmpty() ? null : 
Objects.requireNonNull(elts.first()).attr("value");
-    }
-
     /**
      * Turns a Faces Ajax submission into a full-page submission of the same 
command.
-     * The Ajax fields are only kept for stateless views, where they can't 
fail view state restoration.
+     * For a stateful view, its Ajax fields are removed so the complete form 
is processed and any expired view rebuilt.
+     * A stateless view can retain the Ajax fields and its partial execute.
      */
     static PartialAjaxResult noJSFAjaxRequests(Map<String, List<String>> 
formFields, boolean isStateless) {
-        return noJSFAjaxRequests(formFields, isStateless, 
AjaxReplay.FULL_PAGE);
-    }
-
-    /**
-     * Keeps a Faces Ajax submission intact when it's passed through to the 
waiting Ajax client,
-     * or when the view is stateless so the Ajax fields can't fail view state 
restoration.
-     * Otherwise, turns it into a full-page submission of the same command.
-     */
-    static PartialAjaxResult noJSFAjaxRequests(Map<String, List<String>> 
formFields, boolean isStateless,
-            AjaxReplay ajaxReplay) {
-        var fullForm = new LinkedHashMap<String, List<String>>();
+        var result = new LinkedHashMap<String, List<String>>();
+        boolean isPartialAjaxRequest = 
formFields.keySet().stream().anyMatch(FormResubmitSupport::isFacesAjaxField);
         formFields.forEach((name, values) -> {
-            if (!isFacesAjaxField(name)) {
-                fullForm.put(name, values);
+            if (isStateless || !isFacesAjaxField(name)) {
+                result.put(name, values);
             }
         });
-        boolean isPartialAjaxRequest = fullForm.size() != formFields.size();
-        var replay = ajaxReplay.forForm(isPartialAjaxRequest, isStateless);
-        var result = isStateless || replay.isPassThrough() ? new 
LinkedHashMap<>(formFields) : fullForm;
-        if (replay == AjaxReplay.RENDER_ALL) {
-            result.put(PARTIAL_RENDER_PARAM_NAME, 
List.of(ALL_PARTIAL_PHASE_CLIENT_IDS));
-        }
         // The source value becomes the submitted command's parameter name
         formFields.getOrDefault(BEHAVIOR_SOURCE_PARAM_NAME, List.of()).stream()
                 .filter(source -> !Utils.isEmpty(source)).findFirst()
                 .ifPresent(source -> result.putIfAbsent(source, List.of("")));
-        return new PartialAjaxResult(result, isPartialAjaxRequest, replay);
+        return new PartialAjaxResult(result, isPartialAjaxRequest);
     }
 
     private static boolean isFacesAjaxField(String name) {
@@ -767,7 +650,7 @@ public class FormResubmitSupport {
 
     static boolean isJSFStatefulForm(@NonNull Map<String, List<String>> 
formFields) {
         return formFields.getOrDefault(FACES_VIEW_STATE, List.of()).stream()
-                .anyMatch(viewState -> 
STATEFUL_VIEW_STATE_PATTERN.matcher(viewState).matches());
+                .anyMatch(viewState -> !viewState.isBlank() && 
!"stateless".equals(viewState));
     }
 
     static boolean isJSFClientStateSavingMethod(ServletContext servletContext) 
{
diff --git 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitViewHandler.java
 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitViewHandler.java
new file mode 100644
index 000000000..cb33041ab
--- /dev/null
+++ 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitViewHandler.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed 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.shiro.ee.filters;
+
+import java.io.IOException;
+import static 
org.apache.shiro.ee.filters.FormResubmitSupport.REBUILD_EXPIRED_VIEW;
+import jakarta.faces.application.ViewHandler;
+import jakarta.faces.application.ViewHandlerWrapper;
+import jakarta.faces.component.UIViewRoot;
+import jakarta.faces.context.FacesContext;
+import static 
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitAjaxRenderAllDisabled;
+import lombok.SneakyThrows;
+import org.omnifaces.util.ComponentsLocal;
+import org.omnifaces.util.FacesLocal;
+
+/**
+ * Rebuilds expired server-side views only for marked or replayed Shiro form 
submissions.
+ */
+public class FormResubmitViewHandler extends ViewHandlerWrapper {
+    public FormResubmitViewHandler(ViewHandler wrapped) {
+        super(wrapped);
+    }
+
+    @Override
+    @SneakyThrows(IOException.class)
+    public UIViewRoot restoreView(FacesContext context, String viewId) {
+        var view = super.restoreView(context, viewId);
+        var request = FacesLocal.getRequest(context);
+        boolean rebuildExpiredView = 
Boolean.TRUE.equals(request.getAttribute(REBUILD_EXPIRED_VIEW));
+        if (view == null && context.isPostback() && 
(FormResubmitRequest.isResubmit(request) || rebuildExpiredView)
+                && 
!FormResubmitSupport.isJSFClientStateSavingMethod(FacesLocal.getServletContext(context)))
 {
+            if (rebuildExpiredView && 
context.getPartialViewContext().isAjaxRequest()
+                    && 
!isFormResubmitAjaxRenderAllDisabled(FacesLocal.getServletContext(context))) {
+                context.getPartialViewContext().setRenderAll(true);
+            }
+            boolean processingEvents = context.isProcessingEvents();
+            context.setProcessingEvents(true);
+            try {
+                return ComponentsLocal.buildView(context, viewId);
+            } finally {
+                context.setProcessingEvents(processingEvents);
+            }
+        }
+        return view;
+    }
+}
diff --git 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
index c00a35da6..733fbae74 100644
--- 
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
+++ 
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
@@ -14,15 +14,8 @@
 package org.apache.shiro.ee.filters;
 
 import static 
org.apache.shiro.ee.cdi.ShiroScopeContext.addScopeSessionListeners;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.FORM_IS_RESUBMITTED;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.getPostData;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.isDirectResubmitCandidate;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.isLoginUrl;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.isPostRequest;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.normalizeSavedRequest;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.redirectToView;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.resubmitSavedForm;
-import org.apache.shiro.ee.filters.FormResubmitSupport.ReplayFlow;
+import static 
org.apache.shiro.ee.filters.FormResubmitSupport.REBUILD_EXPIRED_VIEW;
+import static 
org.apache.shiro.ee.filters.FormResubmitSupport.isViewRebuildCandidate;
 import static 
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.getCharacterEncoding;
 import static 
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isCharEncodingEnabled;
 import static 
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isShiroEEDisabled;
@@ -169,9 +162,9 @@ public class ShiroFilter extends 
org.apache.shiro.web.servlet.ShiroFilter {
                     log.debug("Create Session Failed", e);
                 }
                 var newSubject = wrapped.createSubject(context);
-                if (session == null && isDirectResubmitCandidate(newSubject, 
webContext.getServletRequest())) {
-                    log.debug("Subject {} without a session, form will be 
replayed", newSubject.getPrincipal());
-                    
webContext.getServletRequest().setAttribute(FORM_IS_RESUBMITTED, Boolean.TRUE);
+                if (session == null && isViewRebuildCandidate(newSubject, 
webContext.getServletRequest())) {
+                    log.debug("Subject {} without a session, expired Faces 
view may be rebuilt", newSubject.getPrincipal());
+                    
webContext.getServletRequest().setAttribute(REBUILD_EXPIRED_VIEW, Boolean.TRUE);
                 }
                 return newSubject;
             } else {
@@ -240,34 +233,7 @@ public class ShiroFilter extends 
org.apache.shiro.web.servlet.ShiroFilter {
             origChain.doFilter(request, response);
         } else {
             setCharacterEncodingIfNeeded(request);
-            // Replay only once the security chain has permitted the request, 
so that a denied request
-            // still saves its form for the login flow, with its body intact
-            super.executeChain(request, response, (chainRequest, 
chainResponse) -> {
-                if 
(Boolean.TRUE.equals(chainRequest.getAttribute(FORM_IS_RESUBMITTED))) {
-                    chainRequest.removeAttribute(FORM_IS_RESUBMITTED);
-                    if (isPostRequest(chainRequest) && 
!isLoginUrl(WebUtils.toHttp(chainRequest))) {
-                        resubmitForm(WebUtils.toHttp(chainRequest), 
WebUtils.toHttp(chainResponse));
-                        return;
-                    }
-                }
-                origChain.doFilter(chainRequest, chainResponse);
-            });
-        }
-    }
-
-    /**
-     * Replays the current POST, whose session has been lost, in place of the 
request's own processing
-     */
-    private static void resubmitForm(HttpServletRequest httpRequest, 
HttpServletResponse httpResponse) {
-        String postData = getPostData(httpRequest);
-        // never log the body itself: anonymous replays may carry credentials 
or other secrets
-        log.debug("Resubmitting POST to {} ({} bytes of form data)", 
httpRequest.getRequestURI(), postData.length());
-        // the raw request URI need not be canonical, nor start with the 
context path
-        String savedRequest = 
normalizeSavedRequest(Servlets.getRequestURIWithQueryString(httpRequest), 
httpRequest);
-        if (savedRequest == null) {
-            redirectToView(httpRequest, httpResponse);
-        } else {
-            resubmitSavedForm(postData, savedRequest, httpRequest, 
httpResponse, ReplayFlow.IN_PLACE);
+            super.executeChain(request, response, origChain);
         }
     }
 
diff --git a/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml 
b/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml
index 0f2860c65..6959e5af4 100644
--- a/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml
+++ b/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml
@@ -23,6 +23,9 @@
               xmlns="https://jakarta.ee/xml/ns/jakartaee";
               xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
               xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee 
https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_0.xsd";>
+    <application>
+        
<view-handler>org.apache.shiro.ee.filters.FormResubmitViewHandler</view-handler>
+    </application>
     <component>
         
<component-type>org.apache.shiro.ee.faces.tags.PrincipalTag</component-type>
         
<component-class>org.apache.shiro.ee.faces.tags.PrincipalTag</component-class>
diff --git 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitDispatchTest.java
 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitDispatchTest.java
new file mode 100644
index 000000000..576e80f2c
--- /dev/null
+++ 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitDispatchTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed 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.shiro.ee.filters;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import jakarta.servlet.RequestDispatcher;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class FormResubmitDispatchTest {
+    @Mock
+    private HttpServletRequest request;
+    @Mock
+    private HttpServletResponse response;
+    @Mock
+    private ServletContext servletContext;
+    @Mock
+    private RequestDispatcher dispatcher;
+    @Mock
+    private ServletOutputStream output;
+
+    @Test
+    void afterLoginReplayUsesOnePostAndNoGet() throws Exception {
+        replayAfterLogin(false);
+    }
+
+    @Test
+    void afterLoginAjaxReplayProcessesCommandThenRedirects() throws Exception {
+        replayAfterLogin(true);
+    }
+
+    private void replayAfterLogin(boolean ajaxForm) throws Exception {
+        when(request.getContextPath()).thenReturn("/app");
+        when(request.getRequestURL()).thenReturn(new 
StringBuffer("https://localhost/app/form.xhtml";));
+        when(request.getServletContext()).thenReturn(servletContext);
+        
when(request.getAttributeNames()).thenReturn(Collections.emptyEnumeration());
+        when(servletContext.getContextPath()).thenReturn("/app");
+        
when(servletContext.getRequestDispatcher("/form.xhtml")).thenReturn(dispatcher);
+        if (!ajaxForm) {
+            when(response.getOutputStream()).thenReturn(output);
+        }
+        byte[] result = "replayed".getBytes(StandardCharsets.UTF_8);
+        doAnswer(invocation -> {
+            HttpServletRequest replay = invocation.getArgument(0);
+            HttpServletResponse captured = invocation.getArgument(1);
+            assertThat(replay.getMethod()).isEqualTo("POST");
+            
assertThat(replay.getParameter("jakarta.faces.ViewState")).isEqualTo("123:456");
+            assertThat(replay.getParameter("name")).isEqualTo("Jack & Jill");
+            assertThat(FormResubmitRequest.isResubmit(replay)).isTrue();
+            if (ajaxForm) {
+                
assertThat(replay.getParameter("jakarta.faces.partial.ajax")).isNull();
+                
assertThat(replay.getParameter("jakarta.faces.source")).isNull();
+                assertThat(replay.getParameter("form:submit")).isEmpty();
+                assertThat(replay.getHeader("Faces-Request")).isNull();
+            }
+            captured.getOutputStream().write(result);
+            return null;
+        }).when(dispatcher).forward(any(), any());
+
+        String formData = 
"name=Jack+%26+Jill&jakarta.faces.ViewState=123%3A456"
+                + (ajaxForm ? 
"&jakarta.faces.partial.ajax=true&jakarta.faces.source=form%3Asubmit" : "");
+        FormResubmitSupport.resubmitSavedForm(formData,
+                "/app/form.xhtml", request, response);
+
+        verify(dispatcher).forward(any(), any());
+        if (ajaxForm) {
+            verify(response).sendRedirect("/app/form.xhtml");
+            verify(response, never()).getOutputStream();
+        } else {
+            verify(output).write(result);
+            verify(response, never()).sendRedirect(any());
+        }
+    }
+}
diff --git 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
index 5238bbefb..418af2253 100644
--- 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
+++ 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
@@ -17,7 +17,6 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import jakarta.servlet.http.HttpServletRequest;
-import org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -48,24 +47,13 @@ class FormResubmitRequestTest {
                 .thenReturn(Collections.enumeration(List.of("partial/ajax")));
     }
 
-    private FormResubmitRequest request(AjaxReplay ajaxReplay) {
-        return new FormResubmitRequest(login, "POST", Map.of(), ajaxReplay);
-    }
-
     @Test
     void facesRequestHeaderIsHiddenFromAllAccessorsOnFullPageReplay() {
-        var request = request(AjaxReplay.FULL_PAGE);
+        var request = new FormResubmitRequest(login, Map.of());
         assertThat(request.getHeader("Faces-Request")).isNull();
         assertThat(request.getHeader("faces-request")).isNull();
         
assertThat(Collections.list(request.getHeaders("Faces-Request"))).isEmpty();
         
assertThat(Collections.list(request.getHeaderNames())).containsExactly("Host");
     }
 
-    @Test
-    void facesRequestHeaderIsVisibleOnPassThroughReplay() {
-        var request = request(AjaxReplay.PASS_THROUGH);
-        
assertThat(request.getHeader("Faces-Request")).isEqualTo("partial/ajax");
-        
assertThat(Collections.list(request.getHeaders("Faces-Request"))).containsExactly("partial/ajax");
-        
assertThat(Collections.list(request.getHeaderNames())).containsExactly("Faces-Request",
 "Host");
-    }
 }
diff --git 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitViewHandlerTest.java
 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitViewHandlerTest.java
new file mode 100644
index 000000000..7aba8ad9b
--- /dev/null
+++ 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitViewHandlerTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed 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.shiro.ee.filters;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import jakarta.faces.application.ViewHandler;
+import jakarta.faces.component.UIViewRoot;
+import jakarta.faces.context.ExternalContext;
+import jakarta.faces.context.FacesContext;
+import jakarta.faces.context.PartialViewContext;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.omnifaces.util.ComponentsLocal;
+
+import static 
jakarta.faces.application.StateManager.STATE_SAVING_METHOD_PARAM_NAME;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class FormResubmitViewHandlerTest {
+    private static final String VIEW_ID = "/form.xhtml";
+    @Mock
+    private FacesContext context;
+    @Mock
+    private ExternalContext externalContext;
+    @Mock
+    private ServletContext servletContext;
+    @Mock
+    private HttpServletRequest request;
+    @Mock
+    private ViewHandler wrapped;
+    @Mock
+    private UIViewRoot view;
+    @Mock
+    private PartialViewContext partialViewContext;
+    @Mock
+    private MockedStatic<ComponentsLocal> components;
+
+    private FormResubmitViewHandler handler;
+
+    @BeforeEach
+    void setUp() {
+        handler = new FormResubmitViewHandler(wrapped);
+        
lenient().when(context.getExternalContext()).thenReturn(externalContext);
+        lenient().when(externalContext.getRequest()).thenReturn(request);
+        
lenient().when(externalContext.getContext()).thenReturn(servletContext);
+    }
+
+    private void replay() {
+        when(context.isPostback()).thenReturn(true);
+        
when(request.getAttributeNames()).thenReturn(Collections.emptyEnumeration());
+        var replay = new FormResubmitRequest(request, Map.of());
+        when(externalContext.getRequest()).thenReturn(new 
HttpServletRequestWrapper(replay));
+    }
+
+    @Test
+    void existingViewIsRestoredNormally() {
+        when(wrapped.restoreView(context, VIEW_ID)).thenReturn(view);
+        assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+        components.verifyNoInteractions();
+    }
+
+    @Test
+    void expiredReplayUsesOmniFacesViewBuilder() {
+        replay();
+        components.when(() -> ComponentsLocal.buildView(context, 
VIEW_ID)).thenReturn(view);
+        assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+        components.verify(() -> ComponentsLocal.buildView(context, VIEW_ID));
+    }
+
+    @Test
+    void expiredMarkedRequestUsesOmniFacesViewBuilder() {
+        when(context.isPostback()).thenReturn(true);
+        
when(request.getAttribute(FormResubmitSupport.REBUILD_EXPIRED_VIEW)).thenReturn(Boolean.TRUE);
+        when(context.getPartialViewContext()).thenReturn(partialViewContext);
+        when(partialViewContext.isAjaxRequest()).thenReturn(true);
+        components.when(() -> ComponentsLocal.buildView(context, 
VIEW_ID)).thenReturn(view);
+        assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+        components.verify(() -> ComponentsLocal.buildView(context, VIEW_ID));
+        verify(partialViewContext).setRenderAll(true);
+    }
+
+    @Test
+    void markedRequestKeepsRenderTargetsWhenRenderAllIsDisabled() {
+        when(context.isPostback()).thenReturn(true);
+        
when(request.getAttribute(FormResubmitSupport.REBUILD_EXPIRED_VIEW)).thenReturn(Boolean.TRUE);
+        when(context.getPartialViewContext()).thenReturn(partialViewContext);
+        when(partialViewContext.isAjaxRequest()).thenReturn(true);
+        
when(servletContext.getAttribute("org.apache.shiro.form-resubmit.ajax-render-all.disabled"))
+                .thenReturn(Boolean.TRUE);
+        components.when(() -> ComponentsLocal.buildView(context, 
VIEW_ID)).thenReturn(view);
+
+        assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+
+        verify(partialViewContext, never()).setRenderAll(anyBoolean());
+    }
+
+    @Test
+    void viewBuildEnablesAndRestoresProcessingEvents() {
+        replay();
+        var processingEvents = new AtomicBoolean();
+        when(context.isProcessingEvents()).thenAnswer(invocation -> 
processingEvents.get());
+        doAnswer(invocation -> {
+            processingEvents.set(invocation.getArgument(0));
+            return null;
+        }).when(context).setProcessingEvents(anyBoolean());
+        components.when(() -> ComponentsLocal.buildView(context, 
VIEW_ID)).thenAnswer(invocation -> {
+            assertThat(processingEvents.get()).isTrue();
+            return view;
+        });
+
+        assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+
+        assertThat(processingEvents.get()).isFalse();
+        verify(context).setProcessingEvents(true);
+        verify(context).setProcessingEvents(false);
+    }
+
+    @Test
+    void ordinaryExpiredPostbackIsNotRebuilt() {
+        when(context.isPostback()).thenReturn(true);
+        assertThat(handler.restoreView(context, VIEW_ID)).isNull();
+        components.verifyNoInteractions();
+    }
+
+    @Test
+    void initialRequestIsNotRebuilt() {
+        assertThat(handler.restoreView(context, VIEW_ID)).isNull();
+        components.verifyNoInteractions();
+    }
+
+    @Test
+    void clientSideStateIsNotRebuilt() {
+        replay();
+        
when(servletContext.getInitParameter(STATE_SAVING_METHOD_PARAM_NAME)).thenReturn("client");
+        assertThat(handler.restoreView(context, VIEW_ID)).isNull();
+        components.verifyNoInteractions();
+    }
+
+    @Test
+    void buildFailurePropagatesToReplayFallback() {
+        replay();
+        var failure = new IOException("Cannot build view");
+        components.when(() -> ComponentsLocal.buildView(context, 
VIEW_ID)).thenThrow(failure);
+        assertThatExceptionOfType(IOException.class)
+                .isThrownBy(() -> handler.restoreView(context, 
VIEW_ID)).isSameAs(failure);
+        verify(context).setProcessingEvents(true);
+        verify(context).setProcessingEvents(false);
+    }
+}
diff --git 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
index a7d458681..1cc947e9a 100644
--- 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
+++ 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
@@ -15,11 +15,6 @@ package org.apache.shiro.ee.filters;
 
 import org.apache.shiro.ee.filters.FormResubmitSupport.PartialAjaxResult;
 
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.FULL_PAGE;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.PASS_THROUGH;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.RENDER_ALL;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.extractJSFNewViewState;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.isPartialResponseError;
 import static org.apache.shiro.ee.filters.FormResubmitSupport.getDispatchPath;
 import static org.apache.shiro.ee.filters.FormResubmitSupport.getFormCharset;
 import static org.apache.shiro.ee.filters.FormResubmitSupport.getReferer;
@@ -248,10 +243,13 @@ class FormSupportTest {
                 + "j_idt11=Submit+...&jakarta.faces.ViewState"
                 + "=-8335355445345003673%3A-6008443334776649058";
         
assertThat(isJSFStatefulForm(parseFormData(statefulFormData))).isTrue();
+        assertThat(isJSFStatefulForm(parseFormData(
+                
"jakarta.faces.ViewState=YjJ1dDkzQmFzZTY0LW15ZmFjZXM="))).isTrue();
         String statelessFormData
                 = 
"j_idt5=j_idt5&j_idt5%3Aj_idt7=aaa&j_idt5%3Aj_idt9=bbb&j_idt5%3A"
                 + "j_idt11=Submit+...&jakarta.faces.ViewState=stateless";
         
assertThat(isJSFStatefulForm(parseFormData(statelessFormData))).isFalse();
+        
assertThat(isJSFStatefulForm(parseFormData("jakarta.faces.ViewState="))).isFalse();
         assertThatExceptionOfType(NullPointerException.class).isThrownBy(() -> 
isJSFStatefulForm(null));
         String nonJSFFormData
                 = 
"j_idt5=j_idt5&j_idt5%3Aj_idt7=aaa&j_idt5%3Aj_idt9=bbb&j_idt5%3A"
@@ -274,9 +272,9 @@ class FormSupportTest {
     @Test
     void parseFormDataHonoursCharset() {
         assertThat(FormResubmitSupport.parseFormData("name=J%F6rg", 
StandardCharsets.ISO_8859_1))
-                .containsExactly(Map.entry("name", List.of("J\u00f6rg")));
+                .containsExactly(Map.entry("name", List.of("Jörg")));
         assertThat(FormResubmitSupport.parseFormData("name=J%C3%B6rg", 
StandardCharsets.UTF_8))
-                .containsExactly(Map.entry("name", List.of("J\u00f6rg")));
+                .containsExactly(Map.entry("name", List.of("Jörg")));
     }
 
     @Test
@@ -295,24 +293,12 @@ class FormSupportTest {
         assertThat(getFormCharset(request, 
servletContext)).isEqualTo(StandardCharsets.UTF_8);
     }
 
-    @Test
-    void extractViewState() {
-        assertThatExceptionOfType(NullPointerException.class).isThrownBy(() -> 
extractJSFNewViewState(null));
-        assertThat(extractJSFNewViewState("")).isNull();
-        assertThat(extractJSFNewViewState("xxx")).isNull();
-        assertThat(extractJSFNewViewState("<input 
name=\"jakarta.faces.ViewState\" value=\"123:456\"/>"))
-                .isEqualTo("123:456");
-        assertThat(extractJSFNewViewState("<input 
name=\"jakarta.faces.ViewState\" value=\"-123:-456\"/>"
-                + "<input name=\"jakarta.faces.ViewState\" 
value=\"-789:-012\"/>"))
-                .isEqualTo("-123:-456");
-    }
-
     @Test
     void noAjaxRequests() {
         
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"
                         + "&jakarta.faces.partial.ajax=true&hello=bye"), 
false)).isEqualTo(new PartialAjaxResult(
                         
parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456&hello=bye"),
-                        true, FULL_PAGE));
+                        true));
         
assertThat(noJSFAjaxRequests(parseFormData("j_idt12=j_idt12&j_idt12:j_idt14=asdf&j_idt12:j_idt16=asdf"
                         + 
"&jakarta.faces.ViewState=7709788254588873136:-8052771455757429917"
                         + "&jakarta.faces.source=j_idt12:j_idt18"
@@ -324,56 +310,22 @@ class FormSupportTest {
                 .isEqualTo(new 
PartialAjaxResult(parseFormData("j_idt12=j_idt12&j_idt12:j_idt14=asdf"
                         + "&j_idt12:j_idt16=asdf"
                         + 
"&jakarta.faces.ViewState=7709788254588873136:-8052771455757429917&j_idt12:j_idt18="),
-                        true, FULL_PAGE));
+                        true));
     }
 
     @Test
-    void ajaxFieldsAreKeptForPassThrough() {
-        String ajaxForm = 
"aaa=bbb&jakarta.faces.ViewState=-123:-456&jakarta.faces.source=j_idt12:j_idt18"
+    void statelessAjaxFieldsAreKeptForReplay() {
+        String ajaxForm = 
"aaa=bbb&jakarta.faces.ViewState=stateless&jakarta.faces.source=j_idt12:j_idt18"
                 + 
"&jakarta.faces.partial.render=j_idt12&jakarta.faces.partial.ajax=true";
-        assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), false, 
PASS_THROUGH)).isEqualTo(new PartialAjaxResult(
-                parseFormData(ajaxForm + "&j_idt12:j_idt18="), true, 
PASS_THROUGH));
-        // a non-Ajax form submitted while the Ajax client is on the page 
isn't an Ajax replay
-        
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
 false, RENDER_ALL))
-                .isEqualTo(new 
PartialAjaxResult(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
-                        false, FULL_PAGE));
-    }
-
-    @Test
-    void partialResponseErrorIsDetected() {
-        assertThat(isPartialResponseError("""
-                <?xml version='1.0' encoding='UTF-8'?>
-                <partial-response id="j_id1">
-                    <error>
-                        <error-name>class 
jakarta.faces.application.ViewExpiredException</error-name>
-                        <error-message><![CDATA[viewId:/index.xhtml - View 
/index.xhtml could not be restored.]]></error-message>
-                    </error>
-                </partial-response>""")).isTrue();
-        assertThat(isPartialResponseError("""
-                <?xml version='1.0' encoding='UTF-8'?>
-                <partial-response id="j_id1">
-                    <changes>
-                        <update id="form"><![CDATA[<error>not an 
error</error>]]></update>
-                        <update 
id="j_id1:jakarta.faces.ViewState:0"><![CDATA[-123:-456]]></update>
-                    </changes>
-                </partial-response>""")).isFalse();
-        assertThat(isPartialResponseError("<html><body>full 
page</body></html>")).isFalse();
+        assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), 
true)).isEqualTo(new PartialAjaxResult(
+                parseFormData(ajaxForm + "&j_idt12:j_idt18="), true));
     }
 
     @Test
-    void ajaxReplayOfRebuiltViewRendersAll() {
-        String ajaxForm = 
"aaa=bbb&jakarta.faces.ViewState=-123:-456&jakarta.faces.source=j_idt12:j_idt18"
-                + 
"&jakarta.faces.partial.render=j_idt12&jakarta.faces.partial.ajax=true";
-        assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), false, 
RENDER_ALL).result())
-                .containsEntry("jakarta.faces.partial.render", List.of("@all"))
-                .containsEntry("jakarta.faces.partial.ajax", List.of("true"));
-        // the render targets stay in place when a stateless view isn't rebuilt
-        assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), true, 
RENDER_ALL))
-                .isEqualTo(new PartialAjaxResult(parseFormData(ajaxForm + 
"&j_idt12:j_idt18="), true, PASS_THROUGH));
-        // or without any render targets
-        
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"
-                + 
"&jakarta.faces.source=j_idt12:j_idt18&jakarta.faces.partial.ajax=true"), 
false, RENDER_ALL)
-                .result()).containsEntry("jakarta.faces.partial.render", 
List.of("@all"));
+    void nonAjaxFieldsAreUnchangedForReplay() {
+        
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
 false))
+                .isEqualTo(new 
PartialAjaxResult(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
+                        false));
     }
 
     @Test
@@ -387,7 +339,7 @@ class FormSupportTest {
                 + "&jakarta.faces.partial.ajax=true"), false);
         assertThat(result).isEqualTo(new PartialAjaxResult(
                 
parseFormData("text=a%26b%2Bc%3Dd&jakarta.faces.ViewState=123%3A456&j_idt12%3Aj_idt18="),
-                true, FULL_PAGE));
+                true));
         assertThat(result.result()).containsEntry("text", List.of("a&b+c=d"));
     }
 
diff --git 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/ViewRebuildCandidateTest.java
similarity index 76%
rename from 
support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
rename to 
support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/ViewRebuildCandidateTest.java
index 5ecf30552..3bc4370d1 100644
--- 
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
+++ 
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/ViewRebuildCandidateTest.java
@@ -15,7 +15,7 @@ package org.apache.shiro.ee.filters;
 
 import static 
jakarta.faces.application.StateManager.STATE_SAVING_METHOD_CLIENT;
 import static 
jakarta.faces.application.StateManager.STATE_SAVING_METHOD_PARAM_NAME;
-import static 
org.apache.shiro.ee.filters.FormResubmitSupport.isDirectResubmitCandidate;
+import static 
org.apache.shiro.ee.filters.FormResubmitSupport.isViewRebuildCandidate;
 import static org.apache.shiro.ee.filters.FormResubmitSupport.isFormUrlEncoded;
 import static org.apache.shiro.ee.filters.FormResubmitSupport.isSavableForm;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -32,11 +32,11 @@ import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
 /**
- * Replaying a form in place, without a login flow, for remembered and 
anonymous subjects
+ * Rebuilding expired views without a login flow for remembered and anonymous 
subjects
  */
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
-class DirectResubmitCandidateTest {
+class ViewRebuildCandidateTest {
     private static final String FORM_RESUBMIT_DISABLED_PARAM = 
"org.apache.shiro.form-resubmit.disabled";
     private static final String FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM = 
"org.apache.shiro.form-resubmit.anonymous.disabled";
 
@@ -59,68 +59,68 @@ class DirectResubmitCandidateTest {
     }
 
     @Test
-    void anonymousExpiredSessionIsReplayed() {
-        assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+    void anonymousExpiredSessionAllowsViewRebuild() {
+        assertThat(isViewRebuildCandidate(subject, request)).isTrue();
     }
 
     @Test
-    void anonymousWithoutPriorSessionIsNotReplayed() {
+    void anonymousWithoutPriorSessionDoesNotAllowViewRebuild() {
         when(request.getRequestedSessionId()).thenReturn(null);
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
     }
 
     @Test
-    void rememberedIsReplayedEvenWithoutPriorSession() {
+    void rememberedAllowsViewRebuildEvenWithoutPriorSession() {
         when(request.getRequestedSessionId()).thenReturn(null);
         when(subject.isRemembered()).thenReturn(true);
-        assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+        assertThat(isViewRebuildCandidate(subject, request)).isTrue();
     }
 
     @Test
-    void anonymousReplayCanBeDisabled() {
+    void anonymousViewRebuildCanBeDisabled() {
         
when(servletContext.getAttribute(FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
     }
 
     @Test
-    void anonymousDisablingKeepsRememberedReplay() {
+    void anonymousDisablingKeepsRememberedViewRebuild() {
         
when(servletContext.getAttribute(FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
         when(subject.isRemembered()).thenReturn(true);
-        assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+        assertThat(isViewRebuildCandidate(subject, request)).isTrue();
     }
 
     @Test
-    void globalDisablingStopsAllReplays() {
+    void globalDisablingStopsAllViewRebuilds() {
         
when(servletContext.getAttribute(FORM_RESUBMIT_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
         when(subject.isRemembered()).thenReturn(true);
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
     }
 
     @Test
-    void clientStateSavingNeedsNoReplay() {
+    void clientStateSavingNeedsNoViewRebuild() {
         
when(servletContext.getInitParameter(STATE_SAVING_METHOD_PARAM_NAME)).thenReturn(STATE_SAVING_METHOD_CLIENT);
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
     }
 
     @Test
-    void getIsNotReplayed() {
+    void getDoesNotAllowViewRebuild() {
         when(request.getMethod()).thenReturn("GET");
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
     }
 
     @Test
-    void crossSitePostIsNotReplayed() {
+    void crossSitePostDoesNotAllowViewRebuild() {
         when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
     }
 
     @Test
-    void nonFormContentIsNotReplayed() {
+    void nonFormContentDoesNotAllowViewRebuild() {
         when(request.getContentType()).thenReturn("application/json");
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
         when(request.getContentType()).thenReturn("multipart/form-data; 
boundary=xyz");
-        assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+        assertThat(isViewRebuildCandidate(subject, request)).isFalse();
     }
 
     @Test

Reply via email to