# Pagination lifecycle contract

- **Outcome:** The canonical `Pagination` factory (`admin/src/components/Pagination.js`) retains its existing props/API and DOM structure (`<footer class="ipz-pagination">` with optional total span, prev/window/next `Button` controls, optional page-size `SelectControl`), while gaining idempotent `mount()`, in-place `update(next)`, and idempotent `destroy()` following the settled Toggle.js/Checkbox.js/Disclosure.js bind/unbind + destroyed-flag pattern. `update()` re-derives the clamped current page and the `{1, max, current-1, current, current+1}` window using the exact original expressions, so every accepted key (`page`, `totalPages`, `pageSize`, `pageSizes`, `totalItems`, `summary`, `onPageChange`, `onPageSizeChange`, `showTotal`, `showPageSize`, `className`) demonstrably takes effect in the live DOM rather than being silently accepted and ignored. `Pagination` has no `attrs`/handler-override surface of its own (unlike Toggle/Checkbox/Disclosure) — handler precedence is inherited unchanged from the child `Button`/`SelectControl` factories it composes, so there is no both-handlers-fire class of bug to fix here; this is stated explicitly rather than omitted.
- **Status:** IDLE
- **Task ID:** D5-PAGINATION-01
- **Source request:** Owner requested Delivery 5 Pagination lifecycle convergence for `plugins/international-press-zone/admin/src/components/Pagination.js`, from current `origin/master`, without delegation, external models, or skills; preserve every public API, caller behavior, event-callback precedence (supplied attrs handlers fully replace built-ins per the settled Toggle.js/Checkbox.js/Disclosure.js pattern), page state, disabled boundaries, keyboard/native button semantics, and DOM identity. Add idempotent `mount()`, `update(next)`, `destroy()`. Every key `update()` accepts must demonstrably take effect — a discriminating test per update path (page, totalPages/totalItems, callbacks, disabled states), plus caller compatibility, repeated lifecycle calls, no-op update identity, listener cleanup/remount. Tests must fail against `origin/master` code and against both-handlers-fire/listener-leak mutations. Audit all six callers first; preserve their behavior. Builds/gates run only through `~/.claude/bin/local-gate`. Commit locally; do not land.
- **Acceptance criteria:** All six existing `Pagination()` callers keep working unchanged: `admin/src/components/ExceptionsTab.js` (full prop set incl. `summary`), `admin/src/pages/fields-translate.js` (full prop set, no `summary`), `admin/src/pages/content-translate.js` and `admin/src/pages/string-translate.js` and `admin/src/pages/translate-history.js` (`showPageSize:false, showTotal:false` — total span and size-select div do not exist in the DOM; `update()` must not throw on any path when either or both are absent), `admin/src/pages/translate-posts.js` (`showPageSize:false` only, `showTotal` defaults true). `footer.mount(container)`, `footer.update(next)`, `footer.destroy()` exist, are idempotent, and preserve footer node identity across repeated calls and no-op updates. `update({page})` / `update({totalPages})` re-clamp `current` with the identical clamp expression, rebuild the numbered-button window with the identical `Set` expression, update `aria-current`/variant on every button, and re-derive prev/next `disabled` boundaries — proven by comparing a mutated instance against a freshly-constructed instance at the same target state (construct-vs-update parity), and never invoking `onPageChange`/`onPageSizeChange` as a side effect of clamping. `update({onPageChange})` / `update({onPageSizeChange})` swap the live handler in place (old handler zero calls after swap, new handler exactly one call) without re-registering listeners on unrelated buttons. `update({totalItems})` / `update({summary})` correctly re-render the total span text (including the `summary || 'Total N'` fallback) without needing `showTotal` re-passed, and is a no-op when `showTotal` is false (no total span exists). `update({pageSizes})` / `update({pageSize})` delegate to `select.updateOptions()`. `destroy()` removes all button/select listeners (via each child's own `destroy()`) and detaches the footer node; `mount()` after `destroy()` reattaches the footer and re-mounts all current buttons/select in original order without leaking duplicate listeners — proven by post-destroy click producing zero calls and post-remount click producing exactly one call. Focused tests, full admin suite (pre-existing failures verified pre-existing via reverting only `Pagination.js` against the current tree), lint, production build (changed generated assets committed, second build byte-identical), `tools/ui-inventory.mjs --check` + `tools/ui-gate.mjs`, and `tools/factory-gate.sh all` all pass with locked dependency install, all builds/tests run through `~/.claude/bin/local-gate`.
- **Preserved WIP/ref/path:** Worktree `agent-a74adab47305932cf` at `/home/user/Projects/Press.zone/wordpress/wp-content/.claude/worktrees/agent-a74adab47305932cf`; HEAD `2f9f6e561` equals fetched `origin/master` at task start; clean tree, no pre-existing uncommitted changes.
- **Constraints:** Only `admin/src/components/Pagination.js`, its new lifecycle test file (`admin/tests/pagination-lifecycle.test.js`), generated build assets touched by the required production build, `docs/architecture/ui-inventory.report.{md,json}` (regenerated, not hand-edited), and this plan/index may change. Do not change `Button.js`, `FormControls.js`/`SelectControl`, or any of the six caller files. Do not land.
- **Execution steps:**
  1. Read Toggle.js/Checkbox.js/Disclosure.js (settled pattern), current Pagination.js, `Button.js`/`SelectControl` (already have their own mount/update/destroy — Pagination composes them, does not reimplement their listener binding), `dom.js` `el()` (confirms `on*` → `addEventListener`), and all six caller call sites.
  2. Rewrite `Pagination()` to hold mutable closure state (`currentPage`, `currentTotalPages`, `currentPageSize`, `currentPageSizes`, `currentTotalItems`, `currentSummary`, `currentShowTotal`, `currentShowPageSize`, `pageChangeHandler`, `pageSizeChangeHandler`) and a `renderControls()`/`renderTotal()` helper that rebuilds the prev/window/next buttons (destroying discarded ones) and the total span from current state, reusing the exact clamp/window expressions from the original function body. Add `footer.mount`/`update`/`destroy` per the settled pattern.
  3. Add `admin/tests/pagination-lifecycle.test.js` covering: public markup/caller shapes (all six combinations), no-op `update({})` identity, `update({page})`/`update({totalPages})` construct-vs-update parity + no callback side effects, boundary disabled states, `onPageChange`/`onPageSizeChange` swap-in-place, `totalItems`/`summary` re-render + fallback + showTotal:false no-throw, `pageSizes`/`pageSize` delegation, destroy/remount listener cleanup with no leak, native button keyboard/click semantics untouched.
  4. Verify discrimination: run the new test file against unmodified `origin/master` Pagination.js (reverted in-place) — confirm genuine failures (missing `mount`/`update`/`destroy`).
  5. Run: locked `composer install` + `npm ci`, focused Pagination tests, full admin suite, lint, production build (commit changed generated assets, verify byte-identical second build), `ui-inventory.mjs --check` + `ui-gate.mjs`, `tools/factory-gate.sh all` — all through `~/.claude/bin/local-gate`.
  6. Commit locally with terse messages; update this plan's receipt and INDEX.md; do not land.
- **Current receipt:** `Pagination()` now returns the same `<footer>` node with `mount()`/`update(next)`/`destroy()` following the settled Toggle.js/Checkbox.js/Disclosure.js bind/unbind + destroyed-flag pattern. All prior DOM shape is unchanged (`.ipz-pagination`, `.ipz-pagination__total`, `.ipz-pagination__controls`, `.ipz-pagination__size`, native `Button`/`SelectControl` children). Internally the component holds mutable closure state (`currentPage`, `currentTotalPages`, `currentPageSize`, `currentPageSizes`, `currentTotalItems`, `currentSummary`, `currentShowTotal`, `currentShowPageSize`, `pageChangeHandler`, `pageSizeChangeHandler`); `buildControls()`/`renderControls()` reuse the exact original clamp (`Math.max(1, Math.min(...))`) and window (`new Set([1, max, current-1, current, current+1])`) expressions, so `update({page})`/`update({totalPages})` re-derive the live button set (variant, `aria-current`, disabled boundaries) identically to a fresh construction at the same state — verified by construct-vs-update parity assertions. `update()` never invokes `onPageChange`/`onPageSizeChange` as a side effect. Every accepted key demonstrably re-renders: `page`/`totalPages` rebuild the numbered window; `onPageChange`/`onPageSizeChange` swap the live handler in place with zero re-registration on unrelated buttons; `totalItems`/`summary` re-render the total span text with the `summary || 'Total N'` fallback; `pageSizes`/`pageSize` delegate to `SelectControl`'s own `updateOptions()`; `showTotal`/`showPageSize` toggle the optional regions live; `className` updates the root class. As explicitly scoped, `Pagination` composes `Button`/`SelectControl` but exposes no `attrs`/handler-override surface of its own, so there is no both-handlers-fire class of bug here — precedence is inherited unchanged from those child factories.
  New `admin/tests/pagination-lifecycle.test.js` (15 tests) covers: default full-featured markup, prev/next disabled boundaries, the `showPageSize:false, showTotal:false` caller shape (content-translate.js/string-translate.js/translate-history.js) exercised through every `update()` path without throwing, the ExceptionsTab `summary` shape, prev/next emission with clamped page and no construction/update side effects, page-size emission with the numeric value, construct-vs-update window parity for both `page` and `totalPages` (including re-clamping when `totalPages` shrinks below the current page without `page` being passed), no-op `update({})` identity, `onPageChange`/`onPageSizeChange` swap-in-place, total/summary re-render with the default-label fallback, `pageSizes`/`pageSize` delegation (including the native `updateSelectControlOptions` duplicate-value guard), live `showTotal`/`showPageSize` toggling, destroy/remount listener cleanup with zero leaked invocations, and native button click/keyboard activation timing untouched (no keydown listener added).
  Discrimination verified directly: reverting only `Pagination.js` to unmodified `origin/master` (test file kept) reproduced 10/15 genuine failures — missing `mount`/`update`/`destroy` (`TypeError: footer.update/mount is not a function`) on every update/lifecycle-dependent test, while the 5 construction-only tests (default markup, disabled boundaries, both caller shapes, plain click emission) still passed, confirming the tests discriminate the added lifecycle API specifically, not just presence of new code.
  One real bug was found and fixed during implementation: the first draft's `renderControls()` called each child `Button`'s own `.mount()`/`.destroy()` methods on every render (including initial construction), which broke `content-translate-loading.test.js` (a pre-existing test that mocks `Button.js` with a plain-DOM-only stub lacking those methods) with `TypeError: button.mount is not a function`. Fixed by having the internal rebuild path (`renderControls()`, invoked on `page`/`totalPages` changes and at construction) use plain DOM `appendChild`/`remove()` instead — safe because discarded buttons are always replaced by brand-new instances that are never reused, so there is no listener leak from skipping their `.destroy()`. The footer's own top-level `mount()`/`destroy()` (called rarely, only by direct `Pagination()` callers wanting explicit lifecycle control) still use the real child `Button.mount()`/`.destroy()` for genuine listener cleanup on the current button set. A second real bug (missing the `.ipz-pagination__size` wrapper `<div>` around the page-size `<select>`, present in `origin/master`'s DOM shape) was caught by the initial focused-test run and fixed before any discrimination check.
  All required gates ran through `~/.claude/bin/local-gate`: locked `composer install` + `npm ci`, focused Pagination tests (15/15 pass), full admin suite (727/731 pass — the same 4 pre-existing failures reproduced identically with only `Pagination.js` reverted to `origin/master` and the new test file removed: `admin-stores.test.js`'s PHP-policy-fixture drift and 3 `translations-route-loading.test.js` failures, none touching Pagination), `npx eslint src/components/Pagination.js` (clean, no output), production build twice in a row (second build byte-identical to the first — diffed the full `admin/dist` tree, zero differences), `node tools/ui-inventory.mjs --check` (ok: 123 modules, 0 direction violations, 2 duplicate ids, after regenerating the previously drifted report), `node tools/ui-gate.mjs` (ok: 3 then 9 changed files scoped after the plan/index updates, no new violations), and `bash tools/factory-gate.sh all` (all green: php-syntax, phpunit/procedural regressions [63 discovered, 4 known-failing pre-existing], phpcs baseline, phpstan 168/168 no errors, admin lint, admin build, ui-architecture 45/45 gate tests, ui-inventory --check, ui-gate).
  Generated-asset churn: exactly one file underwent genuine content-hash rotation from the `Pagination.js` source change — `782.d7a560ac.js` (`origin/master`'s committed chunk, confirmed via its `runtime.js` chunk map) → `782.21faafde.js`, still referenced by the rebuilt `runtime.js`'s chunk map. Two stray untracked build artifacts from earlier accidental builds during this session (`782.92a7c557.js` and an interim `782.21faafde.js`) were deleted before the final, from-a-clean-baseline production build; no pre-existing orphans were present or left behind.
  **Rebase.** `origin/master` advanced once mid-work, before any commit existed on this branch (`2079fefa2`, "Add dormant synced pattern translation adapter" — no overlap with `Pagination.js`, only a shared touch on `docs/plans/INDEX.md`). Rebased cleanly (`git stash` → `git fetch` → `git rebase FETCH_HEAD` → `git stash pop`), auto-merging `INDEX.md`, zero conflicts. Focused tests re-verified passing post-rebase.
- **Review-round-2: 6 latent blockers repaired.** Independent review of commit `2b310e6f8` found 6 defects, all fixed in place:
  1. B1 — `destroy()` never unbound the page-size `<select>`'s `change` handler (`SelectControl` has no lifecycle API of its own to call); a post-destroy `change` event still invoked `pageSizeChangeHandler`. Fixed by guarding the handler itself with the shared `destroyed` flag (`onChange: event => { if (destroyed) return; ... }`).
  2. B2 — the destroy/remount test asserted post-remount click behavior only; it never proved a retained button (or select) stops firing immediately after `destroy()`, so a mutant deleting `controlButtons.forEach(button => button.destroy())` from `footer.destroy()` survived. Fixed by capturing `retainedButton`/`retainedSelect` references before `destroy()` and asserting zero handler calls after clicking/changing them.
  3. B3 — `update({className})` had no discriminating test. Added one asserting the root class is replaced in place (`ipz-pagination <new>`, old class name gone).
  4. B4 — `update({pageSize})` alone (the `else if` branch, without `pageSizes`) had no discriminating test — only the combined `{pageSizes, pageSize}` path was covered. Added one exercising the standalone branch.
  5. B5 — the destroy→mount resurrection test asserted `update()` no longer throws but never proved it actually re-renders; a mutant deleting `destroyed = false` inside `mount()` survived (post-remount `update()` would silently no-op forever). Added an assertion that `update({page: 4})` after resurrection produces DOM identical to a freshly-constructed instance at that state.
  6. B6 — removed a reasoning/justification comment block (why `renderControls()` deliberately avoids calling child `Button.mount()`/`.destroy()`) that exceeded the length of the code it documented, keeping only the one non-obvious invariant line; removed a self-narrating comment in the test file (`// prev, 1, 2(current), 3, 4? no — ...`) that walked through the author's own arithmetic instead of stating a fact about the code.
  No stray `zz-probe.test.js` was present.
  Mutation-kill verified directly for all three review-cited mutants: reverting the B1 destroyed-guard reproduces the exact `onPageSizeChange` post-destroy-fire failure; reverting the B2 `controlButtons.forEach(button => button.destroy())` line reproduces the exact post-destroy-click failure; deleting the B5 `destroyed = false` assignment inside `mount()` reproduces a stale-window DOM mismatch on the post-resurrection `update({page:4})` assertion. All three mutants were re-applied individually, confirmed failing, then reverted to the fixed code before the final verification pass.
  Two new tests were added (`update({className})`, `update({pageSize})` alone) bringing the suite to 17 tests (up from 15); all 17 pass on the fixed code.
  All required gates re-ran through `~/.claude/bin/local-gate` on the fixed code: focused Pagination tests (17/17 pass), full admin suite (729/733 pass — the same 4 pre-existing failures, unchanged), `npx eslint src/components/Pagination.js tests/pagination-lifecycle.test.js` (clean), production build twice in a row (second build byte-identical to the first — diffed the full `admin/dist` tree, zero differences; one genuine chunk rotation, `782.21faafde.js` → `782.e6667a49.js`, caused by the `Pagination.js` source edit), `node tools/ui-inventory.mjs --check` (ok, after regenerating), `node tools/ui-gate.mjs` (ok, 5 changed files scoped, no new violations), and `bash tools/factory-gate.sh all` (all green, `fail 0`).
- **Next executable action:** Commit locally through hooks; landing remains explicitly excluded per the owner's instruction.
