嵌套 ui:repeat 中的 h:selectOneRadio 分组失效
结论
ui:repeat 支持嵌套,Jakarta Faces 规范也没有把 h:selectOneRadio group 限制在单层 ui:repeat 中。从现象看,问题更可能出在 Jakarta Faces 4.0.4 对“分组单选框 + 嵌套迭代组件 + 完整提交”的生命周期或行状态处理上。现有信息不足以将原因归结为 Long 转换失败或 HTML value 格式错误。
仅凭这个示例,还无法确认它属于 Red Hat 或 Mojarra 的已知缺陷。实际运行的是 Red Hat EAP 提供并维护的实现,最好准备一个最小可复现项目,先在 EAP 当前支持的最新补丁上复测,再通过 Red Hat 支持渠道提交问题。
补丁或修复可用前,可以考虑以下方案:
- 展平数据,只保留一层
ui:repeat。这种方式相对稳妥。 - 把外层循环改为
c:forEach,同时留意它在构建视图阶段执行。 - 暂时使用
<f:ajax execute="@this">规避问题。它会改变提交时机,无法修复完整提交本身。 - 如果不需要完全自定义布局,可以改用单个
h:selectOneRadio配合多个选项。
请求值中的 clientId 是正常的
生成的值可能类似:
myForm:rep:2:addrep:2:sel:9
前半部分标识生成该选项的组件和迭代行,最后的 9 才是选项值。分组内的多个 h:selectOneRadio 共用同一个请求参数名:
myForm:items
实现需要让提交值同时包含组件身份和选项值,才能确定用户选中了哪一个重复行中的单选框。
正常情况下,Faces 实现会在解码阶段识别组件前缀,然后把实际选项值交给转换器。请求值包含 clientId,并不等于 Long 转换器会收到整段字符串。
为什么完整提交没有更新模型
浏览器将参数放入 HTTP 请求,只能确认客户端已经提交。selectedValue 能否更新,还要看 Faces 生命周期是否顺利完成:
Apply Request Values
↓
Process Validations
↓
Update Model Values
↓
Invoke Application
ui:repeat 不会为每一行创建一套永久独立的子组件。它会复用同一组子组件,在遍历各行时切换行索引。嵌套两层 ui:repeat 后,组件的有效 clientId 同时受两个行索引影响。
完整提交需要遍历整个组件树及所有重复行。如果实现在嵌套遍历时没有正确保留分组单选框的 submitted value、行索引或局部状态,选中行解码出的值可能在后续遍历中丢失,也可能无法匹配正确的 f:selectItem。一旦验证失败,或组件没有可供更新的本地值,Update Model Values 阶段就不会写入 pageBean.selectedValue。
同时应检查页面上的 h:messages。验证失败时,action 和 actionListener 也可能不会执行:
<h:messages id="messages" />
为什么加入 f:ajax 后可以工作
可以明确写成:
<h:selectOneRadio
id="sel"
value="#{pageBean.selectedValue}"
group="items"
required="true"
requiredMessage="Select one!">
<f:selectItem itemValue="#{addr.id()}" />
<f:ajax execute="@this" render="messages" />
</h:selectOneRadio>
execute="@this" 只处理触发 AJAX 请求的单选框。Faces 能根据请求中的具体 clientId 定位当前迭代行,不必像完整提交那样处理所有重复行。转换、验证和模型更新也会在这次 AJAX 请求中完成。
等用户再次点击提交按钮时,selectedValue 通常已经由前一次 AJAX 请求写入。也就是说,AJAX 避开了完整遍历中出问题的路径,并不能说明完整提交已经可以正确处理这层嵌套结构。
采用这种方案时还要留意:
- 用户选择单选框后,模型会立即更新。
- 如果 AJAX 请求失败,后续完整提交仍可能触发原问题。
required校验和消息的显示时机可能改变。- 如果业务要求点击提交按钮后才更新模型,这种处理并不完全等价。
推荐方案:将数据展平
如果每个地址都只是同一个单选组中的候选项,可以在 Bean 中准备一份扁平列表:
public record AddressOption(
Long personId,
String personName,
Long addressId,
String street) {
}
页面只保留一层 ui:repeat:
<h:form id="myForm">
<h:messages id="messages" />
<ui:repeat id="options"
value="#{pageBean.addressOptions}"
var="option">
<div>
<h:outputText
value="#{option.personId()}, #{option.personName()}" />
<h:selectOneRadio
id="sel"
value="#{pageBean.selectedValue}"
group="items"
required="true"
requiredMessage="Select one!">
<f:selectItem itemValue="#{option.addressId()}" />
</h:selectOneRadio>
<h:outputLabel
value="#{option.street()}"
for="sel" />
</div>
</ui:repeat>
<h:commandButton
value="Doit"
action="#{pageBean.sayHello}" />
</h:form>
页面仍然使用 ui:repeat 和 group,但避开了嵌套迭代的行状态处理。人员分组等视觉结构可以通过扁平数据中的 personId、CSS 或额外的输出组件实现。
使用 c:forEach 时的注意事项
也可以把外层循环改为在构建视图阶段执行的 c:forEach:
<c:forEach
items="#{pageBean.availableData}"
var="data"
varStatus="status">
<div>
<h:outputText value="#{data.id()}, #{data.name()}" />
<ui:repeat
id="addresses"
value="#{data.addresses()}"
var="addr">
<h:selectOneRadio
id="sel"
value="#{pageBean.selectedValue}"
group="items"
required="true"
requiredMessage="Select one!">
<f:selectItem itemValue="#{addr.id()}" />
</h:selectOneRadio>
<h:outputLabel value="#{addr.street()}" for="sel" />
</ui:repeat>
</div>
</c:forEach>
c:forEach 是 Facelets 标签处理器,不属于运行时迭代组件。它在构建组件树时展开内容,页面运行时只剩一层 ui:repeat,从而避开嵌套迭代的状态问题。
这种方式适用于外层集合在视图生命周期内结构稳定的场景。如果外层集合在回发之间经常增删或排序,构建出的组件树可能与保存的视图状态不一致。数据量较大时,每个外层元素都会生成实际组件,因此组件树也会比双层 ui:repeat 更大。
排查和提交缺陷时应提供的信息
可以用最小项目逐项确认:
- 在页面中加入
h:messages,检查是否出现转换错误或required验证错误。 - 暂时移除
required="true",判断问题来自分组解码还是必填验证。 - 记录
selectedValue的 setter 是否被调用。 - 分别测试单层
ui:repeat、双层ui:repeat、外层c:forEach和f:ajax execute="@this"。 - 记录 EAP 完整版本、补丁级别以及实际的 Faces 实现版本。
jakarta.jakartaee-api:10.0.0只是 API 依赖,无法单独说明服务器使用了哪个实现构建。 - 在 EAP 当前支持的最新补丁上复测。如果问题仍可复现,将最小应用和原始 POST 参数一并提交给 Red Hat。
嵌套 ui:repeat 本身符合语义,但当前运行环境已经出现可重复的生命周期异常。生产代码可以先改用单层 ui:repeat,也可以在合适的场景下谨慎使用 c:forEach。f:ajax 更适合作为临时规避措施。
备注:内容仅供参考。
