# TagInput lifecycle contract

- **Outcome:** The canonical `TagInput` factory retains its existing 5-arg call shape and DOM structure (`.international-form-group` > label + tooltip, `.international-tag-input` > tags container + text input), all tag add/remove semantics (Enter-to-add, blur-to-add, dedupe, × remove), and keyboard/caret behavior, while gaining idempotent `mount()`, in-place `update(next)`, and idempotent `destroy()` — matching the settled Toggle.js/Checkbox.js/Disclosure.js pattern. No new public parameter or override surface was introduced: an initial revision added an unrequested 6th `options.attrs` param, which independent review correctly rejected (no caller uses it, no other converged component in this series added new surface unprompted, and it carried a silent-failure hole via `Object.hasOwn` on an `undefined` handler value) and which has been fully removed.
- **Status:** IDLE
- **Task ID:** D5-TAGINPUT-01
- **Source request:** Owner requested Delivery 5 TagInput lifecycle convergence for `plugins/international-press-zone/admin/src/components/TagInput.js`, from current `origin/master`, without delegation, external models, or skills; preserve every public API, caller behavior, event-callback precedence, tag add/remove semantics, input/caret identity (this workstream started from a caret-reset bug), keyboard semantics, and DOM identity. Every `update()` key needs its own discriminating test with construct-vs-update parity; `destroy()` must silence ALL handlers including any on the inner input node; tests must kill mutants (listener-leak-on-destroy, destroyed-flag-not-reset-on-mount, each update branch neutered). No reasoning/justification comments. Audit all callers first. Builds/gates through local-gate only. Commit locally; do not land.
- **Coordinator repair request (post-review):** (1) Remove the invented `options.attrs` parameter and its four dedicated tests — no converged component in this series introduced new public surface unprompted (Checkbox's `attrs` pre-existed its lifecycle slice), no caller uses it, and it carried a real hole (`attrs.onkeydown: undefined` would silently disable Enter-to-add via `Object.hasOwn`); remove the now-dead per-key `removeEventListener` loop and its comment, verify `unbind()` coverage and mutant kills still hold with the plain two-listener bind/unbind. (2) Correct the receipt: re-measure the focused-test fail count against unmodified master (was misreported as 12/18; the corrected count is with the repaired 16-test file) and correct the full-suite pre-existing-failure attribution to the true three files (`admin-stores.test.js` 1, `settings-loading.test.js` 2, `translations-route-loading.test.js` 3 — not two files as originally reported). (3) Add two additional coverage tests: remount-after-sibling-append preserving root position, and a remove button rendered by `update({state})` clicked after `destroy()`. Delete any stray `probe-taginput.test.js` (none was found in this worktree). Rerun the full gate chain and commit through hooks; do not land.
- **Acceptance criteria:** `TagInput(key, label, help, state, onUpdate)` (5-arg, no 6th parameter) callers keep working unchanged — same classes (`international-form-group`, `international-label--settings`, `international-tooltip`, `international-tag-input`, `international-tag-input__tags`, `international-tag-input__tag`, `international-tag-input__tag-text`, `international-tag-input__tag-remove`, `international-tag-input__input`), same comma-separated `state[key]` storage, same dedupe-on-add, same Enter/blur add-and-clear behavior, same × remove; `root.mount(container)`, `root.update(next)`, `root.destroy()` exist, are idempotent, preserve the root and inner `input` node identity across repeated calls and no-op updates; every `update()`-accepted key (`label`, `help`, `onUpdate`, `state`) has its own test proving it takes effect and matches a fresh construction with the same values; `destroy()` removes both built-in listeners (`keydown`, `blur`) from the input via a plain `bind()`/`unbind()` pair (no attrs surface), detaches the root, and is proven via two independent mutants: neutering `unbind()` to a no-op fails 1 test, neutering the destroyed-flag reset on `mount()` fails 1 test; remount reattaches the correct listeners without changing node identity and preserves the root's position among DOM siblings; a remove-button pill rendered after `update({state})` is silenced by a subsequent `destroy()` the same as one rendered at construction time; focused tests, full admin suite, lint, production build (second build byte-identical; TagInput is not part of the bundled webpack entry so `dist/` is unaffected — verified via `grep -c` on the built bundle), `tools/ui-inventory.mjs --check` + `tools/ui-gate.mjs`, and `tools/factory-gate.sh all` all pass with locked dependency install.
- **Preserved WIP/ref/path:** Worktree `agent-af5ee4c3e09ed3558`; HEAD `6962ec5f1` equals fetched `origin/master` at start; clean tree at start, no pre-existing uncommitted changes.
- **Constraints:** Only `admin/src/components/TagInput.js`, its new lifecycle test file `admin/tests/taginput-lifecycle.test.js`, `docs/architecture/ui-inventory.report.{md,json}` (regenerated, not hand-edited), and this plan/index may change. No other callers of `TagInput` exist in the codebase besides its own re-export in `admin/src/components/index.js` (verified by `grep -rl TagInput admin/src admin/tests`), so no other file needed touching. Do not land.
- **Execution steps:**
  1. Read Toggle.js/Checkbox.js/Disclosure.js (settled lifecycle + override-precedence patterns) and current TagInput.js; confirm no other callers exist.
  2. Add `mount`/`update`/`destroy` to the returned root `<div class="international-form-group">`, following the bind/unbind + destroyed-flag pattern, with a plain two-listener (`keydown`, `blur`) `bind()`/`unbind()` pair and no new public parameter.
  3. Add `admin/tests/taginput-lifecycle.test.js` covering: caller compatibility, add-on-Enter, add-on-blur + remove-by-click, dedupe, input node identity across add/remove, each `update()` key (`label`, `help`, `onUpdate`, `state`) with construct-vs-update parity, no-op update identity, idempotent mount, destroy silencing both built-in handlers (proven by `input.value` staying unmutated, not just by absorbed side effects), a stale in-DOM remove-button handler silenced post-destroy, destroyed-flag reset on remount, remount preserving root position among DOM siblings, and a `update({state})`-rendered remove button silenced by a later `destroy()`.
  4. Verify tests fail against unmodified `origin/master` (checked out via `git show <pre-slice-sha>:...path` since the file had already been committed once in this worktree — 11/16 failures reproduced), run the full admin suite before/after via master-swap to confirm the same 6 pre-existing failures across three files (`admin-stores.test.js`, `settings-loading.test.js`, `translations-route-loading.test.js`) are unrelated, then run: locked npm/composer installs, focused tests, full admin suite, lint, production build twice (byte-identical; confirmed `TagInput` is absent from the built bundle via `grep -c`), `ui-inventory.mjs --check` + `ui-gate.mjs`, `tools/factory-gate.sh all` — all through `local-gate`.
  5. Kill the required mutants by hand: neuter `unbind()` to a no-op (1 test fails), neuter the destroyed-flag reset in `mount()` (1 test fails); restore the passing implementation after each and re-verify green.
  6. Commit locally with a terse message; update this plan's receipt and INDEX.md; do not land.
  7. **Repair pass (post-review):** remove the invented `options.attrs` param, its four dedicated tests, and the dead attrs-specific `removeEventListener` loop/comment; re-verify both mutants still die with the plain bind/unbind; add the two additional coverage tests (sibling-position preservation across destroy/remount, remove button rendered by `update({state})` silenced by `destroy()`); re-measure the master fail-count and the full-suite pre-existing-failure file list; rerun the entire gate chain; commit through hooks.
- **Current receipt:** First pass (commits `9985d4a26`/`304d9b14c`) added an unrequested 6th `options.attrs` param; independent review rejected it (no caller, no precedent for unprompted new surface in this series, and a real silent-failure hole where `attrs.onkeydown: undefined` would pass `Object.hasOwn` and disable Enter-to-add). This repair pass fully reverted that surface and corrected the receipt's evidence.

  `TagInput()` returns the same root `<div class="international-form-group">` with `mount()`/`update(next)`/`destroy()`, and the original 5-arg call shape only (no 6th parameter). All prior classes/DOM shape/state format/dedupe/Enter-and-blur-add/× remove are unchanged (verified by diffing the current implementation against the true unmodified-master `TagInput.js`, retrieved via `git show <pre-slice-commit>:.../TagInput.js` since the working file had already been committed once in this worktree, and by construct-vs-update parity assertions in the tests). `bind()`/`unbind()` is a plain two-listener pair (`keydown`, `blur`) with no attrs/override surface. `update()` supports `label` (rewrites the label text node in place), `help` (adds/updates/removes the tooltip span in place), `onUpdate` (swaps the change callback), and `state` (repoints tag rendering and future mutations at a new external state object) — each with its own test proving the change takes effect and matches a value-equivalent fresh construction. `destroy()` unbinds both built-in listeners, detaches the root, and is idempotent; a `destroyed` closure flag also guards `setTags()` (covering a remove-button pill already in the DOM from before destroy, whose onclick closure is not otherwise reachable by unbind, including one rendered later via `update({state})`) and `root.update()`.

  `admin/tests/taginput-lifecycle.test.js` now has 16 tests: caller compatibility, add-on-Enter, add-on-blur, remove-by-click, dedupe, input node identity preservation, all four `update()` keys with construct-vs-update parity, no-op update identity, idempotent mount, destroy silencing (proven via `input.value` staying unmutated after a live keydown dispatch, not merely via absorbed side effects), a stale in-DOM remove-button handler silenced post-destroy, destroyed-flag reset on remount, remount preserving the root's position among pre-existing DOM siblings (new), and a remove button rendered by `update({state})` staying silenced after a later `destroy()` (new). Both required mutants were re-verified to be killed on the repaired implementation: neutering `unbind()` to a no-op fails 1 test (the destroy-silencing test); neutering the destroyed-flag reset in `mount()` fails 1 test (the destroyed-flag-reset-on-mount test).

  Focused tests re-measured against a true unmodified-master `TagInput.js` (not the once-already-committed attrs version): **11/16 fail** (not 12/18 as originally reported — the test count itself changed after removing the four attrs tests and adding two new ones). Full admin suite: **739/745 pass** on the repaired implementation (723/729 baseline + 16 new focused tests) vs 723/729 on unmodified `origin/master` — the same **6 pre-existing failures spanning three files**: `admin-stores.test.js` (1: "uses a fixture that matches the canonical PHP policy"), `settings-loading.test.js` (2: "paints a form skeleton immediately..." and "keeps unsaved form edits..."), `translations-route-loading.test.js` (3: "defaults to the themes tab...", "switchTab() destroys the previous sub-page...", "uses the scoped languages store..."). This corrects the original receipt, which under-reported these as two files. `npm run lint:js` clean, no output. Production build run twice producing byte-identical `dist/js/main.js`/`dist/css/main.css` hashes both times (`4e22e3ce...`/`0ae7bf4a...`, unchanged from the first pass) — `TagInput` remains absent from the bundled webpack admin entry (`grep -c` returns 0), so `dist/` is unaffected by this change. `node tools/ui-inventory.mjs --check` (ok: 123 modules, 0 direction violations, 2 baseline duplicate ids) after regenerating the report, and `node tools/ui-gate.mjs` (ok: 2 changed files scoped, no new violations). `bash tools/factory-gate.sh all` passed in full on the repaired, mutant-verified state (php-syntax, phpunit/procedural regressions, phpcs baseline, phpstan, admin lint, admin build, ui-architecture 45/45 gate tests, ui-inventory --check ok, ui-gate ok). No stray `probe-taginput.test.js` was found in this worktree.

  **Committed.** First pass: local commits `9985d4a26`/`304d9b14c`. Repair pass: local commit **`75642ec38`** "Repair TagInput lifecycle: drop invented attrs surface, correct receipt" on branch `worktree-agent-af5ee4c3e09ed3558`. 4 files changed, 43 insertions(+), 109 deletions(-). The pre-commit hook ran and passed the remote PHPCS/PHPStan/slopgate check inline (`IPZ_REMOTE_PHP_GATE_OK` sentinel observed) and ESLint clean. Per the task's explicit instruction ("Fetch+rebase only before first commit; after commits, no rebase/merge"), no rebase was performed after this repair commit even though `origin/master` has since advanced with unrelated commits. Landing remains explicitly excluded per the owner's instruction.
- **Next executable action:** None within this local-only slice.
