You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@tapestry.apache.org by hl...@apache.org on 2007/05/17 23:48:45 UTC

svn commit: r539131 - in /tapestry/tapestry5/trunk/tapestry-core/src: main/java/org/apache/tapestry/corelib/components/ main/java/org/apache/tapestry/internal/services/ main/java/org/apache/tapestry/internal/structure/ main/java/org/apache/tapestry/int...

Author: hlship
Date: Thu May 17 14:48:44 2007
New Revision: 539131

URL: http://svn.apache.org/viewvc?view=rev&rev=539131
Log:
TAPESTRY-1356: Implement client-side field persistence

Added:
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorage.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImpl.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStrategy.java
    tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/ClientPersistenceDemo.html
    tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/app1/pages/ClientPersistenceDemo.java
    tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImplTest.java
Modified:
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/corelib/components/Form.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ComponentInvocation.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/InternalModule.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/PersistentFieldChangeImpl.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ServicesMessages.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/InternalComponentResourcesImpl.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/StructureMessages.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/test/InternalBaseTestCase.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/services/TapestryModule.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/test/TapestryTestCase.java
    tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/services/ServicesStrings.properties
    tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/structure/StructureStrings.properties
    tapestry/tapestry5/trunk/tapestry-core/src/site/apt/guide/persist.apt
    tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/Start.html
    tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/IntegrationTests.java

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/corelib/components/Form.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/corelib/components/Form.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/corelib/components/Form.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/corelib/components/Form.java Thu May 17 14:48:44 2007
@@ -42,6 +42,7 @@
 import org.apache.tapestry.annotations.Persist;
 import org.apache.tapestry.corelib.mixins.RenderInformals;
 import org.apache.tapestry.dom.Element;
+import org.apache.tapestry.internal.TapestryInternalUtils;
 import org.apache.tapestry.internal.services.HeartbeatImpl;
 import org.apache.tapestry.internal.util.Base64ObjectInputStream;
 import org.apache.tapestry.internal.util.Base64ObjectOutputStream;
@@ -334,9 +335,11 @@
 
             String actionsBase64 = _request.getParameter(FORM_DATA);
 
+            ObjectInputStream ois = null;
+
             try
             {
-                ObjectInputStream ois = new Base64ObjectInputStream(actionsBase64);
+                ois = new Base64ObjectInputStream(actionsBase64);
 
                 while (true)
                 {
@@ -350,11 +353,15 @@
             }
             catch (EOFException ex)
             {
-                // Expected.
+                // Expected
             }
             catch (Exception ex)
             {
                 throw new RuntimeException(ex);
+            }
+            finally
+            {
+                TapestryInternalUtils.close(ois);
             }
 
             heartbeat.end();

Added: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorage.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorage.java?view=auto&rev=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorage.java (added)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorage.java Thu May 17 14:48:44 2007
@@ -0,0 +1,33 @@
+// Copyright 2007 The Apache Software Foundation
+//
+// 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.tapestry.internal.services;
+
+import org.apache.tapestry.Link;
+import org.apache.tapestry.services.PersistentFieldChange;
+import org.apache.tapestry.services.PersistentFieldStrategy;
+
+/**
+ * Describes an object that can store {@link PersistentFieldChange}s, and manage a query parameter
+ * stored into a {@link Link} to maining this data across requests.
+ */
+public interface ClientPersistentFieldStorage extends PersistentFieldStrategy
+{
+    /**
+     * Updates a link, adding a query parameter to it (if necessary) to store
+     * 
+     * @param link
+     */
+    void updateLink(Link link);
+}

Added: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImpl.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImpl.java?view=auto&rev=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImpl.java (added)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImpl.java Thu May 17 14:48:44 2007
@@ -0,0 +1,263 @@
+// Copyright 2007 The Apache Software Foundation
+//
+// 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.tapestry.internal.services;
+
+import static org.apache.tapestry.ioc.IOCConstants.PERTHREAD_SCOPE;
+import static org.apache.tapestry.ioc.internal.util.CollectionFactory.newMap;
+
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import org.apache.tapestry.Link;
+import org.apache.tapestry.internal.TapestryInternalUtils;
+import org.apache.tapestry.internal.util.Base64ObjectInputStream;
+import org.apache.tapestry.internal.util.Base64ObjectOutputStream;
+import org.apache.tapestry.ioc.annotations.Scope;
+import org.apache.tapestry.ioc.internal.util.CollectionFactory;
+import org.apache.tapestry.services.PersistentFieldChange;
+import org.apache.tapestry.services.Request;
+
+/**
+ * Manages client-persistent values on behalf of a {@link ClientPersistentFieldStorageImpl}. Some
+ * effort is made to ensure that we don't uncessarily convert between objects and Base64 (the
+ * encoding used to record the value on the client).
+ */
+@Scope(PERTHREAD_SCOPE)
+public class ClientPersistentFieldStorageImpl implements ClientPersistentFieldStorage
+{
+    static final String PARAMETER_NAME = "t:state:client";
+
+    private static class Key implements Serializable
+    {
+        private static final long serialVersionUID = -2741540370081645945L;
+
+        private final String _pageName;
+
+        private final String _componentId;
+
+        private final String _fieldName;
+
+        Key(final String pageName, final String componentId, final String fieldName)
+        {
+            _pageName = pageName;
+            _componentId = componentId;
+            _fieldName = fieldName;
+        }
+
+        public boolean matches(String pageName)
+        {
+            return _pageName.equals(pageName);
+        }
+
+        public PersistentFieldChange toChange(Object value)
+        {
+            return new PersistentFieldChangeImpl(_componentId == null ? "" : _componentId,
+                    _fieldName, value);
+        }
+
+        @Override
+        public int hashCode()
+        {
+            final int PRIME = 31;
+
+            int result = 1;
+
+            result = PRIME * result + ((_componentId == null) ? 0 : _componentId.hashCode());
+
+            // _fieldName and _pageName are never null
+
+            result = PRIME * result + _fieldName.hashCode();
+            result = PRIME * result + _pageName.hashCode();
+
+            return result;
+        }
+
+        @Override
+        public boolean equals(Object obj)
+        {
+            if (this == obj) return true;
+            if (obj == null) return false;
+            if (getClass() != obj.getClass()) return false;
+            final Key other = (Key) obj;
+
+            // _fieldName and _pageName are never null
+
+            if (!_fieldName.equals(other._fieldName)) return false;
+            if (!_pageName.equals(other._pageName)) return false;
+
+            if (_componentId == null)
+            {
+                if (other._componentId != null) return false;
+            }
+            else if (!_componentId.equals(other._componentId)) return false;
+
+            return true;
+        }
+    }
+
+    private final Map<Key, Object> _persistedValues = newMap();
+
+    private String _clientData;
+
+    private boolean _mapUptoDate = false;
+
+    public ClientPersistentFieldStorageImpl(Request request)
+    {
+        String value = request.getParameter(PARAMETER_NAME);
+
+        // MIME can encode to a '+' character; the browser converts that to a space; we convert it
+        // back.
+
+        _clientData = value == null ? null : value.replace(' ', '+');
+    }
+
+    public void updateLink(Link link)
+    {
+        refreshClientData();
+
+        if (_clientData != null) link.addParameter(PARAMETER_NAME, _clientData);
+    }
+
+    public Collection<PersistentFieldChange> gatherFieldChanges(String pageName)
+    {
+        refreshMap();
+
+        if (_persistedValues.isEmpty()) return Collections.emptyList();
+
+        Collection<PersistentFieldChange> result = CollectionFactory.newList();
+
+        for (Map.Entry<Key, Object> e : _persistedValues.entrySet())
+        {
+            Key key = e.getKey();
+
+            if (key.matches(pageName)) result.add(key.toChange(e.getValue()));
+        }
+
+        return result;
+    }
+
+    public void postChange(String pageName, String componentId, String fieldName, Object newValue)
+    {
+        refreshMap();
+
+        Key key = new Key(pageName, componentId, fieldName);
+
+        if (newValue == null)
+            _persistedValues.remove(key);
+        else
+        {
+            if (!Serializable.class.isInstance(newValue))
+                throw new IllegalArgumentException(ServicesMessages
+                        .clientStateMustBeSerializable(newValue));
+
+            _persistedValues.put(key, newValue);
+        }
+
+        _clientData = null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private void refreshMap()
+    {
+        if (_mapUptoDate) return;
+
+        // Parse the client data to form the map.
+
+        restoreMapFromClientData();
+
+        _mapUptoDate = true;
+    }
+
+    private void restoreMapFromClientData()
+    {
+        _persistedValues.clear();
+
+        if (_clientData == null) return;
+
+        ObjectInputStream in = null;
+
+        try
+        {
+            in = new Base64ObjectInputStream(_clientData);
+
+            int count = in.readInt();
+
+            for (int i = 0; i < count; i++)
+            {
+                Key key = (Key) in.readObject();
+                Object value = in.readObject();
+
+                _persistedValues.put(key, value);
+            }
+        }
+        catch (Exception ex)
+        {
+            throw new RuntimeException(ServicesMessages.corruptClientState(), ex);
+        }
+        finally
+        {
+            TapestryInternalUtils.close(in);
+        }
+    }
+
+    private void refreshClientData()
+    {
+        // Client data will be null after a change to the map, or if there was no client data in the
+        // request. In any other case where the client data is non-null, it is by definition
+        // up-to date (since it is reset to null any time there's a change to the map).
+
+        if (_clientData != null) return;
+
+        // Very typical: we're refreshing the client data but haven't created the map yet, and there
+        // was no value in the request. Leave it as null.
+
+        if (!_mapUptoDate) return;
+
+        // Null is also appropriate when the persisted values are empty.
+
+        if (_persistedValues.isEmpty()) return;
+
+        // Otherwise, time to update _clientData from _persistedValues
+
+        Base64ObjectOutputStream os = null;
+
+        try
+        {
+            os = new Base64ObjectOutputStream();
+
+            os.writeInt(_persistedValues.size());
+
+            for (Map.Entry<Key, Object> e : _persistedValues.entrySet())
+            {
+                os.writeObject(e.getKey());
+                os.writeObject(e.getValue());
+            }
+
+        }
+        catch (Exception ex)
+        {
+            throw new RuntimeException(ex.getMessage(), ex);
+        }
+        finally
+        {
+            TapestryInternalUtils.close(os);
+        }
+
+        _clientData = os.toBase64();
+    }
+}

Added: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStrategy.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStrategy.java?view=auto&rev=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStrategy.java (added)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ClientPersistentFieldStrategy.java Thu May 17 14:48:44 2007
@@ -0,0 +1,57 @@
+// Copyright 2007 The Apache Software Foundation
+//
+// 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.tapestry.internal.services;
+
+import java.util.Collection;
+
+import org.apache.tapestry.Link;
+import org.apache.tapestry.services.PersistentFieldChange;
+import org.apache.tapestry.services.PersistentFieldStrategy;
+
+/**
+ * Implements simple client-persistent properties. Most of the logic is delegated to an instance of
+ * {@link ClientPersistentFieldStorage}. This division of layer allows this service to be a true
+ * singleton, and a listener to the {@link LinkFactory}, and allow per-request state to be isolated
+ * inside the other service.
+ */
+public class ClientPersistentFieldStrategy implements PersistentFieldStrategy, LinkFactoryListener
+{
+    private final ClientPersistentFieldStorage _storage;
+
+    public ClientPersistentFieldStrategy(ClientPersistentFieldStorage storage)
+    {
+        _storage = storage;
+    }
+
+    public Collection<PersistentFieldChange> gatherFieldChanges(String pageName)
+    {
+        return _storage.gatherFieldChanges(pageName);
+    }
+
+    public void postChange(String pageName, String componentId, String fieldName, Object newValue)
+    {
+        _storage.postChange(pageName, componentId, fieldName, newValue);
+    }
+
+    public void createdActionLink(Link link)
+    {
+        _storage.updateLink(link);
+    }
+
+    public void createdPageLink(Link link)
+    {
+        _storage.updateLink(link);
+    }
+}

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ComponentInvocation.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ComponentInvocation.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ComponentInvocation.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ComponentInvocation.java Thu May 17 14:48:44 2007
@@ -15,6 +15,7 @@
 package org.apache.tapestry.internal.services;
 
 import static org.apache.tapestry.ioc.internal.util.CollectionFactory.newMap;
+import static org.apache.tapestry.ioc.internal.util.Defense.notBlank;
 
 import java.util.List;
 import java.util.Map;
@@ -65,8 +66,7 @@
     public String buildURI(boolean isForm)
     {
         String path = getPath();
-        if (isForm || _parameters == null)
-            return path;
+        if (isForm || _parameters == null) return path;
 
         StringBuilder builder = new StringBuilder();
 
@@ -124,8 +124,10 @@
 
     public void addParameter(String parameterName, String value)
     {
-        if (_parameters == null)
-            _parameters = newMap();
+        notBlank(parameterName, "parameterName");
+        notBlank(value, "value");
+
+        if (_parameters == null) _parameters = newMap();
 
         if (_parameters.containsKey(parameterName))
             throw new IllegalArgumentException(ServicesMessages.parameterNameMustBeUnique(

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/InternalModule.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/InternalModule.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/InternalModule.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/InternalModule.java Thu May 17 14:48:44 2007
@@ -32,10 +32,8 @@
 import org.apache.tapestry.internal.bindings.LiteralBinding;
 import org.apache.tapestry.internal.bindings.PropBindingFactory;
 import org.apache.tapestry.internal.util.IntegerRange;
-import org.apache.tapestry.ioc.Configuration;
 import org.apache.tapestry.ioc.Location;
 import org.apache.tapestry.ioc.MappedConfiguration;
-import org.apache.tapestry.ioc.ObjectLocator;
 import org.apache.tapestry.ioc.ObjectProvider;
 import org.apache.tapestry.ioc.OrderedConfiguration;
 import org.apache.tapestry.ioc.ServiceBinder;
@@ -43,14 +41,12 @@
 import org.apache.tapestry.ioc.annotations.InjectService;
 import org.apache.tapestry.ioc.annotations.Scope;
 import org.apache.tapestry.ioc.annotations.Symbol;
-import org.apache.tapestry.ioc.internal.util.InternalUtils;
 import org.apache.tapestry.ioc.services.ChainBuilder;
 import org.apache.tapestry.ioc.services.ClassFactory;
 import org.apache.tapestry.ioc.services.PropertyAccess;
 import org.apache.tapestry.ioc.services.ThreadCleanupHub;
 import org.apache.tapestry.ioc.services.ThreadLocale;
 import org.apache.tapestry.ioc.services.TypeCoercer;
-import org.apache.tapestry.services.AliasContribution;
 import org.apache.tapestry.services.ApplicationGlobals;
 import org.apache.tapestry.services.ApplicationInitializer;
 import org.apache.tapestry.services.ApplicationInitializerFilter;
@@ -61,6 +57,7 @@
 import org.apache.tapestry.services.ComponentMessagesSource;
 import org.apache.tapestry.services.Context;
 import org.apache.tapestry.services.ObjectRenderer;
+import org.apache.tapestry.services.PersistentFieldStrategy;
 import org.apache.tapestry.services.PropertyConduitSource;
 import org.apache.tapestry.services.Request;
 import org.apache.tapestry.services.RequestExceptionHandler;
@@ -88,22 +85,7 @@
         binder.bind(RequestExceptionHandler.class, DefaultRequestExceptionHandler.class);
         binder.bind(PageLinkHandler.class, PageLinkHandlerImpl.class);
         binder.bind(ResourceStreamer.class, ResourceStreamerImpl.class);
-    }
-
-    @SuppressWarnings("unchecked")
-    private static void add(Configuration<AliasContribution> configuration, ObjectLocator locator,
-            Class... serviceInterfaces)
-    {
-        for (Class serviceInterface : serviceInterfaces)
-        {
-            String name = serviceInterface.getName();
-            String serviceId = InternalUtils.lastTerm(name);
-
-            AliasContribution contribution = AliasContribution.create(serviceInterface, locator
-                    .getService(serviceId, serviceInterface));
-
-            configuration.add(contribution);
-        }
+        binder.bind(ClientPersistentFieldStorage.class, ClientPersistentFieldStorageImpl.class);
     }
 
     public static void contributeTemplateParser(MappedConfiguration<String, URL> configuration)
@@ -534,5 +516,16 @@
                 checkInterval), "before:*");
 
         configuration.add("Localization", new LocalizationFilter(localizationSetter));
+    }
+
+    public PersistentFieldStrategy buildClientPersistentFieldStrategy(LinkFactory linkFactory,
+            ServiceResources resources)
+    {
+        ClientPersistentFieldStrategy service = resources
+                .autobuild(ClientPersistentFieldStrategy.class);
+
+        linkFactory.addListener(service);
+
+        return service;
     }
 }

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/PersistentFieldChangeImpl.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/PersistentFieldChangeImpl.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/PersistentFieldChangeImpl.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/PersistentFieldChangeImpl.java Thu May 17 14:48:44 2007
@@ -1,4 +1,4 @@
-// Copyright 2006 The Apache Software Foundation
+// Copyright 2006, 2007 The Apache Software Foundation
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,42 +12,44 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package org.apache.tapestry.internal.services;
-
-import org.apache.tapestry.ioc.internal.util.Defense;
-import org.apache.tapestry.services.PersistentFieldChange;
-
-public class PersistentFieldChangeImpl implements PersistentFieldChange
-{
-    private final String _componentId;
-
-    private final String _fieldName;
-
-    private final Object _value;
-
-    public PersistentFieldChangeImpl(final String componentId, final String fieldName,
-            final Object value)
-    {
-        Defense.notNull(componentId, "componentId");
-        Defense.notBlank(fieldName, "fieldName");
-
-        _componentId = componentId;
-        _fieldName = fieldName;
-        _value = value;
-    }
-
-    public String getComponentId()
-    {
-        return _componentId;
-    }
-
-    public String getFieldName()
-    {
-        return _fieldName;
-    }
-
-    public Object getValue()
-    {
-        return _value;
-    }
-}
+package org.apache.tapestry.internal.services;
+
+import static org.apache.tapestry.ioc.internal.util.Defense.notBlank;
+import static org.apache.tapestry.ioc.internal.util.Defense.notNull;
+
+import org.apache.tapestry.services.PersistentFieldChange;
+
+public class PersistentFieldChangeImpl implements PersistentFieldChange
+{
+    private final String _componentId;
+
+    private final String _fieldName;
+
+    private final Object _value;
+
+    public PersistentFieldChangeImpl(final String componentId, final String fieldName,
+            final Object value)
+    {
+        notNull(componentId, "componentId");
+        notBlank(fieldName, "fieldName");
+
+        _componentId = componentId;
+        _fieldName = fieldName;
+        _value = value;
+    }
+
+    public String getComponentId()
+    {
+        return _componentId;
+    }
+
+    public String getFieldName()
+    {
+        return _fieldName;
+    }
+
+    public Object getValue()
+    {
+        return _value;
+    }
+}

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ServicesMessages.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ServicesMessages.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ServicesMessages.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/services/ServicesMessages.java Thu May 17 14:48:44 2007
@@ -378,4 +378,14 @@
     {
         return MESSAGES.format("field-injection-error", className, fieldName, cause);
     }
+
+    static String clientStateMustBeSerializable(Object newValue)
+    {
+        return MESSAGES.format("client-state-must-be-serializable", newValue);
+    }
+
+    static String corruptClientState()
+    {
+        return MESSAGES.get("corrupt-client-state");
+    }
 }

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/InternalComponentResourcesImpl.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/InternalComponentResourcesImpl.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/InternalComponentResourcesImpl.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/InternalComponentResourcesImpl.java Thu May 17 14:48:44 2007
@@ -171,7 +171,17 @@
 
     public void persistFieldChange(String fieldName, Object newValue)
     {
-        _element.persistFieldChange(this, fieldName, newValue);
+        try
+        {
+            _element.persistFieldChange(this, fieldName, newValue);
+        }
+        catch (Exception ex)
+        {
+            throw new TapestryException(StructureMessages.fieldPersistFailure(
+                    getCompleteId(),
+                    fieldName,
+                    ex), getLocation(), ex);
+        }
     }
 
     public void bindParameter(String parameterName, Binding binding)

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/StructureMessages.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/StructureMessages.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/StructureMessages.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/structure/StructureMessages.java Thu May 17 14:48:44 2007
@@ -95,4 +95,9 @@
     {
         return MESSAGES.format("duplicate-block", component.getCompleteId(), blockId);
     }
+
+    static String fieldPersistFailure(String componentId, String fieldName, Throwable cause)
+    {
+        return MESSAGES.format("field-persist-failure", componentId, fieldName, cause);
+    }
 }

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/test/InternalBaseTestCase.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/test/InternalBaseTestCase.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/test/InternalBaseTestCase.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/internal/test/InternalBaseTestCase.java Thu May 17 14:48:44 2007
@@ -489,11 +489,6 @@
         expect(factory.createPageLink(page)).andReturn(link);
     }
 
-    protected final void train_getParameter(Request request, String elementName, String value)
-    {
-        expect(request.getParameter(elementName)).andReturn(value).atLeastOnce();
-    }
-
     protected final void train_isLoaded(InternalComponentResources resources, boolean isLoaded)
     {
         expect(resources.isLoaded()).andReturn(isLoaded);

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/services/TapestryModule.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/services/TapestryModule.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/services/TapestryModule.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/services/TapestryModule.java Thu May 17 14:48:44 2007
@@ -256,15 +256,15 @@
      * A few of the built in services overlap in terms of service interface so we make contributions
      * to the Alias service to disambiguate. This ensures that a bare parameter (without an
      * InjectService annotation) will chose the correct value without being further qualified.
-     * <ul>
-     * <li>{@link ComponentEventResultProcessor}: the master ComponentEventResultProcessor service
+     * <dl>
+     * <dt>{@link ComponentEventResultProcessor} <dd> the master ComponentEventResultProcessor service
      * (rather than one of the other services that exist to handle a specific type of result)</li>
-     * <li>{@link ObjectRenderer}: the master ObjectRenderer service (rather than the one of the
+     * <dt>{@link ObjectRenderer} <dd> the master ObjectRenderer service (rather than the one of the
      * other services that renders a specific type of object)</li>
-     * <li>{@link ClassFactory}: the <em>ComponentClassFactory</em> (which will be recreated if
+     * <dt>{@link ClassFactory} <dd> the <em>ComponentClassFactory</em> (which will be recreated if
      * the component class loader is recreated, on a change to a component class)
-     * <li>{@link DataTypeAnalyzer}: the <em>DefaultDataTypeAnalyzer</em> service
-     * </ul>
+     * <dt>{@link DataTypeAnalyzer} <dd> the <em>DefaultDataTypeAnalyzer</em> service
+     * </dl>
      */
     public static void contributeAlias(Configuration<AliasContribution> configuration,
             ObjectLocator locator,
@@ -1394,15 +1394,27 @@
     }
 
     /**
-     * Contributes the "session" strategy.
+     * Contributes several strategies:
+     * <dl>
+     * <dt>session
+     * <dd>Values are stored in the {@link Session}
+     * <dt>flash
+     * <dd>Values are stored in the {@link Session}, until the next request (for the page)
+     * <dt>client
+     * <dd>Values are encoded into URLs (or hidden form fields)
+     * </dl>
      */
     public void contributePersistentFieldManager(
             MappedConfiguration<String, PersistentFieldStrategy> configuration,
 
-            Request request)
+            Request request,
+
+            @InjectService("ClientPersistentFieldStrategy")
+            PersistentFieldStrategy clientStrategy)
     {
         configuration.add("session", new SessionPersistentFieldStrategy(request));
         configuration.add("flash", new FlashPersistentFieldStrategy(request));
+        configuration.add("client", clientStrategy);
     }
 
     public void contributeValidationMessagesSource(Configuration<String> configuration)

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/test/TapestryTestCase.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/test/TapestryTestCase.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/test/TapestryTestCase.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/java/org/apache/tapestry/test/TapestryTestCase.java Thu May 17 14:48:44 2007
@@ -970,4 +970,9 @@
         expect(locatable.getLocation()).andReturn(location).atLeastOnce();
     }
 
+    protected final void train_getParameter(Request request, String elementName, String value)
+    {
+        expect(request.getParameter(elementName)).andReturn(value).atLeastOnce();
+    }
+
 }

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/services/ServicesStrings.properties
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/services/ServicesStrings.properties?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/services/ServicesStrings.properties (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/services/ServicesStrings.properties Thu May 17 14:48:44 2007
@@ -73,4 +73,7 @@
 request-exception=Processing of request failed with uncaught exception: %s
 component-recursion=The template for component %s is recursive (contains another direct or indirect reference to component %<s). \
   This is not supported (components may not contain themselves).
-field-injection-error=Error obtaining injected value for field %s.%s: %s
\ No newline at end of file
+field-injection-error=Error obtaining injected value for field %s.%s: %s
+client-state-must-be-serializable=State persisted on the client must be serializable, but %s does not implement the Serializable interface.
+corrupt-client-state=Serialized client state was corrupted. \
+  This may indicate that too much state is being stored, which can cause the encoded string to be truncated by the client web browser.
\ No newline at end of file

Modified: tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/structure/StructureStrings.properties
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/structure/StructureStrings.properties?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/structure/StructureStrings.properties (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/main/resources/org/apache/tapestry/internal/structure/StructureStrings.properties Thu May 17 14:48:44 2007
@@ -33,3 +33,4 @@
   Embedded component ids must be unique (excluding case, which is ignored).
 duplicate-block=Component %s already contains a block with id '%s'. \
   Block ids must be unique (excluding case, which is ignored).
+field-persist-failure=Error persisting field %s:%s: %s

Modified: tapestry/tapestry5/trunk/tapestry-core/src/site/apt/guide/persist.apt
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/site/apt/guide/persist.apt?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/site/apt/guide/persist.apt (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/site/apt/guide/persist.apt Thu May 17 14:48:44 2007
@@ -33,9 +33,6 @@
 
   The value for each field is the <strategy> used to store the field between requests.
   
-  Currently, only the default value, "session", is supported. Other implementations, that store
-  the value on the client, are forthcoming.
-  
 * session strategy
 
   The session strategy stores field changes into the session; the session is created as necessary.
@@ -43,6 +40,8 @@
   A suitably long session attribute name is used; it incorporates the
   name of the page, the nested component id, and the name of the field.
   
+  Session strategy is the default strategy used unless otherwise overridden.
+  
 * flash strategy
 
   The flash strategy stores information in the session as well, just for not very long.  Values are
@@ -51,6 +50,23 @@
   
   The flash is typically used to store temporary messages that should only be displayed to the user
   once.
+  
+* client stategy
+
+  The field is persisted onto the client; you will see an additional query parameter in each URL
+  (or an extra hidden field in each form).
+  
+  Client persistence is somewhat expensive.  It can bloat the size of the rendered pages by adding hundreds
+  of characters to each link. There is extra processing on each request to de-serialize the 
+  values encoded into the query parameter.
+  
+  Client persistence does not scale very well; as more information is stored into the query parameter, its
+  length can become problematic. In many cases, web browsers, firewalls or other servers may silently
+  truncate the URL which will break the application.
+  
+  Use client persistence with care, and store a minimal amount of data.  Try to store the identity (that is,
+  primary key) of an object, rather than the object itself.
+ 
   
 Persistence Search
 

Added: tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/ClientPersistenceDemo.html
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/ClientPersistenceDemo.html?view=auto&rev=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/ClientPersistenceDemo.html (added)
+++ tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/ClientPersistenceDemo.html Thu May 17 14:48:44 2007
@@ -0,0 +1,27 @@
+<html t:type="Border" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd">
+
+<h1>Client Persistence Demo</h1>
+
+
+  <p>
+    Persisted value: [${persistedValue}]
+  </p>
+  
+  <p>
+    Session: [${sessionExists}]
+  </p>
+  
+  
+  <p>
+    <t:actionlink t:id="storeString">store string</t:actionlink>
+  </p>
+  
+  <p>
+    <t:actionlink t:id="storeBad">store non-serializable</t:actionlink>
+  </p>
+  
+  <p>
+    <t:pagelink page="clientpersistencedemo">refresh</t:pagelink>
+  </p>
+
+</html>
\ No newline at end of file

Modified: tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/Start.html
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/Start.html?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/Start.html (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/test/app1/WEB-INF/Start.html Thu May 17 14:48:44 2007
@@ -131,6 +131,9 @@
             <t:pagelink page="inheritedbindingsdemo">Inherited Bindings Demo</t:pagelink> --
             Tests for components that inherit bindings from containing components
           </li>
+          <li>
+            <t:pagelink page="ClientPersistenceDemo">Client Persistence Demo</t:pagelink> -- component field values persisted on the client side
+          </li>
         </ul>
       </td>
     </tr>

Modified: tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/IntegrationTests.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/IntegrationTests.java?view=diff&rev=539131&r1=539130&r2=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/IntegrationTests.java (original)
+++ tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/IntegrationTests.java Thu May 17 14:48:44 2007
@@ -913,4 +913,17 @@
                 "Bound: [ value: the-bound-value, bound: true ]",
                 "Unbound: [ value: null, bound: false ]");
     }
+
+    @Test
+    public void client_persistence()
+    {
+        open(BASE_URL);
+        clickAndWait("link=Client Persistence Demo");
+
+        assertTextPresent("Persisted value: []", "Session: [false]");
+
+        clickAndWait("link=store string");
+
+        assertTextPresent("Persisted value: [A String]", "Session: [false]");
+    }
 }

Added: tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/app1/pages/ClientPersistenceDemo.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/app1/pages/ClientPersistenceDemo.java?view=auto&rev=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/app1/pages/ClientPersistenceDemo.java (added)
+++ tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/integration/app1/pages/ClientPersistenceDemo.java Thu May 17 14:48:44 2007
@@ -0,0 +1,53 @@
+// Copyright 2007 The Apache Software Foundation
+//
+// 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.tapestry.integration.app1.pages;
+
+import org.apache.tapestry.annotations.Inject;
+import org.apache.tapestry.annotations.Persist;
+import org.apache.tapestry.services.Request;
+
+public class ClientPersistenceDemo
+{
+    @Persist("client")
+    private Object _persistedValue;
+
+    @Inject
+    private Request _request;
+
+    public Object getPersistedValue()
+    {
+        return _persistedValue;
+    }
+
+    public boolean getSessionExists()
+    {
+        return _request.getSession(false) != null;
+    }
+
+    void onActionFromStoreString()
+    {
+        _persistedValue = "A String";
+    }
+
+    void onActionFromStoreBad()
+    {
+        _persistedValue = new Runnable()
+        {
+            public void run()
+            {
+            }
+        };
+    }
+}

Added: tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImplTest.java
URL: http://svn.apache.org/viewvc/tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImplTest.java?view=auto&rev=539131
==============================================================================
--- tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImplTest.java (added)
+++ tapestry/tapestry5/trunk/tapestry-core/src/test/java/org/apache/tapestry/internal/services/ClientPersistentFieldStorageImplTest.java Thu May 17 14:48:44 2007
@@ -0,0 +1,245 @@
+// Copyright 2007 The Apache Software Foundation
+//
+// 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.tapestry.internal.services;
+
+import static org.apache.tapestry.ioc.internal.util.CollectionFactory.newList;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.isA;
+
+import java.util.List;
+
+import org.apache.tapestry.Link;
+import org.apache.tapestry.internal.util.Holder;
+import org.apache.tapestry.services.PersistentFieldChange;
+import org.apache.tapestry.services.Request;
+import org.apache.tapestry.test.TapestryTestCase;
+import org.easymock.EasyMock;
+import org.easymock.IAnswer;
+import org.testng.annotations.Test;
+
+public class ClientPersistentFieldStorageImplTest extends TapestryTestCase
+{
+    @Test
+    public void no_client_data_in_request()
+    {
+        Request request = mockRequest(null);
+        Link link = mockLink();
+
+        replay();
+
+        ClientPersistentFieldStorage storage = new ClientPersistentFieldStorageImpl(request);
+
+        // Should do nothing.
+
+        storage.updateLink(link);
+
+        verify();
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void store_and_restore_a_change()
+    {
+        Request request = mockRequest(null);
+        Link link = mockLink();
+        final Holder<String> holder = Holder.create();
+
+        String pageName = "Foo";
+        String componentId = "bar.baz";
+        String fieldName = "biff";
+        Object value = 99;
+
+        // Use an IAnswer to capture the value.
+
+        link.addParameter(eq(ClientPersistentFieldStorageImpl.PARAMETER_NAME), isA(String.class));
+        getMocksControl().andAnswer(new IAnswer<Void>()
+        {
+            public Void answer() throws Throwable
+            {
+                String base64 = (String) EasyMock.getCurrentArguments()[1];
+
+                holder.put(base64);
+
+                return null;
+            }
+        });
+
+        replay();
+
+        ClientPersistentFieldStorage storage1 = new ClientPersistentFieldStorageImpl(request);
+
+        storage1.postChange(pageName, componentId, fieldName, value);
+
+        List<PersistentFieldChange> changes1 = newList(storage1.gatherFieldChanges(pageName));
+
+        storage1.updateLink(link);
+
+        verify();
+
+        System.out.println(holder.get());
+
+        assertEquals(changes1.size(), 1);
+        PersistentFieldChange change1 = changes1.get(0);
+
+        assertEquals(change1.getComponentId(), componentId);
+        assertEquals(change1.getFieldName(), fieldName);
+        assertEquals(change1.getValue(), value);
+
+        // Now more training ...
+
+        train_getParameter(request, ClientPersistentFieldStorageImpl.PARAMETER_NAME, holder.get());
+
+        replay();
+
+        ClientPersistentFieldStorage storage2 = new ClientPersistentFieldStorageImpl(request);
+
+        List<PersistentFieldChange> changes2 = newList(storage2.gatherFieldChanges(pageName));
+
+        verify();
+
+        assertEquals(changes2.size(), 1);
+        PersistentFieldChange change2 = changes2.get(0);
+
+        assertEquals(change2.getComponentId(), componentId);
+        assertEquals(change2.getFieldName(), fieldName);
+        assertEquals(change2.getValue(), value);
+
+        assertNotSame(change1, change2);
+    }
+
+    @Test
+    public void multiple_changes()
+    {
+        Request request = mockRequest(null);
+        Link link = mockLink();
+
+        String pageName = "Foo";
+        String componentId = "bar.baz";
+
+        link.addParameter(eq(ClientPersistentFieldStorageImpl.PARAMETER_NAME), isA(String.class));
+
+        replay();
+
+        ClientPersistentFieldStorage storage = new ClientPersistentFieldStorageImpl(request);
+
+        for (int k = 0; k < 3; k++)
+        {
+            for (int i = 0; i < 20; i++)
+            {
+                // Force some cache collisions ...
+
+                storage.postChange(pageName, componentId, "field" + i, i * k);
+            }
+        }
+
+        storage.postChange(pageName, null, "field", "foo");
+        storage.postChange(pageName, null, "field", "bar");
+
+        storage.updateLink(link);
+
+        verify();
+    }
+
+    @Test
+    public void null_value_is_a_remove()
+    {
+        Request request = mockRequest(null);
+        Link link = mockLink();
+
+        String pageName = "Foo";
+        String componentId = "bar.baz";
+        String fieldName = "woops";
+
+        replay();
+
+        ClientPersistentFieldStorage storage = new ClientPersistentFieldStorageImpl(request);
+
+        storage.postChange(pageName, componentId, fieldName, 99);
+        storage.postChange(pageName, componentId, fieldName, null);
+
+        storage.updateLink(link);
+
+        assertTrue(storage.gatherFieldChanges(pageName).isEmpty());
+
+        verify();
+    }
+
+    @Test
+    public void value_not_serializable()
+    {
+        Request request = mockRequest(null);
+
+        Object badBody = new Object()
+        {
+            @Override
+            public String toString()
+            {
+                return "<BadBoy>";
+            }
+        };
+
+        replay();
+
+        ClientPersistentFieldStorage storage = new ClientPersistentFieldStorageImpl(request);
+
+        try
+        {
+            storage.postChange("Foo", "bar.baz", "woops", badBody);
+            unreachable();
+        }
+        catch (IllegalArgumentException ex)
+        {
+            assertEquals(
+                    ex.getMessage(),
+                    "State persisted on the client must be serializable, but <BadBoy> does not implement the Serializable interface.");
+        }
+
+        verify();
+    }
+
+    @Test
+    public void corrupt_client_data()
+    {
+        // A cut-n-paste from some previous output, with the full value significantly truncated.
+        Request request = mockRequest("H4sIAAAAAAAAAEWQsUoDQRCGJxdDTEwRU2hlZ71pBQ");
+
+        replay();
+
+        ClientPersistentFieldStorage storage = new ClientPersistentFieldStorageImpl(request);
+
+        try
+        {
+            storage.postChange("Foo", "bar.baz", "woops", 99);
+            unreachable();
+        }
+        catch (RuntimeException ex)
+        {
+            assertEquals(ex.getMessage(), ServicesMessages.corruptClientState());
+            assertNotNull(ex.getCause());
+        }
+
+        verify();
+    }
+
+    protected final Request mockRequest(String clientData)
+    {
+        Request request = mockRequest();
+
+        train_getParameter(request, ClientPersistentFieldStorageImpl.PARAMETER_NAME, clientData);
+
+        return request;
+    }
+
+}