Programming Thoughts
Struts 2 - Server State per Browser Tab
Copying Private Session to New Tab

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

Users expect pages in different tabs to not interfere with each other's session data, even if they're the same page but for different records. The first attempt describes a workable but flawed solution. This article attempts to fix these flaws.

Opening a link in a new tab

Whenever the user right-clicks on a link to open it in a new tab, the server, and the viewer Struts Action, receives the tab id of the existing tab and cannot know the user is opening a new tab. Thus, when the server dispatches to a JSP page, it must detect it doesn't have a tab id, set a new one, and reload. This will send the new tab id in a cookie and re-invoke the viewer Struts Action. The Action won't have server side data linked to the new tab id, so must be told the old tab id to find and copy it.

Worse, the page might have navigation to other pages showing related records, such as a product details page navigating back to a page of product search results. What the user expects must be considered.

Page purposeUser expectationAction
Menu entry Access page as normal. None. Any existing authentication and authorisation state will be shared session state.
Record detail View and edit record and any associated records. Refer to record displayed in old page. If the record is from a search results list, a new list must be created containing just that record.
Record detail View and edit record and any associated records, navigate owning, record list. Shallow copy of list used by old page.

The menu entry is not a problem and can be ignored. For the second and third entries, this requires browser code to set the old cookie value as well as the new one when it reloads. The old cookie value must be erased at other times.

JavaScript

Browser code is based on that described in Programming Thoughts, Struts 2: Server State per Browser Tab. Replace setListenersForTabId and setTabIdCookie with the following.

window.addEventListener("load", tabStartup); function deleteOldTabIdCookie() { document.cookie = "_OldTabId=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/"; } function getUrlExtension(url) { return url.split(/[#?]/)[0].split('.').pop().trim(); } function setOldTabIdCookie(oldTabId) { var strCookie; strCookie = '_OldTabId=' + oldTabId + '; SameSite=Strict; path=/'; if (window.location.protocol.toLowerCase() == 'https:') { strCookie += ' secure;'; } document.cookie = strCookie; } function setTabIdCookie() { var strCookie, tabId; deleteOldTabIdCookie(); tabId = getTabId(); strCookie = '_TabId=' + tabId + '; SameSite=Strict; path=/'; if (window.location.protocol.toLowerCase() == 'https:') { strCookie += ' secure;'; } document.cookie = strCookie; } function tabStartup() { var cookieValue; deleteOldTabIdCookie(); if (window.top.name == "") { window.top.name = "" + (new Date()).getTime(); cookieValue = getCookie("_TabId"); setTabIdCookie(); if (cookieValue != "" && !urlHasNoServerState(location.toString())) { setOldTabIdCookie(cookieValue); location.reload(); } } window.addEventListener("focus", setTabIdCookie); document.body.addEventListener("focus", setTabIdCookie); document.body.addEventListener("mouseover", setTabIdCookie); } function urlHasNoServerState(url) { var extension; extension = getUrlExtension(url); return extension == 'html' || extension == 'jsp'; }

Struts 2 interceptors

The use of BrowserTabSessionImpl from Part 1 makes it easy to find all tab-specific attributes for an old tab id and as most collections are cloneable, creating a shallow copy is easy. Alas, real world applications tend to have odd requirements and it's bad design to not accommodate exceptional behaviour. This means the recipient Action must be able to override how attributes are copied or whether they are copied.

Further, use of Cloneable is discouraged in favour of copy constructors, as explained by one of the designers of Java at Josh Bloch on Design. As not every object has a copy constructor, use of Cloneable is the fallback option. Failing that, the reference to the attribute value must be copied, creating a shared object, allowing Actions to interfere with each other's session state but it's better than crashing over unexpectedly missing attributes.

Copy constructors and cloning can also fail at run-time, leaving the Action with missing attributes. Handling this is application specific, so the Action must receive notification of failures.

The injection interface from Part 1 becomes the following, with the new copyAttributesFromOldTabToNewTab function allowing attribute copying to be overridden and attributes to be ignored, whilst handleAttributeFailures handles any copying failures.

/** * Interface for Struts 2 actions that accept a browser tab id. */ public interface BrowserTabAware2 { /** * May be overridden to manually copy or clone tab-specific attributes from the page of an old tab to a new tab * whenever the user opens a hyperlink in a new browser tab. Any attributes not copied and their names not added to * attributeIgnores will be automatically copied or cloned later. * * @param oldBrowserTabSession Session used by old tab. * @param newBrowserTabSession Session used by new tab. * @param attributeIgnores Add names of attributes that should not be automatically copied or cloned. * @param attributeFailures Add names of attributes where manual copying or cloning failed. */ default public void copyAttributesFromOldTabToNewTab(BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession, Set<String> attributeIgnores, Set<String> attributeFailures) { // Empty }; /** * May be overridden to handle attributes that failed manual or automatic copying or cloning. * @param oldBrowserTabSession Session used by old tab. * @param newBrowserTabSession Session used by new tab. * @param attributeFailures Names of attributes where copying or cloning failed. */ default public void handleAttributeFailures(BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession, Set<String> attributeFailures) { // Empty } public BrowserTabSession getBrowserTabSession(); public void setBrowserTabSession(BrowserTabSession value); }

The new interceptor from Part 1 reads any old tab id sent by the browser and copies tab-specific attributes from old tab to new tab.

/** * 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> * <DL> * </DL> * * <H3>Extending the interceptor</H3> * <P>The following methods can be overridden :-</P> * <DL> * <DT>attributeCloneAction</DT><DD>Whether to ignore, copy or clone a tab-specific attribute of the old tab.</DD> * <DT>attributeFromOldTabToNewClone</DT><DD>Clones a private, tab-specific attribute from the old tab to the new.</DD> * <DT>attributeFromOldTabToNewCopyConstructor</DT><DD>Copies a private, tab-specific attribute from the old tab to the * new using the copy constructor.</DD> * <DT>attributeFromOldTabToNewCopyReference</DT><DD>Copies reference to a private, tab-specific attribute from the old * tab to the new.</DD> * <DT>attributeFromOldTabToNewIgnore</DT><DD>Notification that a private, tab-specific attribute found in the old tab will not be copied.</DD> * <DT>attributeFromOldTabToNewNull</DT><DD>Copies null value of a private, tab-specific attribute from the old tab to * the new.</DD> * <DT>attributesFromOldTabToNewTab</DT><DD>Copies private, tab-specific attributes from the old tab to the new by * various means.</DD> * </DL> * * <H3>Example code</H3> * <PRE> * @InterceptorRefs({ * @InterceptorRef(value="browserTab2"), * @InterceptorRef(value="defaultStack") *}) * </PRE> */ public class BrowserTabInterceptor2 extends AbstractInterceptor { public enum AttributeCloneAction {CLONE, COPY_CONSTRUCTOR, COPY_REFERENCE, IGNORE, NULL} /** * Returns what to do with existing attributes of an existing page if the user is opening a hyperlink in a new * browser tab. */ protected AttributeCloneAction attributeCloneAction(BrowserTabAware2 browserTabAware, ActionInvocation invocation, String attributeName, Object attributeValue) { Class<?> valueClass; if (attributeValue == null) { return AttributeCloneAction.NULL; } valueClass = attributeValue.getClass(); try { valueClass.getConstructor(valueClass); return AttributeCloneAction.COPY_CONSTRUCTOR; } catch (NoSuchMethodException | SecurityException e) { // Ignore; } if (attributeValue instanceof Cloneable) { return AttributeCloneAction.CLONE; } return AttributeCloneAction.COPY_REFERENCE; } /** * Handles an attribute from the old tab that should be cloned and returns whether it succeeded. */ protected boolean attributeFromOldTabToNewTabClone(BrowserTabAware2 browserTabAware, ActionInvocation invocation, BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession, Entry<String,Object> oldAttributeEntry) { Method cloneMethod; if (oldAttributeEntry.getValue() instanceof Cloneable) { try { cloneMethod = oldAttributeEntry.getValue().getClass().getMethod("clone"); newBrowserTabSession.setAttribute(oldAttributeEntry.getKey(), cloneMethod.invoke(oldAttributeEntry.getValue())); return true; } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { return false; } } else { return false; } } /** * Handles an attribute from the old tab that that should be copied with a copy constructor and returns whether it * succeeded. */ protected boolean attributeFromOldTabToNewTabCopyConstructor(BrowserTabAware2 browserTabAware, ActionInvocation invocation, BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession, Entry<String,Object> oldAttributeEntry) { Class<?> valueClass; Constructor<?> copyConstructor; Object newValue; try { valueClass = oldAttributeEntry.getValue().getClass(); copyConstructor = valueClass.getConstructor(valueClass); newValue = copyConstructor.newInstance(oldAttributeEntry.getValue()); newBrowserTabSession.setAttribute(oldAttributeEntry.getKey(), newValue); return true; } catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { return false; } } /** * Handles an attribute from the old tab that where its reference should be copied and returns whether it succeeded. */ protected boolean attributeFromOldTabToNewTabCopyReference(BrowserTabAware2 browserTabAware, ActionInvocation invocation, BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession, Entry<String,Object> oldAttributeEntry) { newBrowserTabSession.setAttribute(oldAttributeEntry.getKey(), oldAttributeEntry.getValue()); return true; } /** * Handles an attribute from the old tab that should be ignored and returns whether it succeeded, which does nothing. */ protected boolean attributeFromOldTabToNewTabIgnore(BrowserTabAware2 browserTabAware, ActionInvocation invocation, BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession, Entry<String,Object> oldAttributeEntry) { // Does nothing return true; } /** * Handles an attribute from the old tab that that has a null value and returns whether it succeeded. */ protected boolean attributeFromOldTabToNewTabNull(BrowserTabAware2 browserTabAware, ActionInvocation invocation, BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession, Entry<String,Object> oldAttributeEntry) { newBrowserTabSession.setAttribute(oldAttributeEntry.getKey(), oldAttributeEntry.getValue()); return true; } /** * Called when the user's web browser reloads because the page has been opened in a new tab, to copy private, * tab-specific attributes from the old tab to the new. */ protected void attributesFromOldTabToNewTab(BrowserTabAware2 browserTabAware, ActionInvocation invocation, BrowserTabSession oldBrowserTabSession, BrowserTabSession newBrowserTabSession) { AttributeCloneAction attributeCloneAction; Map<String,Object> oldPrivateMap, newPrivateMap; Set<String> attributeFailures, attributeIgnores; String oldAttributeName; boolean success; attributeFailures = new HashSet<>(); attributeIgnores = new HashSet<>(); browserTabAware.copyAttributesFromOldTabToNewTab(oldBrowserTabSession, newBrowserTabSession, attributeIgnores, attributeFailures); oldPrivateMap = oldBrowserTabSession.getPrivateAttributeMap(); newPrivateMap = newBrowserTabSession.getPrivateAttributeMap(); for (Entry<String,Object> oldAttributeEntry: oldPrivateMap.entrySet()) { oldAttributeName = oldAttributeEntry.getKey(); if (!attributeIgnores.contains(oldAttributeName) && !newPrivateMap.containsKey(oldAttributeName)) { attributeCloneAction = attributeCloneAction(browserTabAware, invocation, oldAttributeName, oldAttributeEntry.getValue()); success = true; switch (attributeCloneAction) { case CLONE: success = attributeFromOldTabToNewTabClone(browserTabAware, invocation, oldBrowserTabSession, newBrowserTabSession, oldAttributeEntry); break; case COPY_CONSTRUCTOR: success = attributeFromOldTabToNewTabCopyConstructor(browserTabAware, invocation, oldBrowserTabSession, newBrowserTabSession, oldAttributeEntry); break; case COPY_REFERENCE: success = attributeFromOldTabToNewTabCopyReference(browserTabAware, invocation, oldBrowserTabSession, newBrowserTabSession, oldAttributeEntry); break; case IGNORE: success = attributeFromOldTabToNewTabIgnore(browserTabAware, invocation, oldBrowserTabSession, newBrowserTabSession, oldAttributeEntry); break; case NULL: success = attributeFromOldTabToNewTabNull(browserTabAware, invocation, oldBrowserTabSession, newBrowserTabSession, oldAttributeEntry); break; } if (!success) { attributeFailures.add(oldAttributeName); } } } browserTabAware.handleAttributeFailures(oldBrowserTabSession, newBrowserTabSession, attributeFailures); } @Override public String intercept(ActionInvocation invocation) throws Exception { BrowserTabAware2 browserTabAware; BrowserTabSession browserTabSession, oldBrowserTabSession; HttpServletRequest request; String result; Cookie[] cookies; String cookieValue, oldCookieValue; if (invocation.getAction() instanceof BrowserTabAware2) { request = invocation.getInvocationContext().getServletRequest(); cookies = request.getCookies(); cookieValue = ServletLibrary.getBrowserTabId(cookies); cookieValue = (cookieValue != null)?cookieValue:""; oldCookieValue = ServletLibrary.getBrowserOldTabId(cookies); oldCookieValue = (oldCookieValue != null)?oldCookieValue:""; browserTabSession = new BrowserTabSessionImpl(invocation.getInvocationContext().getServletRequest().getSession(), cookieValue); browserTabAware = (BrowserTabAware2)invocation.getAction(); if (oldCookieValue.length() > 0 && !oldCookieValue.equals(cookieValue)) { oldBrowserTabSession = new BrowserTabSessionImpl(invocation.getInvocationContext().getServletRequest().getSession(), oldCookieValue); attributesFromOldTabToNewTab(browserTabAware, invocation, oldBrowserTabSession, browserTabSession); } browserTabAware.setBrowserTabSession(browserTabSession); } result = invocation.invoke(); return result; } }

Next part

Continued in JavaScript Detecting New Tab.