# FilterBar lifecycle contract

- **Outcome:** The canonical `FilterBar` factory (`admin/src/components/FilterBar.js`) retains its existing props/API and DOM structure (`<div class="ipz-filters">` wrapping a `<div class="ipz-filters__row">` of caller-supplied group nodes), while gaining idempotent `mount()`, in-place `update(next)`, and idempotent `destroy()` following the settled Toggle.js/Checkbox.js/Disclosure.js/Pagination.js/TagInput.js bind/unbind + destroyed-flag pattern. `FilterGroup` (the same file's second export) is left unchanged: it is a stateless pure render helper with no internal state or listeners of its own, so it has nothing to converge — the settled precedent (Pagination composing `Button`/`SelectControl`) is only to add lifecycle to the component that owns mutable render state, not every helper it composes. `FilterBar` itself registers no event handlers of its own (it is a pure container, same as Pagination's own explicit precedent of "no attrs/handler-override surface"), so `destroy()`'s job is to (a) detach its own root node and (b) delegate teardown to any group that exposes its own `destroy()` (a lifecycle-aware component passed directly as a group), while plain `FilterGroup()`-wrapped groups are simply removed — discarded groups are always replaced by brand-new nodes, never reused, so plain removal leaks nothing, mirroring Pagination's own stated reasoning for its discarded buttons.
- **Status:** IDLE
- **Task ID:** D5-FILTERBAR-01
- **Source request:** Owner requested Delivery 5 FilterBar lifecycle convergence for `plugins/international-press-zone/admin/src/components/FilterBar.js`, from current `origin/master`, without delegation, external models, or skills; preserve every public API and caller behavior exactly (audit all callers first). Add idempotent `mount()`, `update(next)`, `destroy()`. Never add new public surface. Every key `update()` accepts gets its own discriminating test with construct-vs-update DOM parity. `destroy()` must silence all handlers including inner controls and anything rendered by later `update()` calls (destroyed-flag guard). Input/select node identity survives `update()`. Tests must kill mutants: listener-leak-on-destroy, destroyed-flag-not-reset-on-mount, each update branch neutered, node-replaced-on-render — verified by applying each mutant. No reasoning comments. Receipts must be exactly accurate. Builds/gates run only through `~/.claude/bin/local-gate`. Fetch+rebase only before first commit. Commit locally; do not land.
- **Acceptance criteria:** Both existing callers of `FilterBar`/`FilterGroup` keep working unchanged: `admin/src/pages/content-translate.js` (`renderFilters()` builds a fresh `FilterBar({ groups, ... })` each time and swaps it into the DOM via `refreshFilters()`'s `replaceChild`, never calling the new lifecycle methods) and `admin/src/pages/string-translate.js` (same full-rebuild pattern). Neither caller's behavior changes. `bar.mount(container)`, `bar.update(next)`, `bar.destroy()` exist, are idempotent, and preserve the root `<div class="ipz-filters">` node identity across repeated calls and no-op updates. `update({groups})` replaces the rendered group set in place inside the persistent `.ipz-filters__row` node (proven via construct-vs-update parity: an updated instance's row `innerHTML` matches a freshly-constructed instance at the same target groups) without ever recreating the row or bar node itself, and destroys any discarded group that exposes its own `destroy()` (exactly once, proven with a mock lifecycle-aware group). `update({className})` replaces the root class in place (old class name gone, `ipz-filters` prefix retained) without touching the group set. No-op `update({})` leaves the groups array, node identities, and className fully untouched. `destroy()` detaches the bar from its parent, destroys every lifecycle-aware group exactly once, and is idempotent (a second call is a no-op, no double-destroy). `update()` after `destroy()` is a silent no-op (destroyed-flag guard) — proven by asserting no visible DOM change. `mount(container)` after `destroy()` resets the destroyed flag, reattaches the bar, remounts every lifecycle-aware group via its own `mount()`, and provably re-enables `update()` (not a permanent no-op) by asserting a follow-up `update({groups})` genuinely re-renders. `mount()` is idempotent (no duplicate node when already attached) and safe with no container argument. Focused tests (verified to genuinely fail against unmodified `origin/master`'s `FilterBar.js`), full admin suite (failures verified pre-existing via reverting only `FilterBar.js` + removing the new test file 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 (`npm ci`, `composer install`), all builds/tests run through `~/.claude/bin/local-gate`.
- **Preserved WIP/ref/path:** Worktree `agent-a712ff9ba43004628` at `/home/user/Projects/Press.zone/wordpress/wp-content/.claude/worktrees/agent-a712ff9ba43004628`; branch was already at `origin/master` at task start (`git rebase origin/master` reported "up to date"), clean tree, no pre-existing uncommitted changes.
- **Constraints:** Only `admin/src/components/FilterBar.js`, its new lifecycle test file (`admin/tests/filterbar-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 `content-translate.js`, `string-translate.js`, `components/index.js`, or `FilterGroup`. Do not land.
- **Execution steps:**
  1. Read Toggle.js/Checkbox.js/Disclosure.js/Pagination.js (settled pattern), current `FilterBar.js`/`FilterGroup`, and both caller call sites (`content-translate.js`, `string-translate.js`) — confirmed neither caller ever calls `.mount()`/`.update()`/`.destroy()` on the `FilterBar()` return value; both use a full construct-and-`replaceChild` pattern (`refreshFilters()`/`renderFilters()`).
  2. Rewrite `FilterBar()` to hold mutable closure state (`currentGroups`, `currentClassName`, `destroyed`) over the persistent `<div class="ipz-filters__row">` and root `<div class="ipz-filters">` nodes. Add `bar.mount`/`update`/`destroy` per the settled pattern; `FilterGroup` untouched (stateless, no lifecycle to converge).
  3. Add `admin/tests/filterbar-lifecycle.test.js` covering: public markup shape, falsy-group filtering (backward-compat construction), lifecycle method presence, no-op `update({})` identity, `update({groups})` construct-vs-update parity + discarded-group destroy delegation, `update({className})` (including empty-string), `destroy()` detach + lifecycle-group teardown + idempotency, `destroy()` on plain groups (no throw), `update()`-after-`destroy()` no-op, `mount()`-after-`destroy()` resurrection (destroyed-flag reset, group remount, provably-live follow-up `update()`), `mount()` idempotency and no-container safety.
  4. Verify discrimination: run the new test file against unmodified `origin/master` `FilterBar.js` (swapped in-place) — confirm genuine failures (missing `mount`/`update`/`destroy`).
  5. Apply and verify each required mutant individually against the fixed implementation, confirming a genuine test failure, then revert: (a) delete the `teardownGroups(currentGroups)` call from `destroy()` — listener-leak-on-destroy; (b) delete `destroyed = false` from `mount()` — destroyed-flag-not-reset-on-mount; (c) delete the `renderGroups()` call from the `groups` update branch — update-branch-neutered / node-replaced-on-render (groups path); (d) delete the `bar.className = ...` line from the `className` update branch — update-branch-neutered (className path).
  6. Run: locked `npm ci` + `composer install`, focused FilterBar 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`.
  7. Commit locally with terse messages; update this plan's receipt and INDEX.md; do not land.
- **Current receipt:** `FilterBar()` now returns the same `<div class="ipz-filters">` node with `mount()`/`update(next)`/`destroy()` following the settled Toggle.js/Checkbox.js/Disclosure.js/Pagination.js pattern. All prior DOM shape is unchanged (`.ipz-filters`, `.ipz-filters__row`, caller-supplied group children in original order). Internally the component holds mutable closure state (`currentGroups`, `currentClassName`, `destroyed`); `renderGroups()` reuses `row.replaceChildren(...)` to swap the group set in place without recreating the row or bar node, so `update({groups})` matches a fresh construction at the same target state byte-for-byte in the row's `innerHTML` (verified by construct-vs-update parity assertion). `update()` never adds any callback/side-effect surface FilterBar didn't already have — there was none to preserve. Every accepted key demonstrably re-renders: `groups` swaps the row's children and destroys any discarded group exposing its own `destroy()` (exactly once, verified with a mock lifecycle-aware group; plain `FilterGroup()` groups are removed via plain `.remove()`, matching Pagination's own stated "discarded, never reused, no leak" reasoning); `className` replaces the root class in place. `FilterGroup` was intentionally left unmodified: it is a stateless pure function with no internal state or listeners, so there is nothing in it to converge — a decision matching Pagination's explicit precedent of composing lifecycle-unaware children without inventing lifecycle for them.
  New `admin/tests/filterbar-lifecycle.test.js` (14 tests) covers: default markup shape (`.ipz-filters`/`.ipz-filters__row`, group order), falsy-group filtering backward compatibility, lifecycle method presence, no-op `update({})` identity (className and group node references unchanged), `update({groups})` construct-vs-update parity plus discarded-lifecycle-group `destroy()` delegation (exactly once), `update({className})` in-place replacement (non-empty and empty-string), `destroy()` detach + lifecycle-group teardown + idempotency (second `destroy()` does not re-call child `destroy()`), `destroy()` on plain (non-lifecycle) groups without throwing, `update()`-after-`destroy()` silent no-op (destroyed-flag guard, asserted via unchanged className), `mount()`-after-`destroy()` resurrection (destroyed-flag genuinely reset — proven by a follow-up `update({groups})` actually re-rendering, not merely not-throwing — plus lifecycle-group remount via its own `.mount()`), `mount()` idempotency (no duplicate node when already attached), and `mount()` with no container argument (no-throw, returns the bar).
  Discrimination verified directly: swapping only `FilterBar.js` to unmodified `origin/master` (test file kept) reproduced 12/14 genuine failures, with exactly 2 tests still passing — default markup shape and falsy-group filtering (both construction-only, exercising no lifecycle method) — confirming the tests discriminate the added lifecycle API specifically.
  All four required mutants were applied individually to the fixed implementation and each reproduced a genuine test failure, then reverted: (a) removing the `teardownGroups(currentGroups)` call from `destroy()` (listener-leak-on-destroy) — 1 test failed (lifecycle-group destroy-count assertion); (b) removing `destroyed = false` from `mount()` (destroyed-flag-not-reset-on-mount) — 1 test failed (post-resurrection `update()` no-op assertion); (c) removing the `renderGroups()` call from the `groups` update branch (update-branch-neutered, node-replaced-on-render class) — 2 tests failed (parity assertion, discarded-group destroy-delegation assertion); (d) removing the `bar.className = ...` assignment from the `className` update branch (update-branch-neutered) — 2 tests failed (className replacement assertion, empty-string assertion). No mutant was left applied; the file was restored to the fixed implementation and re-diffed clean after each.
  All required gates ran through `~/.claude/bin/local-gate`: locked `npm ci` and `composer install`, focused FilterBar tests (14/14 pass), full admin suite (753/759 pass — the same 6 pre-existing failures reproduced identically with only `FilterBar.js` reverted to `origin/master` and the new test file removed: `admin-stores.test.js` 1 PHP-policy-fixture-drift failure, `settings-loading.test.js` 2 failures, `translations-route-loading.test.js` 3 failures, none touching FilterBar), `npx eslint src/components/FilterBar.js tests/filterbar-lifecycle.test.js` (`admin/eslint.config.mjs` scopes its ruleset to `files: ['src/**/*.js']`; `FilterBar.js` is genuinely linted and clean, but `tests/filterbar-lifecycle.test.js` matches no rule block and runs with zero rules — the test file is unlinted, not "clean"), 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 drifted report), `node tools/ui-gate.mjs` (ok: 3 then 8 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, matching the TagInput-lifecycle baseline], phpcs baseline, phpstan 170/170 no errors, admin lint, admin build, ui-architecture 45/45 gate tests, ui-inventory --check, ui-gate).
  Generated-asset churn: the `FilterBar.js` source change rotated one module's content-hash chunk — `549.cd6f6f88.js` (`origin/master`'s committed chunk) → `549.99f59264.js`, still referenced by the rebuilt `runtime.js`'s chunk map; the stale `549.cd6f6f88.js` was removed via `git rm`. A separate, unrelated tracked orphan chunk (`549.8e95b0a2.js`, already unreferenced by any `runtime.js` chunk map before this session and pre-existing in the repository) was physically deleted from disk by the sanctioned `npm run build`/`factory-gate.sh` build tooling during this session's later build pass; its removal was staged as part of the same `admin/dist` build-output commit rather than hand-deleted, since it is dist output the build tool itself controls, not hand-authored source.
  Committed locally as `47671e633` ("Converge FilterBar lifecycle"), pre-commit hooks (ESLint + remote PHPCS/PHPStan/slopgate) passed cleanly.
- **Review-round-2: 4 blockers repaired, 4 coverage gaps closed.**
  1. B1 (shallow destroy) — `teardownGroup()` previously only checked the top-level group node for its own `destroy()`. A real caller composition (`FilterGroup` wrapping a lifecycle-owning `SearchControl`, per `content-translate.js`) has no top-level `destroy()` on the `FilterGroup` wrapper itself, so a pending debounced `onQueryChange` timer inside the nested `SearchControl` survived `bar.destroy()`. Fixed: when a group has no own `destroy()`, `teardownGroup()` now walks `group.querySelectorAll('*')` and calls `.destroy()` on every descendant that exposes one, before removing the group node. Applied identically in both `bar.destroy()` (all current groups) and `update({groups})`'s discard path (groups being replaced), since both feed through the same `teardownGroup()`. Verified: with `SearchControl({debounce:300, onQueryChange})` nested inside a `FilterGroup` inside `FilterBar`, dispatching an `input` event then calling `bar.destroy()` (or `update({groups:[...]})` discarding that group) and advancing fake timers 300ms no longer invokes `onQueryChange`. Mutant-killed: removing the `querySelectorAll('*')` deep-scan reproduces exactly 2 failures (the destroy-path and update-path nested-control tests).
  2. B2 (no identity diff on `update({groups})`) — previously every `update({groups})` call unconditionally tore down all current groups and replaced them, even when the exact same group reference was re-passed unchanged, destroying and reinserting a still-live node. Fixed: `update({groups})` now diffs by identity (`Set` of the next groups) and only tears down groups present in the old set but absent from the new one; unchanged groups are neither destroyed nor recreated. Verified: `bar.update({groups:[g]})` with the same `g` already current now calls `g.destroy()` zero times (was one) and `g` remains in the row.
  3. B3 (banned reasoning comment) — the three-line comment at the old `FilterBar.js:47-49` justifying "plain removal leaks nothing" was deleted; its claim was disproven by B1, and it was prose justification rather than a non-obvious code invariant.
  4. B4 (receipt precision) — the discrimination paragraph's self-correcting "12/14... precisely, 2 tests still passed" phrasing was rewritten to name only the accurate pair (default markup shape, falsy-group filtering) in one clean sentence, matching the actual test names.
  Four additional coverage gaps (surviving mutants) were closed with new discriminating tests: (a) `mount()` sibling-order guard — a bar already attached between two siblings, re-`mount()`ed with the same container, must not move in the DOM (mutant: drop the `bar.parentNode !== container` identity check before `appendChild`); (b) `className`/`groups` branches chained as `else if` instead of independent `if`s — a combined `update({groups, className})` call now asserts both take effect in the same call (mutant: change the second `if` to `else if`); (c) `Object.hasOwn(next,'groups')` guard needing the additional `next.groups != null` non-null check — `update({groups:null})` must leave current groups untouched while `update({groups:[]})` must genuinely clear them (mutant: drop the `!= null` conjunct, reverting to plain `Object.hasOwn`); (d) the `list.filter(Boolean)` filtering step inside `safeGroups()` — the original falsy-groups test only exercised construction, where `el()` already silently drops `null`/`false` children regardless of `filter(Boolean)`, so it did not discriminate; a new test exercises `update({groups:[groupB, null]})` and additionally asserts `row.childNodes.length === 1` (not just `.children`, since a stray `null` argument to `Node.replaceChildren()` is coerced to a literal text node `"null"` that `.children` — element-only — silently hides but `.childNodes` — all nodes — would catch), discriminating the mutant that removes `.filter(Boolean)`.
  All 8 mutants (4 original + 4 newly required) were applied individually against the fixed implementation, each reproduced a genuine test failure, then reverted and the file re-diffed clean: sibling-order guard removal → 1 failure; `else if` chaining → 1 failure; dropping `!= null` → 1 failure; dropping `.filter(Boolean)` → 1 failure (only after the `childNodes.length` assertion was added — the original `.children`-only assertion did not discriminate this mutant, and was strengthened before being counted as a genuine kill); listener-leak-on-destroy (delete `teardownGroups(currentGroups)` from `destroy()`) → 2 failures; destroyed-flag-not-reset-on-mount → 1 failure; groups-branch-neutered (delete `renderGroups()` call) → 4 failures; className-branch-neutered → 3 failures; B1's nested-control deep-scan removal → 2 failures.
  The test suite grew from 14 to 22 tests (8 added: falsy-filter-via-update, same-group-no-destroy identity, `groups:null` no-clear, `groups:[]` clears, destroy-reaches-nested-control, update-reaches-nested-control, groups+className-combined, mount-sibling-order). Discrimination re-verified on the full 22-test file: swapping only `FilterBar.js` to unmodified `origin/master` reproduces exactly 20/22 genuine failures, with only the same 2 construction-only tests (default markup shape, falsy-group filtering) still passing.
  All required gates re-ran through `~/.claude/bin/local-gate` on the repaired code: focused FilterBar tests (22/22 pass), full admin suite (`npx vitest run` reported `Test Files: 3 failed | 57 passed (60)`, `Tests: 6 failed | 761 passed (767)`, no skipped tests — reproduced identically across three separate consecutive runs; the same 6 pre-existing failures, unchanged: `admin-stores.test.js` 1, `settings-loading.test.js` 2, `translations-route-loading.test.js` 3), `npx eslint src/components/FilterBar.js tests/filterbar-lifecycle.test.js` (`FilterBar.js` is genuinely linted, clean; `tests/filterbar-lifecycle.test.js` is out of `eslint.config.mjs`'s `src/**/*.js` scope and runs unlinted — no rules execute against it, so "no output" reflects that emptiness rather than a passing lint check), production build twice in a row (second build byte-identical to the first — diffed the full `admin/dist` tree, zero differences; one further genuine chunk rotation, `549.99f59264.js` → `549.b64e5861.js`, caused by the further `FilterBar.js` source edit; the now-stale `549.99f59264.js` was `git rm`'d), `node tools/ui-inventory.mjs --check` (ok, 123 modules, 0 direction violations, 2 duplicate ids, no drift), `node tools/ui-gate.mjs` (ok, 4 then 5 changed files scoped, no new violations), and `bash tools/factory-gate.sh all` (all green: phpunit/procedural 63 discovered/4 known-failing unchanged, phpstan 170/170 no errors, ui-architecture 45/45, ui-inventory --check, ui-gate — all identical to the pre-repair baseline).
  Invariant depended on: `teardownGroup()`'s deep-scan (`querySelectorAll('*')` over a static snapshot) plus an outer parent's own recursive `destroy()` can each independently reach the same nested control and call its `.destroy()` twice for one logical teardown (e.g. a group exposing its own `destroy()` that itself tears down its children, composed inside a further ancestor that also deep-scans). This is safe only because every element-attached `destroy()` in this codebase (Toggle/Checkbox/Disclosure/Pagination/TagInput/SearchControl and this repair) is idempotent via its own `destroyed` guard; `FilterBar`'s deep-scan teardown relies on that codebase-wide convention rather than de-duplicating destroy targets itself.
  No stray probe/debug test files were left in the tree; all mutant edits were applied and reverted in place, verified byte-identical to the fixed implementation after each revert.
  Committed locally as `eedee940d` ("Repair FilterBar lifecycle: deep teardown, identity diff, mutant coverage"), pre-commit hooks (ESLint + remote PHPCS/PHPStan/slopgate) passed cleanly.
- **Review-round-3: dead imports + receipt accuracy.** Removed unused `afterEach`/`beforeEach` imports from `admin/tests/filterbar-lifecycle.test.js` (never referenced). Corrected two receipt claims: (1) lint — `admin/eslint.config.mjs` scopes its ruleset to `files: ['src/**/*.js']`; `FilterBar.js` is genuinely linted and clean, but `tests/filterbar-lifecycle.test.js` matches no config block and runs with zero rules, so its prior "clean, no output" phrasing implied a passing check where none ran — corrected in both gate-evidence paragraphs above. (2) Suite figure — re-ran the full admin suite three separate times through `~/.claude/bin/local-gate`; `npx vitest run` consistently reported `Test Files: 3 failed | 57 passed (60)` and `Tests: 6 failed | 761 passed (767)` with no skipped tests in any run (no `it.skip`/`describe.skip`/`skipIf` usage exists anywhere in `admin/tests/`, confirmed by search) — the previously-flagged "757 passed + 4 skipped" breakdown could not be reproduced against this exact repaired code; the receipt now states the exact, repeatedly-verified vitest reporter line rather than a derived figure.
  Documented the deep-scan teardown's double-destroy invariant: `teardownGroup()`'s `querySelectorAll('*')` walk can, together with an outer ancestor's own recursive `destroy()`, call the same nested control's `.destroy()` twice for one logical teardown; this is safe only because every element-attached `destroy()` in this codebase (Toggle/Checkbox/Disclosure/Pagination/TagInput/SearchControl, and this component) is idempotent via its own `destroyed` guard — `FilterBar` depends on that codebase-wide convention rather than de-duplicating destroy targets itself.
  Re-ran through `~/.claude/bin/local-gate`: focused FilterBar tests (22/22 pass), full admin suite (761 passed/6 failed/767 total, unchanged), and `bash tools/factory-gate.sh all` (all green: phpunit/procedural 63 discovered/4 known-failing unchanged, phpstan 170/170 no errors, ui-architecture 45/45, ui-inventory --check, ui-gate — identical to the prior baseline).
  Committed locally as `ce994debd` ("Remove dead test imports; correct receipt lint scope and suite figures"), pre-commit hooks (ESLint + IPZ reference claim) passed cleanly.
- **Next executable action:** None; landing remains explicitly excluded per the owner's instruction.
