[
https://issues.apache.org/jira/browse/MYFACES-4778?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Thomas Andraschko resolved MYFACES-4778.
----------------------------------------
Resolution: Fixed
> 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
> Assignee: Thomas Andraschko
> Priority: Major
> Fix For: 5.0.0, 4.1.5
>
>
> {{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)