diff --git a/impl/src/main/java/jakarta/faces/component/ComponentStateHelper.java b/impl/src/main/java/jakarta/faces/component/ComponentStateHelper.java index aa25a74d72..aa1ab0b28a 100644 --- a/impl/src/main/java/jakarta/faces/component/ComponentStateHelper.java +++ b/impl/src/main/java/jakarta/faces/component/ComponentStateHelper.java @@ -354,15 +354,15 @@ public void restoreState(FacesContext context, Object state) { put(serializable, entry.getKey(), entry.getValue()); } } else if (value instanceof List) { - if (defaultMap != null) { - defaultMap.remove(serializable); + List current = (List) get(serializable); + for (Object item : (List) value) { + if (current == null || !current.contains(item)) { + add(serializable, item); + if (current == null) { + current = (List) get(serializable); + } + } } - if (deltaMap != null) { - deltaMap.remove(serializable); - } - - List values = (List) value; - values.stream().forEach(o -> add(serializable, o)); } else { put(serializable, value); handleAttribute(serializable.toString(), value); diff --git a/impl/src/test/java/jakarta/faces/component/UIComponentBaseTestCase.java b/impl/src/test/java/jakarta/faces/component/UIComponentBaseTestCase.java index a88ebf34b1..f0820e5377 100644 --- a/impl/src/test/java/jakarta/faces/component/UIComponentBaseTestCase.java +++ b/impl/src/test/java/jakarta/faces/component/UIComponentBaseTestCase.java @@ -447,6 +447,39 @@ public void testAttributesThatAreSetStateHolder() throws Exception { assertEquals(Arrays.asList("attr2"), c.getAttributes().get("jakarta.faces.component.UIComponentBase.attributesThatAreSet")); } + /** + * The {@code attributesThatAreSet} delta list only carries entries added after + * {@code markInitialState()}. On restore it must therefore be merged onto the list that + * {@code buildView} already rebuilt for this request, not replace it. Replacing dropped every + * attribute the tag handlers re-registered before the delta was applied — e.g. a + * {@code styleClass} rendered on the first postback but gone from the second onward (issue #5880), + * triggered by any {@code setValueExpression(name, ve)} with a non-literal {@code ve} on a + * restored component, such as from a {@code PostRestoreStateEvent} listener. + */ + @Test + public void testAttributesThatAreSetMergeOnRestore() throws Exception { + request.setAttribute("foo", "bar"); + + // buildView registers a tag attribute; a post-restore binding then adds a non-literal one. + ComponentTestImpl c = new ComponentTestImpl(); + c.getAttributes().put("styleClass", "foo bar"); + c.markInitialState(); + c.setValueExpression("label", application.getExpressionFactory().createValueExpression(facesContext.getELContext(), "#{foo}", String.class)); + assertEquals(Arrays.asList("styleClass", "label"), c.getAttributes().get("jakarta.faces.component.UIComponentBase.attributesThatAreSet")); + + Object state = c.saveState(facesContext); // delta carries only [label] + + // Next postback: buildView rebuilds styleClass before the delta is restored on top of it. + c = new ComponentTestImpl(); + c.getAttributes().put("styleClass", "foo bar"); + c.markInitialState(); + c.pushComponentToEL(facesContext, c); + c.restoreState(facesContext, state); + c.popComponentFromEL(facesContext); + + assertEquals(Arrays.asList("styleClass", "label"), c.getAttributes().get("jakarta.faces.component.UIComponentBase.attributesThatAreSet")); + } + @Test public void testValueExpressions() throws Exception {