Programming Thoughts
Struts 2 - Server State per Browser Tab
Initial Design

The same page in different browser tabs that don't interfere with each other

HTTP is a stateless protocol, which worked for the Web's initial purpose. Even a shopping cart needs conversation state, so Netscape created cookies, which web servers use to track server side state. Cookies and, thus, server side state, are per browser. An authenticated user can open a new browser tab and be authenticated in it as well but if they open the same page in different tabs to edit different records, these interfere with each other. User interfaces must be intuitive and the limitations of HTTP are lost on users.

Browser add-ons

Browser add-ons, such as Firefox Multi-Account Containers, can maintain separate sets of cookies, creating separate sessions per tab. This works well but requires users to install and remember to use it. It also requires the user to login for each session.

Hidden form fields

Viewer Actions can generate a unique id, which identifies the tab when passed by JSP pages as hidden form fields or URL parameters. Every Action must read the id and it must be present in every form, hyperlink and redirect or the chain is broken, including redirects in Post/Redirect/Get.

Set cookie when tab selected

Cookies are also sent with browser requests, allowing some automation using Struts 2 interceptors, but cookies are shared between tabs. The trick is to set a named cookie from some aspect of the page when a tab is selected.

JavaScript

Much of the code below was inspired by was inspired by Mike A's answer at Stack Overflow. This can be done in JavaScript when document body receives focus or mouseover. Each JSP page can run JavaScript code like the following.

window.addEventListener("load", setListenersForTabId); function setListenersForTabId() { window.addEventListener("focus", setTabIdCookie); document.body.addEventListener("focus", setTabIdCookie); document.body.addEventListener("mouseover", setTabIdCookie); }

This leads to setting the cookie, which only applies to the same site as the page.

function setTabIdCookie() { var strCookie, tabId; tabId = getTabId(); strCookie = '_TabId=' + tabId + '; SameSite=Strict; path=/'; if (window.location.protocol.toLowerCase() == 'https:') { strCookie += ' secure;'; } document.cookie = strCookie; }

Next, the unique tab id. An aspect of a page that always exists and can store arbitrary text is the window name. Use window.top.name in case the page is using frames. This can be created when the page loads if an id hasn't already been set. As a user can't create more than one page at a time, the current datetime is unique.

window.addEventListener("load", loadTabId); function loadTabId() { if (window.top.name == "") { window.top.name = "" + (new Date()).getTime(); setTabIdCookie(); } } function getTabId() { return window.top.name; }

Struts 2 interceptors

An interceptor can inject the tab id if the Action implements an interface, much like ServletRequestAware but reading from cookies. Server side objects can be stored and retrieved with name combined with the tab id.

/** * Interface for Struts 2 actions that accept a browser tab id. */ public interface BrowserTabAware { public String getTabId(); public void setTabId(String value); }

The interceptor is pretty much like ServletConfigInterceptor.

/** * If the action implements BrowserTabAware, sets tab id from cookies. The requesting web page must be set up for this * to be correct. See {@link ServletLibrary#getBrowserTabId}. * * <H3>Interceptor parameters</H3> * <P>None</P> * * <H3>Extending the interceptor</H3> * <P>This can't be usefully extended.</P> * * <H3>Example code</H3> * <PRE> * @InterceptorRefs({ * @InterceptorRef(value="browserTab"), * @InterceptorRef(value="defaultStack") *}) * </PRE> */ public class BrowserTabInterceptor extends AbstractInterceptor { /** * Sets tab id of Action read from user agent's cookies. */ private void setBrowserTabId(BrowserTabAware browserTabAware, ActionInvocation invocation) { HttpServletRequest request; Cookie[] cookies; String cookieValue; try { request = (HttpServletRequest)invocation.getInvocationContext().get(StrutsStatics.HTTP_REQUEST); cookies = request.getCookies(); } catch (Exception e) { cookies = null; } cookieValue = getBrowserTabId(cookies); cookieValue = (cookieValue != null)?cookieValue:""; browserTabAware.setTabId(cookieValue); } @Override public String intercept(ActionInvocation invocation) throws Exception { String result; if (invocation.getAction() instanceof BrowserTabAware) { setBrowserTabId((BrowserTabAware)invocation.getAction(), invocation); } result = invocation.invoke(); return result; } }

Tab id for new tabs

When the user creates a new tab, the server receives the cookie of the last tab that generated a tab id because cookies are shared between all tabs. Thus, the server cannot tell if the user is using a new or existing tab. Only client side code - the Javascript code shown above - can know by checking the top window name. A new tab must be opened with a JSP page running the code above, or a viewer Action that ignores the tab id and forwards to such a page. A landing page can achieve this.

Requirements and limitations

In conclusion, the code shown above is a working solution but has requirements and limitations.

  • Actions must implement a new interface but this can be implemented in a Template class like ActionSupport.
  • Actions must distinguish between shared and tab-specific session data. This is unavoidable as users expect some data, such as authentication and authorisation, to be shared between tabs.
  • Actions must use session attribute names incorporating the tab id. This may become the subject of a future article.
  • New tabs must open a JSP page setting the tab id or an Action that ignores tab id till dispatches to such a JSP page.
  • A page opened from a link in a new tab fails when used. See Opening a link in a new tab above.

Next part

Continued in Injected Private Session.