PAYATHON 2026

嵌套 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 支持渠道提交问题。

补丁或修复可用前,可以考虑以下方案:

  1. 展平数据,只保留一层 ui:repeat。这种方式相对稳妥。
  2. 把外层循环改为 c:forEach,同时留意它在构建视图阶段执行。
  3. 暂时使用 <f:ajax execute="@this"> 规避问题。它会改变提交时机,无法修复完整提交本身。
  4. 如果不需要完全自定义布局,可以改用单个 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 更大。

排查和提交缺陷时应提供的信息

可以用最小项目逐项确认:

  1. 在页面中加入 h:messages,检查是否出现转换错误或 required 验证错误。
  2. 暂时移除 required="true",判断问题来自分组解码还是必填验证。
  3. 记录 selectedValue 的 setter 是否被调用。
  4. 分别测试单层 ui:repeat、双层 ui:repeat、外层 c:forEach 和 f:ajax execute="@this"。
  5. 记录 EAP 完整版本、补丁级别以及实际的 Faces 实现版本。jakarta.jakartaee-api:10.0.0 只是 API 依赖,无法单独说明服务器使用了哪个实现构建。
  6. 在 EAP 当前支持的最新补丁上复测。如果问题仍可复现,将最小应用和原始 POST 参数一并提交给 Red Hat。

嵌套 ui:repeat 本身符合语义,但当前运行环境已经出现可重复的生命周期异常。生产代码可以先改用单层 ui:repeat,也可以在合适的场景下谨慎使用 c:forEach。f:ajax 更适合作为临时规避措施。

备注:内容仅供参考。