Bauke Scholtz created MYFACES-4778:
--------------------------------------
Summary: UIData and UIRepeat restore per-row state which is stale
or shared with an earlier restore
Key: MYFACES-4778
URL: https://issues.apache.org/jira/browse/MYFACES-4778
Project: MyFaces Core
Issue Type: Bug
Affects Versions: 4.1.5
Reporter: Bauke Scholtz
{{UIData}} and {{UIRepeat}} restore per-row state which does not belong to the
view being restored, in two cases. Both are covered by {{Issue6129IT}} of the
Jakarta Faces 5.0 TCK
([jakartaee/faces#2266|https://github.com/jakartaee/faces/pull/2266]), and
MyFaces 4.1.5-SNAPSHOT fails both tests on an equivalent page. Mojarra had both
bugs too, and fixed them in 4.0.30 and 4.1.21
([eclipse-ee4j/mojarra#6129|https://github.com/eclipse-ee4j/mojarra/issues/6129]).
h3. 1. With rowStatePreserved="true", the local value of a row is kept after
Update Model Values
With {{rowStatePreserved="true"}}, an {{<h:dataTable>}} or {{<ui:repeat>}}
keeps the local value of every row input in its saved row state, also after a
postback which passed validation and updated the model. That stale local value
then hides every later change of the model in that row, as long as the row is
not executed again.
h4. Reproducer
{code:xml}
<h:form id="form">
<h:dataTable id="preserved" value="#{bean.preserved}" var="i"
rowStatePreserved="true">
<h:column><h:inputText id="in" value="#{i.name}" /></h:column>
</h:dataTable>
<ui:repeat id="repeated" value="#{bean.repeated}" var="i"
rowStatePreserved="true">
<h:inputText id="in" value="#{i.name}" />
</ui:repeat>
<h:dataTable id="plain" value="#{bean.plain}" var="i">
<h:column><h:inputText id="in" value="#{i.name}" /></h:column>
</h:dataTable>
<h:commandButton id="submit" value="Submit" />
<h:commandButton id="change" value="Change">
<f:ajax execute="@this" render="@form" listener="#{bean.change}" />
</h:commandButton>
</h:form>
{code}
{code:java}
@Named
@ViewScoped
public class Bean implements Serializable {
private List<Item> preserved = List.of(new Item("original a"), new
Item("original b"));
private List<Item> repeated = List.of(new Item("original e"), new
Item("original f"));
private List<Item> plain = List.of(new Item("original c"), new
Item("original d"));
public void change(AjaxBehaviorEvent event) {
preserved.forEach(item -> item.setName("changed"));
repeated.forEach(item -> item.setName("changed"));
plain.forEach(item -> item.setName("changed"));
}
// Getters, and Item with a name property.
}
{code}
# Type a new value in every input and click Submit. Validation passes, and the
model holds the typed values.
# Click Change. The ajax request sets the name of every item to {{changed}} and
re-renders the form. It does not execute the tables.
h4. Expected
All six inputs show {{changed}}.
h4. Actual
The {{plain}} table shows {{changed}}. The {{preserved}} table and the
{{repeated}} repeat still show the typed values, although the model holds
{{changed}}.
Reproduced on MyFaces 4.1.5-SNAPSHOT on Tomcat 11, with server side state
saving. The view state after step 1 shows the cause: the {{rowDeltaStates}} of
the table still hold {{value}} and {{localValueSet = true}} for each row input.
h4. Cause
With {{rowStatePreserved="true"}},
{{UIData.setRowIndexPreserveComponentState()}} saves the state of a row when
the table leaves that row. {{child.saveState()}} returns the difference between
the current state and the initial state, so this is a delta:
{code:java}
Map<String, Object> sm = saveFullDescendantComponentStates(facesContext, null,
getChildren().iterator(), false);
if (sm != null && !sm.isEmpty())
{
_rowDeltaStates.put(getContainerClientId(facesContext), sm);
}
{code}
The delta is only stored when it is not empty, and an entry is never removed:
# Process Validations leaves a local value in each row input. When the table
leaves the row, the delta holds {{value}} and {{localValueSet}}, and it is
stored.
# Update Model Values clears the local value. The input is back in its initial
state.
# When the table leaves the row again, the delta is empty. It is not stored,
but the entry of step 1 is not removed either.
So the entry of step 1 is saved with the view. Every later request restores
that local value into the row, and there it takes precedence over the model.
{{UIRepeat}} has the same {{if (sm != null && !sm.isEmpty())}} when it saves a
row. Both are the same on {{main}}.
h4. Spec
The javadoc of {{UIInput.updateModel()}}:
{quote}
If the {{setValue()}} method returns successfully:
* Clear the local value of this {{UIInput}}.
* Set the {{localValueSet}} property of this {{UIInput}} to false.
{quote}
The restore puts back a local value which Update Model Values has cleared.
The javadoc of {{UIData.setRowStatePreserved()}}:
{quote}
To accomplish this, {{UIData}} must call {{StateHolder#saveState}} and
{{TransientStateHolder#saveTransientState}} on its children to capture their
state on exiting each row. When re-entering the row,
{{StateHolder#restoreState}} and {{TransientStateHolder#restoreTransientState}}
must be called in order to reinitialize the children to the correct state for
the new row.
{quote}
The restored state is the one of an earlier exit of the row, not of the last
one.
h4. Proposed fix
When the table leaves a row and the delta of that row is empty, remove the
entry of the row from {{_rowDeltaStates}}. The same in {{UIRepeat}}.
{code:java}
if (sm != null && !sm.isEmpty())
{
_rowDeltaStates.put(getContainerClientId(facesContext), sm);
}
else
{
_rowDeltaStates.remove(getContainerClientId(facesContext));
}
{code}
TCK: {{Issue6129IT.testRowStatePreservedDoesNotKeepLocalValueAfterUpdateModel}}.
h3. 2. Two restores of one saved state share the per-row values
With server side state saving, the saved state is kept in the session without
serializing it, as {{jakarta.faces.SERIALIZE_SERVER_STATE}} is {{false}} by
default. A request which restores that saved state changes the per-row values
in it. A later request which restores the same saved state again, such as after
the back button or in a second tab, then sees the per-row values of the request
before.
h4. Reproducer
{code:xml}
<h:form id="form">
<h:inputText id="required" value="#{bean.required}" required="true"
immediate="true" />
<h:dataTable id="table" value="#{bean.tableRows}" var="row">
<h:column><h:inputText id="in" value="#{row.name}" /></h:column>
</h:dataTable>
<ui:repeat id="repeat" value="#{bean.repeatRows}" var="row">
<h:inputText id="in" value="#{row.name}" />
</ui:repeat>
<h:commandButton id="submit" value="Submit" />
<h:commandButton id="refresh" value="Refresh">
<f:ajax execute="required" render="@form" />
</h:commandButton>
</h:form>
{code}
{code:java}
@Named
@ViewScoped
public class Bean implements Serializable {
private List<Row> tableRows = List.of(new Row("zero"), new Row("one"));
private List<Row> repeatRows = List.of(new Row("zero"), new Row("one"));
private String required;
// Getters and setters, and Row with a name property.
}
{code}
# Type {{first}} in row 0 of the table and of the repeat, leave the required
input empty, and click Submit. Validation fails. Keep the
{{jakarta.faces.ViewState}} value of this response.
# Type {{second}} in row 1 of the table and of the repeat, and click Submit.
Validation fails again.
# Put the {{jakarta.faces.ViewState}} value of step 1 back into the hidden
field, as the back button or a second tab would do, and click Refresh.
h4. Expected
The state of step 1: row 0 shows {{first}}, and row 1 shows {{one}}.
h4. Actual
Row 1 of the table and of the repeat shows {{second}}, the value of step 2.
Reproduced on MyFaces 4.1.5-SNAPSHOT on Tomcat 11, with server side state
saving.
h4. Cause
{{UIData.saveState()}} saves its per-row maps {{_rowDeltaStates}},
{{_rowStates}} and {{_rowTransientStates}} through
{{UIComponentBase.saveAttachedState()}}. A {{HashMap}} is no {{StateHolder}}
and no {{Collection}}, so {{saveAttachedState()}} returns it as it is:
{code:java}
else if (attachedObject instanceof Serializable)
{
return attachedObject;
}
{code}
{{UIData.restoreState()}} then takes the restored map as its own field, for
example {{_rowStates = (Map<String, List<EditableValueHolderState>>) rs;}}.
Without serialization, that is the instance in the saved state. When the table
saves the state of a row, it changes that map in place:
{code:java}
if (states != null)
{
_rowStates.put(key, states);
}
else
{
_rowStates.remove(key);
}
{code}
So step 2 changes the saved state of step 1. {{UIRepeat}} saves and restores
its {{_rowStates}} and {{_rowDeltaStates}} in the same way. Client side state
saving, and server side state saving with
{{jakarta.faces.SERIALIZE_SERVER_STATE}} set to {{true}}, are not affected,
because each restore then gets its own deserialized copy.
h4. Spec
No clause states that a saved state must stay as it was saved. These are the
clauses which a shared per-row state goes against.
The javadoc of {{UIData.setRowIndex()}}:
{quote}
To restore current state information for all descendant components, {{UIData}}
must reference its previously stored information for the current {{rowIndex}}
and call setters for each descendant as follows:
{quote}
After step 2, the restore of step 3 references the information which step 2
stored, not the information which was stored with the view state of step 1.
The specification, section "State Saving Alternatives and Implications", on the
{{server}} state saving method:
{quote}
Cause the saved state to be stored on the server in between requests. [...]
Serializing the view in server mode is optional but must be possible by setting
the _context-param jakarta.faces.SERIALIZE_SERVER_STATE_ to _true_.
{quote}
With that context parameter set to {{true}}, nothing is shared. So the result
of a restore depends on an option which the specification describes as optional
for serializing, not as a change of behavior.
h4. Proposed fix
Copy the per-row maps in {{restoreState()}} of {{UIData}} and {{UIRepeat}}, for
example {{_rowStates = new HashMap<>((Map<String,
List<EditableValueHolderState>>) rs);}}. The values in these maps are replaced
when a row is saved, not changed in place, so a copy of the outer map is enough.
TCK:
{{Issue6129IT.testSecondRestoreOfSavedStateDoesNotSeeChangesOfFirstRestore}}.
🤖 Generated with Claude Opus 5.5
--
This message was sent by Atlassian Jira
(v10.3.26#10030026)