Skip to content

Ticking a row past page one of the bulk-assignment subject picker or delete-assignments table jumps back to page one #1682

Open

Description

@joshunrau

Ticking a row on page 2 or later of the bulk remote-assignment subject picker, or of the Delete Assignments table, sends the table back to page 1. Both components rebuild their rows array on every render, and every selection change re-renders them. libui's DataTable passes each new data array to TanStack's table, whose core row model then fires _autoResetPageIndex. Neither table sets autoResetPageIndex or manualPagination, so the page index is reset to 0 after each click. libui only shows 10 rows per page, so in any group with more than 10 subjects or assignments, every tick past the first page throws the user back to the start. They have to navigate back after each click, and can easily tick the wrong row once the page has moved under them.

Where

apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx:106-111 (rebuilt every render, passed as data={rows} at :295):

const rows: PickerRow[] = subjects.map((subject) => ({
  dateOfBirth: subject.dateOfBirth ? toBasicISOString(subject.dateOfBirth) : t({ en: 'NULL', es: 'NULO', fr: 'NUL' }),
  id: subject.id,
  sex: subject.sex ?? '',
  subject: removeSubjectIdScope(subject.id).slice(0, subjectIdDisplayLength)
}));

apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx:58-73 (same pattern, data={rows} at :223):

const instrumentMap = new Map<string, UnilingualInstrumentInfo>(/* ... */);
const deletableAssignments = (assignmentsQuery.data ?? []).filter(/* ... */);
const rows: AssignmentRow[] = deletableAssignments.map((assignment) => ({ /* ... */ }));

Each checkbox's onCheckedChange / onRowClick updates the selection state, which re-renders the component, produces a new rows array, and leads to table.resetPageIndex() in @tanstack/table-core (getCoreRowModel → _autoResetPageIndex).

Reproduce

  1. In a group with at least 11 subjects, open Group → Remote Assignments → Create Assignments.
  2. On "Select Subjects", go to page 2 of the table and tick a subject.

Actual: the subject is selected, but the table jumps back to page 1. (Confirmed in a unit test with 25 subjects: after going to page 2 and ticking S012, the visible rows were S000–S009.) The Delete Assignments table behaves the same way with more than 10 deletable assignments.
Expected: the table stays on page 2 and the user can keep ticking rows there.

Tests

  • apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/SourceStep.test.tsx: it('should stay on the current page when a subject is ticked, so a selection past page one does not bounce back to the start'). Render with a stateful wrapper around selectedIds and more than 10 subjects.
  • apps/web/src/components/DeleteRemoteAssignments/__tests__/DeleteRemoteAssignments.test.tsx: the same for an assignment on page 2.
  • testing/src/specs/remote-assignments-wizard.spec.ts: seed more than 10 subjects, tick one on page 2, and assert page 2 is still shown.

Suggested fix

Memoize rows with useMemo so the array identity changes only when the source data does (subjects, subjectIdDisplayLength, the language in SourceStep; assignmentsQuery.data, instrumentInfoQuery.data, subjectIdDisplayLength in DeleteRemoteAssignments). Selection then never changes data.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Webapps/web (clinician-facing React SPA)
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). The cited lines have not moved.

    Why libui does not catch it

    DouglasNeuroInformatics/libui#106 (in the installed 6.16.1) stopped reset() from rebuilding table state. Its regression test passes a data array defined outside the component, and its "Known gaps" section names this exact case: an unmemoized data still trips autoResetPageIndex, and the proper fix is autoResetPageIndex: false plus an explicit page-reset policy in the store. DataTableProps does not expose autoResetPageIndex, so we cannot set it from here. src/components/DataTable/ is identical in libui 6.18.0, and no libui issue tracks the gap.

    Notes on the useMemo fix

    • Memoize only rows. columns has to stay inline because the checkbox cells close over selected / selectedIds, which libui#106 also points out. That is safe: against @tanstack/table-core 8.21.3, a new columns array with the same data stays on page 2, and only a rebuilt data drops to page 1.
    • SourceStep: the deps are subjects, subjectIdDisplayLength and t. libui's useTranslation memoizes t on resolvedLanguage, so the NULL label still follows a language change.
    • DeleteRemoteAssignments: build instrumentMap and deletableAssignments inside the memo. Both are rebuilt on every render (:58-64), so as deps they would defeat it. t is not needed. Instrument titles already follow the language, because useInstrumentInfoQuery keys on resolvedLanguage.
    • The memo covers selection, not refetches. Assignments and subjects carry Date fields. React Query's replaceEqualDeep (@tanstack/query-core 5.104.0) shares only plain objects and arrays, so every refetch returns a new array. The app's QueryClient sets no staleTime, so tabbing away and back still resets the table to page 1. Only the libui change fixes that, so it is worth a libui issue.

    Same defect in admin Instruments

    apps/web/src/routes/_app/admin/instruments.tsx:116-142 (FormInstrumentsView) and :247-270 (SeriesInstrumentsView) also rebuild rows on every render, and they hold previewItem / rowToArchive state. Previewing an instrument from page 2 (double-click or the Preview row action) sends the table to page 1 behind the dialog, and the table stays there after the dialog closes. useInstrumentSource (:99-108) returns a new function every render, so wrap it in useCallback before it becomes a memo dependency. I would fold this into the same fix, with tests in routes/_app/admin/__tests__/instruments.test.tsx and testing/src/specs/admin-instruments.spec.ts.

    The other client-mode tables are fine. Admin groups, users and instrument repos pass query data straight through, datahub/subjects/index.tsx:474-479 memoizes displayData, and datahub/subjects/$subjectId/table keeps records in state.

    Tests

    • happy-dom never measures the table, so it stays out of the accessibility tree. Reach page 2 the way routes/_app/admin/audit/__tests__/logs.test.tsx:158-163 does: getAllByRole('button', { hidden: true }), filtered by text.
    • Build fixtures once: the 25 subjects outside the stateful wrapper, and mocks.assignments before render. A fixture rebuilt on every render reproduces the bug even with the fix in place.
    • Delete Assignments has no e2e coverage yet. Seed 11 or more assignments through POST /v1/assignments/bulk, as bulk-remote-assignments.spec.ts does, and open /group/remote-assignments?mode=delete. Add the delete table's locators to testing/src/pages/_app/group/remote-assignments.page.ts.

    Landing

    #1679 touches the same two DataTables and test files, so the two fit in one PR. #1685 and #1757 also edit DeleteRemoteAssignments.tsx. If #1757 moves the assignments query to useSuspenseQuery, the assignments ?? [] inside the memo goes away. #1811 edits both DataTable call sites, the wizard spec and the page object, but not rows, so any conflict is textual. #1825 also adds a test to remote-assignments-wizard.spec.ts.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions