# L02.3 — Estabilidad de listados y estados de consulta (V12) Fecha: 22 de octubre de 2026. Implementación en Activos y adopción equivalente en Recursos. Este documento complementa [L01](auditoria-listados-l01.md), [L02.1](selectores-accesibilidad-l02-1.md), [L02.2](configuracion-columnas-l02-2.md) y el [patrón de listados](patron-de-listados-de-entidades.md). ## 1. Resumen de la implementación El inventario mantiene su instancia de `AssetList`/`EntityTable` desde la primera petición. Búsqueda, cabeceras conocidas, configuración y espacio de paginación permanecen reconocibles. La consulta principal, el catálogo y los datos de red tienen estados y reintentos independientes. Las actualizaciones conservan la caché de TanStack Query y comunican cuándo fallan. No se modifican backend, rutas, autenticación, permisos, dependencias ni vendor. Archivos afectados, agrupados por responsabilidad: | Área | Archivos principales | | --- | --- | | rdx-ui, componentes | `entity-list.tsx`, `entity-list-controls.tsx`, `entity-table-configuration.tsx`, `entity-table-content.tsx`; nuevos `entity-list-status.tsx` y `entity-table-skeleton.tsx`, en `packages/rdx-ui/src/components/`. | | rdx-ui, contrato/pruebas/textos | Nuevo `src/lib/entity-list-state.ts`, `src/entity-list.test.tsx` y `src/locales/{es,en}.json`. | | Activos | `modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts`, nuevo `list/helpers/asset-list-query-state.ts`, `list/ui/inventory-page.tsx`, `list/ui/asset-workstation-action.tsx`, `shared/ui/asset-list.tsx` y `modules/assets/src/common/locales/{es,en}.json`. | | Recursos | `modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts`, nuevo `list/helpers/resource-list-query-state.ts`, `list/ui/resources-page.tsx` y `modules/resources/src/common/locales/{es,en}.json`. | | Pruebas MCP | Nuevo `scripts/list-states-ui.test.py`; actualización de `scripts/picker-ui.test.py` y `scripts/visual-audit-ui.test.py` para contratos vigentes y datos abundantes. | | Documentación | Este informe, `DESIGN.md`, `docs/patron-de-listados-de-entidades.md`, `docs/componentes-de-interfaz.md` y los seguimientos de `docs/auditoria-listados-l01.md` / `docs/auditoria-ui-ux-visual.md`. | ## 2. Diagnóstico de consultas | Consulta / clave real | Datos y necesidad | Disponibilidad, actualización y error | | --- | --- | --- | | `inventoryKeys.company(companyId)` → `["inventory", companyId]` | GET `/api/assets/:companyId`: activos, instantáneas, puestos y relaciones. Imprescindible para filas y selección. | Inicialmente `data` indefinido; refetch conserva datos. Error inicial impide operar sobre filas; error posterior permite consultar la copia anterior. | | `catalogKeys.scope(companyId)` → `["asset-catalog", companyId]` | GET `/api/asset-catalog/:companyId`: plantillas y campos vigentes. Necesario para configurar el catálogo completo y editar/crear. | Independiente del inventario. Las instantáneas permiten consultar filas mientras llega; sin catálogo se explica la limitación y se deshabilitan configuración y alta. Si falla refetch se conservan las definiciones anteriores y se indica su antigüedad. | | `infrastructureKeys.company(companyId)` → `["asset-infrastructure", companyId]` | GET `/api/asset-infrastructure/:companyId`: roles, interfaces, IP y MAC. Accesorio para el inventario. | Pendiente/error no bloquea filas; aviso localizado y reintento propio. Refetch fallido mantiene los últimos datos de red. | | `inventoryKeys.company(companyId)` de Resources → `["resource-inventory", companyId]` | GET `/api/resources/:companyId`: recursos y referencias. | Misma separación entre carga, disponibilidad, actualización y error; conserva contratos de selección URL y diálogo explícito. | | `catalogKeys.scope(companyId)` de Resources → `["resource-catalog", companyId]` | GET `/api/resource-catalog/:companyId`: plantillas de recursos. | No bloquea lectura de instantáneas. Configuración y alta requieren referencias disponibles; el error tiene recuperación propia. | Estas consultas no dependen unas de otras y se habilitan al montar la página con `companyId`. Sedes/personas llegan mediante `lookups` desde la consulta de compañía del padre; no se añaden consultas ni se cambia ese requisito de montaje. Los campos dinámicos se derivan del catálogo y de las instantáneas reales, sin consulta adicional. `useListQuery` mantiene su intervalo de 60 s en primer plano, refetch al recuperar foco/conexión y deduplicación por claves. El cliente real configura `retry: false`: los reintentos añadidos son explícitos y dirigidos a la consulta correspondiente. No cambia la política global. Antes, `AssetInventoryPage` condicionaba `AssetList` a `query.data` y agrupaba el pending del inventario, catálogo e infraestructura. El mensaje genérico «Cargando catálogo…» no identificaba la consulta retenida. En Recursos, el pending/error combinado podía ocultar datos utilizables. ## 3. Modelo de estados adoptado `EntityListState` es una unión discriminada presentacional: `loading`, `ready`, `refreshing`, `unavailable` y `stale`. `entityListState` combina disponibilidad, actividad y fallo; no conoce TanStack Query. Los adaptadores de módulo leen `data`, `isFetching`, `isError` y `error`. | Datos disponibles | Consulta | Presentación | | --- | --- | --- | | No | Pendiente | `loading`, mensaje específico y skeleton | | Sí, incluidos cero registros | Correcta | `ready`; vacío solo si la respuesta válida está vacía | | Sí | En curso | `refreshing`, filas anteriores y aviso discreto | | No | Fallida | `unavailable`, error y reintento recuperable | | Sí | Fallida | `stale`, copia anterior, aviso explícito y reintento | Los errores conservan `retrying` para deshabilitar el reintento duplicado e indicar actividad. No se guardan DTO, marcas temporales ni estados de consulta duplicados en React. Se opta por explicar «últimos datos disponibles» sin introducir una hora ni duplicar `dataUpdatedAt`. ## 4. Cambios de composición Activos monta siempre `AssetList`, sin keys dependientes de la consulta. Su controlador deriva el conjunto legible de la caché; Resources aplica la misma regla. `EntityTable` conserva el hook de tabla y acepta `state`, `onRetry` y `columnsDisabled` opcionales. Los consumidores legacy, incluidos PickerList y selectores remotos, mantienen sus contratos existentes. Las páginas deciden permisos, dependencias y operaciones; rdx-ui solo recibe textos y callbacks. `useContextualDetail`, presupuestos de contenedor, navegación y `EntityNotFound` se conservan. Un error transitorio no confirma que haya desaparecido una selección. Una respuesta correcta que la retire sigue utilizando el contrato de desaparición existente. ## 5. Carga inicial La búsqueda y ordenación de columnas conocidas funcionan durante la espera; sus valores se mantienen cuando llegan los datos. Diez placeholders como máximo reservan una página plausible, con las clases de las columnas reales. Son `aria-hidden`, sin nombres de entidades, selección, links ni botones de acción. No se anuncian diez registros ni se muestra un total cero inventado. Paginación/tamaño de página permanecen visibles pero deshabilitados hasta disponer de datos. El catálogo pendiente tiene un marcador decorativo y un mensaje contextual cuando hay filas. Las pestañas y columnas solo utilizan definiciones/instantáneas realmente conocidas. La alta espera a inventario y catálogo válidos; los datos de red pendientes no bloquean el inventario. ## 6. Refetch y datos anteriores «Actualizando activos…»/«Updating assets…» sustituye el resumen en su banda habitual, sin spinner modal, skeleton completo ni toast por cada intervalo. Las filas y herramientas no se desmontan. Se conservan búsqueda, plantilla, orden, página válida, preferencias, selección, scroll y foco durante la petición. El refetch normal del catálogo/red no añade bandas que muevan el listado. ## 7. Error y recuperación El error inicial y el posterior aparecen en la banda de estado, fuera del ancho desplazable de las columnas. Así, el mensaje y Reintentar no requieren scroll horizontal en móvil. La tabla conserva cabeceras y espacio corporal, sin simular registros. Un 503 posterior comunica que se muestran datos anteriores; permite búsqueda y consulta. Alta/edición y operaciones sensibles se restringen cuando su consulta necesaria no es válida; el borrador ya abierto se conserva ante un error transitorio. Los reintentos de catálogo/red no invalidan indiscriminadamente otras consultas. Una denegación `InventoryApiError` con `FORBIDDEN` oculta los datos en caché del inventario y muestra falta de permiso sin prometer recuperación mediante Reintentar. Se conserva la navegación a otros destinos. No se altera la autorización del servidor. ## 8. Estados vacíos La respuesta válida sin activos conserva «Esta compañía todavía no tiene activos.» y su alta de cabecera, cuando las referencias están listas. Cero coincidencias conserva la búsqueda y «Limpiar filtros». Un error no utiliza ninguno de esos mensajes. `—` sigue reservado para valores ausentes/controles sin valor conocido; no sustituye los estados de consulta. ## 9. Componentes compartidos - `lib/entity-list-state.ts`: unión y derivación pura, incluida compatibilidad legacy. - `EntityListStatus`: estado, mensaje y recuperación con Alert/Button existentes; destino de foco opcional `retryFocus`, sin DTO, query keys ni permisos. - `EntityTableSkeleton`: placeholders decorativos con geometría de columnas conocidas. - `EntityTable`: instancia estable y estado explícito; `EntityTableConfiguration` permite deshabilitar la configuración dependiente; `EntityListPagination` distingue total desconocido. Adopción nueva limitada a los inventarios de Activos/Recursos con layout `content`. No se convierte ningún selector remoto en tabla local ni se generalizan estados de consultas ajenas. ## 10. Accesibilidad La carga inicial tiene un único `role=status`; los skeletons no participan en el foco. La tabla señala `aria-busy` durante carga/refetch normal. El refetch periódico no genera anuncios live repetidos. Los errores utilizan el Alert existente y texto explícito, además del color. Los controles pendientes tienen explicación contextual en es/en. Reintentar funciona con Enter y enfoca la búsqueda persistente antes de iniciar la recuperación; así su desaparición no deja el foco en `body`. El destino se aporta desde el consumidor. Las animaciones nuevas respetan `prefers-reduced-motion`. Estas comprobaciones no acreditan cumplimiento WCAG completo ni sustituyen pruebas con lector de pantalla. ## 11. Resultados responsive La matriz de carga inicial comprende 1920×1080, 1440×900, 1366×768, 1024×768, 768×1024, 390×844 y 320×640. Se comprueba estructura, búsqueda habilitada, paginación pendiente, placeholders sin controles y ausencia de overflow horizontal global. La tabla conserva su scroll horizontal local. Paneles siguen los mínimos V01.3 (1144 px) y V01.4 (984 px). La línea base retenida antes del cambio tenía **cero tablas y cero controles de búsqueda y configuración**, tanto a 1440×900 como a 320×640. La corrección conserva una tabla y sus herramientas. Las medidas completas, incluidas posiciones, se guardan junto a las capturas. En la prueba de refetch a 1920×1080 se usan página 2, orden descendente, selección contextual, columnas opcionales y scroll real: horizontal 120 px y vertical 150 px. Durante la espera se conservan ambos scrolls, foco e identidad DOM; la cabecera de tabla permanece en y=230 px. El aviso de fallo posterior ocupa espacio adicional (26 px en ese escenario), sin desmontar filas ni reiniciar scroll/selección. No se promete layout idéntico ante mensajes de error. ## 12. Pruebas | Comprobación ejecutada | Resultado real | | --- | --- | | `pnpm --filter @repo/rdx-ui typecheck`, `@metis/assets typecheck`, `@metis/resources typecheck` | Correctas las tres comprobaciones dirigidas. | | `pnpm --filter @repo/rdx-ui test` | 11/11 ficheros; el fichero de listado ejecutado directamente obtiene 27/27 casos. | | `pnpm --filter @metis/assets test` / `@metis/resources test` | 27/27 y 11/11 ficheros. | | `pnpm exec biome check ` / `pnpm exec eslint ` | Correctos, sin diagnósticos nuevos. | | `pnpm typecheck` | Falla en `packages/shadcn-ui/src/components/calendar.tsx:165`, TS2375 de `locale` con `exactOptionalPropertyTypes`; mismo fallo vendor previo. La interrupción global no sustituye los typechecks dirigidos. | | `pnpm lint` | Falla: 15 errores, un warning y cuatro infos preexistentes. | | `pnpm check` | Falla: 168 errores, un warning y cuatro infos preexistentes. | | `pnpm test` | Falla: 7/8 tareas correctas; dos expectativas SSR antiguas de Compañías (`Personas (1)` y el mensaje de acceso de persona). Ejecutada contra otra MariaDB temporal `metis_desk_test`, sin utilizar la BD del navegador. | | `pnpm build` | Correcto, 2/2 tareas; persiste el aviso de chunk mayor de 500 kB. | | MCP, L02.3 | 7/7 métodos principales + 1/1 adicional de plantilla/contraste/reintento móvil, **ocho escenarios automatizados distintos correctos**. | | MCP, regresiones seleccionadas | 9/9: navegación móvil/teclado/rutas, capacidad del shell y Activos, contraste, asistentes, foco, capacidad de Recursos y edición anidada. | | MCP, L02.2 | 8/8 métodos correctos. | | MCP, L02.1 | 8/8 métodos correctos tras actualizar las expectativas del configurador L02.2. | Se ejecutan **33 métodos MCP distintos**; las repeticiones de diagnóstico no incrementan ese total. Las pruebas unitarias nuevas cubren ocho combinaciones de consulta, compatibilidad legacy, estructura inicial, ausencia de registros ficticios, datos anteriores y distinción entre error, vacío válido y filtros en es/en. `scripts/list-states-ui.test.py` intercepta exclusivamente GET del entorno sintético: retención y liberación inicial/refetch, 503 inicial/posterior, reintento real, catálogo/red independientes, 403, respuesta vacía válida, filtros, edición conservada, movimiento reducido y traducción. No cambia opciones internas de QueryClient para simular los estados; la reconexión controlada dispara el mecanismo normal del consumidor. Ejecutar con la sesión privada activa del preparador, sin escribir su contenido ni guardarla en Git. El procedimiento de [Playwright MCP](playwright-mcp.md) explica cómo obtenerla: ```bash METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \ python3 scripts/list-states-ui.test.py ``` La suite contiene ya los ocho métodos. La ejecución documentada separó el octavo, incorporado al final, de los siete primeros. Para las regresiones se seleccionaron los nueve métodos indicados, no los 28 del fichero entero. La consola final de estas regresiones y la de columnas no registra errores/warnings. En las simulaciones L02.3 aparecen los 503 deliberados del GET interceptado; no se clasifican como fallos espontáneos. ## 13. Evidencias Directorio local ignorado: `.artifacts/playwright/l02-3/`. Contiene baseline `before/`, capturas, snapshots, geometría, foco, consola y escrituras bloqueadas en `after/`, y logs de validación. Los errores HTTP 503/403 se provocan deliberadamente y no representan incidencias espontáneas. El índice distingue ejecuciones intermedias y finales. Las imágenes no se versionan ni se incluyen sesiones privadas; su entrega fuera de esta instalación requiere compartir esos PNG/mediciones de forma explícita. Índice local: `.artifacts/playwright/l02-3/indice-evidencias.md`. Capturas representativas abiertas: baseline retenido 1440, carga nueva 1440, error inicial 320, error posterior 1920, catálogo fallido 1440 y reintento visible 320. El contraste real del texto obtiene 4,83:1 en carga y 10,01:1 en error sobre sus fondos computados; son dos muestras concretas, no una auditoría integral de contraste. Se reutiliza `scripts/visual-audit.py`, login real y usuario ordinario: una única instalación propia desechable, exclusivamente `metis_desk_test`, con **15 compañías**, y una principal con **50 personas, 51 activos y 66 recursos** mediante el preparador de volumen existente. La sesión anterior encontrada devolvía 401 y no se reutilizó ni se eliminaron servicios ajenos. Al terminar se cierra el preparador propio: salida correcta, servicios temporales retirados y comprobación explícita de que su `session.json` ya no existe. Las capturas se conservan sin sesión. ## 14. Regresiones Las suites de columnas L02.2, selectores L02.1 y los casos seleccionados de `visual-audit-ui.test.py` comprueban navegación móvil/portátil, presupuestos de inventario, detalle de Recursos, contraste de selección, asistentes y retorno de foco. Se preservan visibilidad persistente, IDs de campos y acciones ancladas. Los tests anteriores deben esperar datos disponibles explícitamente: la existencia de la tabla ya no implica que acabó el GET. Las primeras ejecuciones detectaron supuestos obsoletos en pruebas: un único botón Editar, etiquetas DOM sin la marca Obligatoria, `selection` configurable y todos los destinos visibles sin scroll con quince compañías. Se actualizan las expectativas al contrato L02.2 y al volumen real, usando nombre accesible para la identidad protegida y comprobando que el foco hace visible Inicio en el menú desplazable. No se cambia el shell para hacer pasar esas expectativas. Los instrumentos nuevos también se corrigieron antes de la ejecución final: esperar promesas de Locator, limitar Reintentar a su listado (hay otros observadores de la misma consulta) y esperar las compuertas en el contexto Node del MCP. Se conservan sus logs fallidos intermedios. ## 15. Limitaciones No se acredita rendimiento de producción, otros motores, lectores de pantalla ni acceso a datos reales. La sesión y el navegador son efímeros. Las interceptaciones no prueban una caída real del backend. Compañía/lookups conserva su carga exterior; otros listados legacy no adoptan automáticamente el nuevo estado. Siguen vigentes los fallos globales preexistentes de calendario vendor, lint/check y dos expectativas SSR de Compañías; no se corrigen fuera de alcance. Skills leídas: `ux-tables` principal; `ux-empty-states`, `ux-error-recovery`, `ux-microcopy`, `shadcn`, `vercel-composition-patterns` y `vercel-react-best-practices` complementan estados, mensajes, primitivas reales, composición y derivación sin efectos. Prevalecen DESIGN y contratos de Metis. Los auditores `*-audit` y `docs/orchestration-policy.md` referidos por algunas skills no están instalados/disponibles; no se instalan ni se afirma haberlos ejecutado. ## 16. Pendientes de L03 y L04 V05 (restauración entre rutas) y V07 (normalización general de búsqueda) permanecen pendientes. V08 sigue resuelto en los escenarios L02.2, sin reordenación ni densidades nuevas. L03 conserva su alcance futuro; L04 podrá valorar adopciones de estado en otros consumidores, sin refactor masivo de consultas ni paginación/virtualización nuevas en esta entrega. **V12 queda resuelto en los escenarios descritos y ejecutados.** No se cambia el estado de otros hallazgos ni se declara que todos los listados del monorepo adopten este contrato.