fix(frontend): harden inline resource drawers

This commit is contained in:
rcourtman
2026-08-25 17:27:14 +01:00
parent f5eb87437a
commit 389ff2825e
16 changed files with 328 additions and 59 deletions
@@ -4301,8 +4301,10 @@ workload, and infrastructure inline drawers; callers may pass row-specific
`data-*` attributes, colspan, and content classes, but they must not recreate
the surface-alt detail row shell locally. The content shell must clip
horizontal paint below the large breakpoint without becoming a scroll
container, then restore visible overflow for the static desktop layout, so
long operator-state copy cannot escape the shared row border.
container, reset the parent table's `whitespace-nowrap` inheritance, and allow
its descendants to shrink, then restore visible overflow for the static
desktop layout. Long operator-state copy must wrap inside the shared row border
instead of painting beneath adjacent controls or disappearing at the clip edge.
Inline detail section content is registry-backed separately from the row shell.
`DetailSectionTable`, `InlineDetailPanel`, and `detailSectionModel.ts` own
detail row compaction, section-table rendering, value-tone classes, and the
@@ -4593,7 +4595,10 @@ When those workspace tabs need an embedded control-bar treatment, they should
still stay on the one canonical `frontend-modern/src/components/shared/Subtabs.tsx`
primitive and reuse the established shell, list, and button class pattern
already proven on owning surfaces like operations rather than introducing new
variant APIs on the primitive.
variant APIs on the primitive. When that rail overflows on phone widths,
`Subtabs` owns visible, accessible edge-scroll controls and keeps them in sync
with native scrolling and rail resize; callers must not add drawer-local arrow
overlays or leave clipped tab labels as the only overflow cue.
The search-input enhancement surfaces now follow that same owner split.
`frontend-modern/src/components/shared/SearchInputEnhancements.tsx` stays the
render shell, `frontend-modern/src/components/shared/useSearchInputEnhancements.ts`
@@ -1943,7 +1943,11 @@ owners preserve native scrollbar geometry with measured spacers, project wheel
and vertical touch movement before native scroll advances, and retain a
directional runway around the viewport. A feature may choose a smaller bounded
budget, but it must not restore an unbounded `<For>` over estate-sized data or
show a blank viewport while the keyed window catches up.
show a blank viewport while the keyed window catches up. `PlatformWindowedRows`
also reconciles unique string/number resource IDs into stable row proxies, so
sparse live snapshots update fields without remounting an open inline drawer
or discarding its tab and form state. Rows without a unique logical ID retain
reference-keyed rendering rather than paying for guessed index identity.
The workload guest-row path now follows the same pattern: the render shell
stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed
cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`,
@@ -2119,7 +2123,10 @@ it may retain total capacity for explanatory copy, but it emits no numeric
memory segments, percentage, or fit calculation that could make unknown usage
look like zero. Platform tables and resource-detail mappers consume the same
canonical unavailable state instead of recomputing `total - free` in render
hot paths.
hot paths. The shell keeps its fitted percentage/capacity label on a
semi-opaque surface scrim with base-content contrast so segment colors cannot
make the small readout illegible; this remains CSS presentation only and adds
no measurement or per-row runtime work.
The dashboard metric bar now follows that same pattern: the shell stays in
`frontend-modern/src/components/Workloads/MetricBar.tsx`, while width,
show-label, sublabel-fit, and threshold-color derivation live in
@@ -2222,7 +2222,9 @@ capability-gated. By default the resource follows the tenant Patrol mode.
Enabling the optional limit requires selecting exact capabilities and may add a
recurring daily time/timezone window; copy must state that the control narrows
the tenant mode, while `NeverAutoRemediate` disables automatic action for the
resource.
resource. At phone widths, the collapsed maintenance-window action stacks
below its explanatory copy so the button cannot squeeze policy text into a
narrow side column; desktop retains the compact side-by-side layout.
The UI must never infer eligibility from capability names, severity, or the
human approval floor.
+27 -15
View File
@@ -1,21 +1,30 @@
{
"version": 1,
"base_sha": "f18f15bf7b1fbdf1078bf412754c059694c8b996",
"verified_at": "2026-08-25T15:58:23Z",
"base_sha": "f5eb87437a396048959a851584d3104326e0d671",
"verified_at": "2026-08-25T16:41:20Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/UpdateBanner.tsx",
"frontend-modern/src/components/whatsNewModel.ts"
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx",
"frontend-modern/src/components/Workloads/StackedMemoryBar.tsx",
"frontend-modern/src/components/shared/InlineDetailTableRow.tsx",
"frontend-modern/src/components/shared/Subtabs.tsx",
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx"
],
"content_sha256": {
"frontend-modern/src/components/UpdateBanner.tsx": "455a8546b5b98c4541993ea7f894ad352c87a29392ede0da2a562e05f85b09de",
"frontend-modern/src/components/whatsNewModel.ts": "d81c8ae4fcc59cff06a63b4a7407a97848acd934f6b6e04535059dc89f652471"
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "1a01f6e6c8db585c32b37afae33ed7b6671dc76e40e8f49c364ee78659e8a961",
"frontend-modern/src/components/Workloads/StackedMemoryBar.tsx": "3266fa75221643199d41b7a87f8e07181e643171c744ac0e32e2ac9c6773e3bf",
"frontend-modern/src/components/shared/InlineDetailTableRow.tsx": "0e169eb088c0bb0754dda5936b52baecc102f1a50853263517d76931729a1673",
"frontend-modern/src/components/shared/Subtabs.tsx": "5106f14ec387c72bd2d3014200678f342e28b04d5ff372aa95410695fe0dc544",
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx": "3f5d02c126379a44803bea3efcc803965d872eb188ec7e0305c973a82fa80924"
},
"routes": ["/release-notes-verification.html"],
"routes": [
"/truenas/overview",
"/kubernetes/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
"width": 1280,
"height": 720
},
{
"width": 390,
@@ -23,13 +32,16 @@
}
],
"states": [
"expanded update banner with a v6.4.0-rc.2 customer-facing What's improved section",
"desktop banner rendered both improvement bullets and excluded the Fixes section from the preview",
"narrow banner retained the full preview at 390px with document scroll width equal to client width"
"TrueNAS system summary with memory-capacity label scrim",
"TrueNAS system drawer Manage tab at desktop width",
"TrueNAS system drawer Manage tab at phone width",
"Kubernetes cluster drawer with five-tab overflow rail before and after scrolling"
],
"interactions": [
"opened the isolated current-tree frontend fixture against the local mock backend",
"expanded the update banner with Show more",
"inspected rendered text, layout geometry, and horizontal overflow at desktop and narrow widths"
"opened the TrueNAS system inline drawer and selected Manage",
"verified Manage remained selected through repeated realtime refresh samples and after the final main rebase",
"inspected wrapped operator-policy help text and the stacked phone maintenance action",
"opened a Kubernetes cluster inline drawer at 390px and used its right-edge tab scroll control",
"verified the left-edge control appeared after scrolling and restored the default viewport"
]
}
@@ -559,7 +559,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
opening it pre-fills with sensible defaults (start = now, end
= +1h) or with the persisted window when one exists. */}
<Show when={!schedulerOpen()}>
<div class="flex items-center justify-between gap-3 pt-2 border-t border-border-subtle">
<div class="flex flex-col items-stretch justify-between gap-3 border-t border-border-subtle pt-2 sm:flex-row sm:items-center">
<div class="min-w-0 flex-1">
<label class="text-sm font-medium text-base-content">Maintenance window</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
@@ -574,13 +574,13 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
type="button"
onClick={handleOpenScheduler}
disabled={saving()}
class="min-h-11 px-2.5 py-1 text-xs font-medium text-base-content border border-border rounded hover:bg-surface-hover disabled:opacity-50 sm:min-h-0"
class="min-h-11 self-start rounded border border-border px-2.5 py-1 text-xs font-medium text-base-content hover:bg-surface-hover disabled:opacity-50 sm:min-h-0"
>
Schedule window
</button>
}
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 self-start">
<button
type="button"
onClick={handleOpenScheduler}
@@ -184,7 +184,10 @@ describe('ResourceOperatorStateSection', () => {
expect(sectionSource).toContain('<FormSelect');
expect(sectionSource).toContain('<FormTextarea');
expect(sectionSource).toContain('min-h-11 min-w-11 px-1.5 py-0.5');
expect(sectionSource).toContain('min-h-11 px-2.5 py-1 text-xs font-medium text-base-content');
expect(sectionSource).toContain(
'flex flex-col items-stretch justify-between gap-3 border-t border-border-subtle pt-2 sm:flex-row sm:items-center',
);
expect(sectionSource).toContain('min-h-11 self-start rounded border border-border');
expect(sectionSource).toContain(
'min-h-11 px-2.5 py-1 text-xs font-medium text-white bg-blue-600',
);
@@ -230,6 +230,8 @@ describe('StackedMemoryBar', () => {
// Should show sublabel in parentheses with bytes format
const sublabel = screen.getByText(/4\.00 GB/);
expect(sublabel).toBeInTheDocument();
expect(sublabel).toHaveClass('text-base-content/80');
expect(sublabel.parentElement).toHaveClass('bg-surface/80', 'shadow-sm');
});
it('hides sublabel when container is too narrow', () => {
@@ -70,7 +70,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-base-content leading-none pointer-events-none min-w-0 overflow-hidden">
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis px-0.5 text-center">
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis rounded-sm bg-surface/80 px-1 text-center shadow-sm">
<span>
<Show
when={!presentation().unavailable}
@@ -80,7 +80,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
</Show>
</span>
<Show when={presentation().showSublabel}>
<span class="metric-sublabel font-normal text-muted">
<span class="metric-sublabel font-normal text-base-content/80">
{' '}
({presentation().displaySublabel})
</span>
@@ -18,6 +18,7 @@ import toggleSource from './Toggle.tsx?raw';
import toggleModelSource from './toggleModel.ts?raw';
import toggleStateSource from './useToggleState.ts?raw';
import filterToolbarSource from './FilterToolbar.tsx?raw';
import subtabsSource from './Subtabs.tsx?raw';
describe('FilterHeader', () => {
afterEach(() => {
@@ -35,6 +36,15 @@ describe('FilterHeader', () => {
expect(container.querySelector('.flex.w-full.items-center.gap-2')).not.toBeNull();
});
it('keeps clipped in-page tab controls visibly scrollable on phones', () => {
expect(subtabsSource).toContain('overflow-x-auto');
expect(subtabsSource).toContain('canScrollLeft');
expect(subtabsSource).toContain('canScrollRight');
expect(subtabsSource).toContain('scrollTabs(-1)');
expect(subtabsSource).toContain('scrollTabs(1)');
expect(subtabsSource).toContain('sm:hidden');
});
it('keeps segmented controls on value callbacks while forwarding div attributes', async () => {
const onChange = vi.fn();
render(() => (
@@ -7,7 +7,7 @@ export const INLINE_DETAIL_TABLE_CELL_CLASS = 'p-0 border-b border-border bg-sur
// border below the lg breakpoint (issue #1622) without creating a scroll
// container the way overflow-hidden would.
export const INLINE_DETAIL_TABLE_CONTENT_CLASS =
'sticky left-0 max-w-[calc(100vw-3.5rem)] overflow-x-clip px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none lg:overflow-x-visible';
'sticky left-0 min-w-0 max-w-[calc(100vw-3.5rem)] whitespace-normal overflow-x-clip px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none lg:overflow-x-visible';
export interface InlineDetailTableRowProps extends TableRowProps {
cellId?: string;
@@ -1225,6 +1225,9 @@ describe('shared primitive guardrails', () => {
expect(subtabsSource).toContain('subtabsShellClass');
expect(subtabsSource).toContain('subtabsListClass');
expect(subtabsSource).toContain('subtabButtonClass');
expect(subtabsSource).toContain('canScrollLeft');
expect(subtabsSource).toContain('canScrollRight');
expect(subtabsSource).toContain('sm:hidden');
});
it('keeps object detail history behind the canonical Overview and History subtabs', () => {
@@ -7662,6 +7665,8 @@ describe('shared primitive guardrails', () => {
expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CELL_CLASS');
expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CONTENT_CLASS');
expect(inlineDetailTableRowSource).toContain('overflow-x-clip');
expect(inlineDetailTableRowSource).toContain('whitespace-normal');
expect(inlineDetailTableRowSource).toContain('min-w-0');
expect(inlineDetailTableRowSource).toContain('lg:overflow-x-visible');
expect(inlineDetailTableRowSource).toContain('event.stopPropagation()');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual(
+106 -27
View File
@@ -1,4 +1,16 @@
import { Component, createEffect, For, JSX, splitProps } from 'solid-js';
import ChevronLeftIcon from 'lucide-solid/icons/chevron-left';
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import {
Component,
createEffect,
createSignal,
For,
JSX,
onCleanup,
onMount,
Show,
splitProps,
} from 'solid-js';
export interface SubtabOption {
value: string;
@@ -25,6 +37,7 @@ interface SubtabsProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onChang
export const subtabsShellClass = 'border-b border-border';
export const subtabsListClass =
'flex min-w-0 items-center gap-3 overflow-x-auto scrollbar-hide sm:gap-6';
export const subtabsRailClass = 'relative min-w-0 flex-1';
export const subtabsTrailingRowClass = 'flex flex-wrap items-center justify-between gap-3';
export const subtabButtonClass =
'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors sm:min-h-10 sm:py-2 sm:text-sm';
@@ -32,6 +45,9 @@ export const subtabButtonActiveClass = 'border-blue-600 text-base-content';
export const subtabButtonInactiveClass = 'border-transparent text-muted hover:text-base-content';
export const Subtabs: Component<SubtabsProps> = (props) => {
let tablistRef: HTMLDivElement | undefined;
const [hasOverflow, setHasOverflow] = createSignal(false);
const [canScrollLeft, setCanScrollLeft] = createSignal(false);
const [canScrollRight, setCanScrollRight] = createSignal(false);
const [local, divProps] = splitProps(props, [
'value',
'onChange',
@@ -51,36 +67,99 @@ export const Subtabs: Component<SubtabsProps> = (props) => {
tablistRef
?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
updateScrollControls();
});
});
const updateScrollControls = () => {
const rail = tablistRef;
if (!rail) return;
const maxScrollLeft = Math.max(0, rail.scrollWidth - rail.clientWidth);
setHasOverflow(maxScrollLeft > 1);
setCanScrollLeft(rail.scrollLeft > 1);
setCanScrollRight(rail.scrollLeft < maxScrollLeft - 1);
};
onMount(() => {
const rail = tablistRef;
if (!rail) return;
rail.addEventListener('scroll', updateScrollControls, { passive: true });
window.addEventListener('resize', updateScrollControls, { passive: true });
const resizeObserver =
typeof ResizeObserver === 'function' ? new ResizeObserver(updateScrollControls) : undefined;
resizeObserver?.observe(rail);
updateScrollControls();
onCleanup(() => {
rail.removeEventListener('scroll', updateScrollControls);
window.removeEventListener('resize', updateScrollControls);
resizeObserver?.disconnect();
});
});
const scrollTabs = (direction: -1 | 1) => {
const rail = tablistRef;
if (!rail) return;
rail.scrollBy({
left: direction * Math.max(120, Math.round(rail.clientWidth * 0.7)),
behavior: 'smooth',
});
};
const tablist = () => (
<div
ref={tablistRef}
role="tablist"
aria-label={local.ariaLabel}
class={`${subtabsListClass} ${local.listClass ?? ''}`.trim()}
>
<For each={local.tabs}>
{(tab) => {
const selected = () => local.value === tab.value;
return (
<button
type="button"
role="tab"
aria-selected={selected()}
tabIndex={selected() ? 0 : -1}
disabled={tab.disabled}
onClick={() => local.onChange(tab.value)}
class={`${subtabButtonClass} ${
selected() ? subtabButtonActiveClass : subtabButtonInactiveClass
} ${local.tabClass ?? ''}`.trim()}
>
{tab.label}
</button>
);
}}
</For>
<div class={subtabsRailClass}>
<div
ref={tablistRef}
role="tablist"
aria-label={local.ariaLabel}
class={`${subtabsListClass} ${hasOverflow() ? 'pr-10' : ''} ${local.listClass ?? ''}`.trim()}
>
<For each={local.tabs}>
{(tab) => {
const selected = () => local.value === tab.value;
return (
<button
type="button"
role="tab"
aria-selected={selected()}
tabIndex={selected() ? 0 : -1}
disabled={tab.disabled}
onClick={() => local.onChange(tab.value)}
class={`${subtabButtonClass} ${
selected() ? subtabButtonActiveClass : subtabButtonInactiveClass
} ${local.tabClass ?? ''}`.trim()}
>
{tab.label}
</button>
);
}}
</For>
</div>
<Show when={canScrollLeft()}>
<button
type="button"
class="absolute inset-y-0 left-0 z-10 flex w-10 items-center justify-start bg-gradient-to-r from-surface via-surface to-transparent pl-1 text-muted hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:hidden"
onClick={() => scrollTabs(-1)}
aria-label={`${local.ariaLabel}: scroll left`}
>
<span class="flex h-7 w-7 items-center justify-center rounded-full border border-border bg-surface shadow-sm">
<ChevronLeftIcon class="h-4 w-4" aria-hidden="true" />
</span>
</button>
</Show>
<Show when={canScrollRight()}>
<button
type="button"
class="absolute inset-y-0 right-0 z-10 flex w-10 items-center justify-end bg-gradient-to-l from-surface via-surface to-transparent pr-1 text-muted hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:hidden"
onClick={() => scrollTabs(1)}
aria-label={`${local.ariaLabel}: scroll right`}
>
<span class="flex h-7 w-7 items-center justify-center rounded-full border border-border bg-surface shadow-sm">
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
</span>
</button>
</Show>
</div>
);
@@ -37,6 +37,8 @@ describe('InlineDetailTableRow', () => {
expect(detail.parentElement).toHaveClass('sm:px-4');
expect(detail.parentElement).toHaveClass('sticky');
expect(detail.parentElement).toHaveClass('left-0');
expect(detail.parentElement).toHaveClass('min-w-0');
expect(detail.parentElement).toHaveClass('whitespace-normal');
expect(detail.parentElement).toHaveClass('max-w-[calc(100vw-3.5rem)]');
expect(detail.parentElement).toHaveClass('lg:static');
expect(detail.parentElement).toHaveClass('lg:max-w-none');
@@ -76,4 +76,45 @@ describe('Subtabs', () => {
}
}
});
it('shows phone scroll affordances when the tab rail is clipped', async () => {
render(() => (
<Subtabs
value="overview"
onChange={vi.fn()}
ariaLabel="Resource detail sections"
tabs={[
{ value: 'overview', label: 'Overview' },
{ value: 'history', label: 'History' },
{ value: 'manage', label: 'Manage' },
{ value: 'deployments', label: 'Deployments' },
]}
/>
));
const tablist = screen.getByRole('tablist', { name: 'Resource detail sections' });
const scrollBy = vi.fn();
Object.defineProperties(tablist, {
clientWidth: { configurable: true, value: 180 },
scrollWidth: { configurable: true, value: 420 },
scrollLeft: { configurable: true, writable: true, value: 0 },
scrollBy: { configurable: true, value: scrollBy },
});
window.dispatchEvent(new Event('resize'));
const scrollRight = await screen.findByRole('button', {
name: 'Resource detail sections: scroll right',
});
expect(scrollRight).toHaveClass('sm:hidden');
expect(screen.queryByRole('button', { name: /scroll left/i })).not.toBeInTheDocument();
await fireEvent.click(scrollRight);
expect(scrollBy).toHaveBeenCalledWith({ left: 126, behavior: 'smooth' });
tablist.scrollLeft = 120;
tablist.dispatchEvent(new Event('scroll'));
expect(
await screen.findByRole('button', { name: 'Resource detail sections: scroll left' }),
).toBeInTheDocument();
});
});
@@ -1,16 +1,54 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import {
createEffect,
createMemo,
createSignal,
For,
Show,
type Accessor,
type JSX,
} from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { usePlatformWindowedItems } from './usePlatformWindowedItems';
export interface PlatformWindowedRowsProps<Row> {
items: Accessor<readonly Row[]>;
children: (item: Row, index: Accessor<number>) => JSX.Element;
/**
* Stable logical identity for rows rebuilt from live resource snapshots.
* When omitted, rows with unique string/number `id` fields are stabilized
* automatically; non-resource rows retain reference-keyed rendering.
*/
keyExtractor?: (item: Row) => string | number;
colSpan?: number;
estimatedRowHeight?: number;
enableThreshold?: number;
windowSize?: number;
}
type StablePlatformRow<Row> = {
__platformWindowKey: string | number;
value: Row;
};
const defaultRowKey = <Row,>(item: Row): string | number | undefined => {
if (typeof item !== 'object' || item === null || !('id' in item)) return undefined;
const id = (item as { id?: unknown }).id;
return typeof id === 'string' || typeof id === 'number' ? id : undefined;
};
const buildStableRows = <Row,>(
items: readonly Row[],
keyExtractor?: (item: Row) => string | number,
): StablePlatformRow<Row>[] | undefined => {
const keys = items.map((item) => keyExtractor?.(item) ?? defaultRowKey(item));
if (keys.some((key) => key === undefined) || new Set(keys).size !== keys.length) return undefined;
return items.map((value, index) => ({
__platformWindowKey: keys[index]!,
value,
}));
};
/**
* Canonical bounded renderer for ordinary platform table rows.
*
@@ -19,8 +57,27 @@ export interface PlatformWindowedRowsProps<Row> {
* prewarms the runway while touch input remains compositor-native.
*/
export function PlatformWindowedRows<Row>(props: PlatformWindowedRowsProps<Row>) {
const initialStableRows = buildStableRows(props.items(), props.keyExtractor);
const [stableRows, setStableRows] = createStore<StablePlatformRow<Row>[]>(
initialStableRows ?? [],
);
const [usesStableRows, setUsesStableRows] = createSignal(initialStableRows !== undefined);
createEffect(() => {
const next = buildStableRows(props.items(), props.keyExtractor);
if (!next) {
setUsesStableRows(false);
return;
}
setStableRows(reconcile(next, { key: '__platformWindowKey' }));
setUsesStableRows(true);
});
const renderItems = createMemo<readonly Row[]>(() =>
usesStableRows() ? stableRows.map((stableRow) => stableRow.value) : props.items(),
);
const windowing = usePlatformWindowedItems({
items: props.items,
items: renderItems,
estimatedItemHeight: props.estimatedRowHeight,
enableThreshold: props.enableThreshold,
windowSize: props.windowSize,
@@ -31,7 +88,7 @@ export function PlatformWindowedRows<Row>(props: PlatformWindowedRowsProps<Row>)
);
return (
<Show when={windowing.isWindowed()} fallback={renderRows(props.items(), 0)}>
<Show when={windowing.isWindowed()} fallback={renderRows(renderItems(), 0)}>
<tr
ref={windowing.setAnchorRef}
aria-hidden="true"
@@ -1,4 +1,5 @@
import { cleanup, render } from '@solidjs/testing-library';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { createSignal, onCleanup } from 'solid-js';
import { afterEach, describe, expect, it } from 'vitest';
import { PlatformWindowedRows } from '../PlatformWindowedRows';
@@ -45,6 +46,49 @@ describe('PlatformWindowedRows', () => {
expect(container.querySelectorAll('[data-platform-window-spacer]')).toHaveLength(2);
});
it('preserves keyed row component state when live snapshots replace row objects', async () => {
let mounts = 0;
let disposals = 0;
const [items, setItems] = createSignal([{ id: 'node-a', label: 'First snapshot' }]);
const StatefulRow = (props: { item: { id: string; label: string } }) => {
mounts += 1;
onCleanup(() => {
disposals += 1;
});
return (
<tr>
<td>{props.item.label}</td>
<td>
<input aria-label="Row-local state" />
</td>
</tr>
);
};
render(() => (
<table>
<tbody>
<PlatformWindowedRows items={items}>
{(item) => <StatefulRow item={item} />}
</PlatformWindowedRows>
</tbody>
</table>
));
const input = screen.getByRole('textbox', {
name: 'Row-local state',
}) as HTMLInputElement;
input.value = 'still editing';
setItems([{ id: 'node-a', label: 'Refreshed snapshot' }]);
expect(await screen.findByText('Refreshed snapshot')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Row-local state' })).toBe(input);
expect(input).toHaveValue('still editing');
expect(mounts).toBe(1);
expect(disposals).toBe(0);
});
it('keeps estate-sized card lists within their configured mounted-item budget', () => {
const items = () => Array.from({ length: 1_000 }, (_, index) => index);
const { container } = render(() => (