Repository navigation
fix(js): only reset the clicked field when removing its last row - #1573
Conversation
Removing the only row of a repeatable field resets it by clicking the
field's Add Row button and then Remove. The button was found with
.parents('.cmb-row'), which also matches the group entry and the group
wrapper, since they have the cmb-row class too. So every repeatable
field in the group, and in its other entries, got an extra empty row.
Look up the Add Row button next to the field's own repeat table
instead, and use .closest() for the row.
Adds a Playwright regression test with a group fixture.
Fixes CMB2#1515
The e2e test env runs with SCRIPT_DEBUG off, so it loads the minified file. Rebuilt with npm run build:js.
jtsternberg
left a comment
There was a problem hiding this comment.
Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.
PR #1573: only reset the clicked field when removing its last row
Head b23737f · base develop · fixes #1515
Briefing
- Origin: #1515. In a group with two or more repeatable fields, removing the only row of one field adds a row to every other repeatable field in the group, in every group entry.
- Root cause (confirmed): 752f0e3 (2017, "related #312") added the reset-by-Add-then-Remove path as
cmb.resetRow( $parent.find( '.cmb-add-row-button' ), $this ). It reused$parent = $this.parents('.cmb-row'), which has been there since the 2014 table→div conversion (106f91e)..parents()returns every.cmb-rowancestor. Inside a group those ancestors include.cmb-repeatable-groupingand.cmb-repeat-group-wrap, so.find()matches every Add Row button in the group andresetRow()clicks all of them. - Fix:
$parentbecomesclosest('.cmb-row'), and the Add button is looked up as$table.siblings('.cmb-add-row').find('.cmb-add-row-button'). The PR also rebuilds the min file and adds a Playwright spec plus a fixture box. - Trade-off: the new lookup depends on the button being a DOM sibling of the table. The old one only needed the button somewhere inside an ancestor row. Markup that was tolerated before now sends
resetRow()into infinite recursion (F2).
Verdict: REQUEST CHANGES
The diagnosis and the fix are correct for standard markup, and that is proven both ways: the spec fails on develop and passes on head. One small change is needed before merge. Any markup where the Add button isn't a sibling of the table now throws Maximum call stack size exceeded on a remove click that works on develop (F2). The fix is about 8 lines, and I ran it against every probe below.
Findings
F1: Scope: rebuilt js/cmb2.min.js is in the PR · nit/question · js/cmb2.min.js
CONTRIBUTING.md → "What we expect" #1 asks contributors to leave rebuilt *.min.js out. The author flagged this themselves (PR body: "Feel free to drop that commit"). It's needed here because .wp-env-tests.json sets SCRIPT_DEBUG: false (includes/CMB2_JS.php:149 serves .min), so the new e2e spec can't pass in CI without it. I checked the rebuild: running terser js/cmb2.js js/cmb2-wysiwyg.js js/cmb2-char-counter.js --compress --no-rename on head gives output byte-identical to the committed min, and the same command on develop reproduces develop's min. The diff therefore contains only this fix. None of #1571, #1572 or #1574 touch JS, so there's no min conflict between them. All four PRs except #1574 add a ### Bug Fixes heading under Unreleased in CHANGELOG.md, which will give trivial merge conflicts.
Recommendation: keep the commit (the CI spec depends on it), or drop it and rebuild at merge. That's Justin's call. The CONTRIBUTING rule doesn't account for e2e specs that need the min file. Consider adding an exception there.
F2: Correctness: empty Add-button lookup now recurses until the stack overflows · medium · blocking · js/cmb2.js:770, js/cmb2.js:783
When $table.siblings('.cmb-add-row') is empty, resetRow() clicks nothing for Add. It then clicks Remove, and the count is still ≤ 2, so it re-enters resetRow() with no end. The old ancestor-scoped find() still found the button when an integration wrapped the table (for styling, tabs or conditional-display wrappers) or moved p.cmb-add-row elsewhere inside the field row. An earlier private review raised this risk; the probe confirms it.
Evidence: probe P3 table wrapped by integration (add button no longer a sibling). It runs jQuery('#probe_top_repeat').wrap('<div class="third-party-wrap">') on a top-level repeatable text field with default: 'TOP-DEF', fills "typed", then clicks Remove:
PR head: P3 value after reset: typed P3 events: {"add":0,"remove":0} P3 errors: ["Maximum call stack size exceeded"]
develop: P3 value after reset: TOP-DEF P3 events: {"add":1,"remove":1} P3 errors: []
This also affects top-level (non-group) fields, which the PR describes as "the same result". That is true for standard markup only. Under C3, whichever bundled copy is newest serves every box on the site, so a theme's or plugin's wrapped markup breaks as soon as any other bundled plugin ships this version.
Recommendation: keep the sibling lookup first. If it comes back empty, fall back to the button that targets this table through data-selector, which is the same contract addAjaxRow() uses to resolve its table. Then guard resetRow() so an unusable button never recurses:
if ( number <= 2 ) {
// Only use this field's add button. Group rows are .cmb-row too.
var tableId = $table.attr( 'id' );
var $addBtn = $table.siblings( '.cmb-add-row' ).find( '.cmb-add-row-button' );
if ( ! $addBtn.length ) {
$addBtn = $( '.cmb-add-row-button' ).filter( function() {
return this.getAttribute( 'data-selector' ) === tableId;
} ).first();
}
return cmb.resetRow( $addBtn, $this );
}
// …
cmb.resetRow = function( $addNewBtn, $removeBtn ) {
if ( ! $addNewBtn.length || $addNewBtn.prop( 'disabled' ) ) {
return;
}
// …filter() with getAttribute avoids building a selector from the ID, so there's no escaping to get wrong. I ran this exact patch (rebuilt into the min file): the PR spec and P1–P6 all pass, P3 resets to TOP-DEF with 1 add/1 remove event, and nothing errors.
F3: Correctness (pre-existing, not caused by this PR): disabled Add button also recurses · low · nit / fold into F2 · js/cmb2.js:783
jQuery doesn't dispatch delegated click handlers on a disabled button. .prop('disabled', true) on Add Row is a common snippet for a third-party "max rows" limit. If it's set while a field has one row, resetRow() clicks nothing and recurses. Probe P6 disabled add button:
PR head: P6 value after: typed P6 errors: ["Maximum call stack size exceeded"]
develop: P6 value after: typed P6 errors: ["Maximum call stack size exceeded"]
The prop('disabled') check in the F2 guard fixes it (patched run: P6 errors: []). The same guard also protects removeGroupRow()'s resetRow() call. Not blocking on its own. It costs nothing to fold into F2.
F4: Testing: spec checks row counts but not defaults, other fields' values, events, or the F2 shape · low · should-fix · tests/playwright/group-repeatable.spec.js:16, tests/playwright/fixtures/test-fields.php:50
The spec proves the regression. With develop's min file restored it fails with Expected: 2 / Received: 3 on tables.nth(1), and it passes on head. Gaps:
- The fixture fields have no
default, so "reset restores the default" (the #312 contract) isn't asserted, only''. - Only the clicked field is filled. A regression that cleared or replaced other fields' values without changing row counts would pass.
- There's no case for F2's wrapped or relocated markup.
Recommendation: give first a default and assert it after reset. Fill second (and entry 2's fields) with sentinel values first and assert they're unchanged. Add a case that wraps the table before clicking Remove and asserts no pageerror and a reset value. Event counts (1 cmb2_add_row + 1 cmb2_remove_row per reset) are an optional extra.
Behavior changes / who observes them
- JS events: a field reset inside a group now fires
cmb2_add_rowonce, on the clicked field's table. Develop fired it once per repeatable table in the group, across every entry. Probe P1 (2 entries × 2 fields; the first event is the setup "add group" click): developadd:5, addTables:[group, g0_a, g0_b, g1_a, g1_b]; headadd:2, addTables:[group, g0_a].cmb2_remove_rowfires once on both. The extra events were for the spurious rows themselves, so a listener can't legitimately depend on them. This is a bug fix, not a default flip, and C3 staging doesn't apply. - Saved data: develop's spurious rows are real inputs (P1 develop: the other three tables went from 2 to 3
.cmb-row). Repeatable sanitizing drops empty values, so for blank fields the save is likely unchanged. For fields with adefault, a spurious row could be saved with the default value. I didn't verify this with a save/reload. - Top-level repeatables: unchanged on standard markup (P2: both builds reset to
TOP-DEF, 1 add/1 remove). On non-sibling markup the behavior changes (F2). - Not affected (checked): PHP, hooks, REST and stored-value interpretation (JS-only diff). The middle-row removal path (P4: a,b,c → remove b →
["a","c"]on both). The last-group-entry reset throughremoveGroupRow()(P5: resets toDEF-A, no errors, on both). $parentnarrowing: the PR says the post-reset path "works the same", and that holds through the UI. Before,$parent.prev().addClass('empty-row')ran on every.cmb-rowancestor when the clicked row hadempty-row. A user can't reach that, because the empty row is.hidden.closest()removes the latent multi-element behavior.
Fences checked
$this.parents('.cmb-row')as$parent→ 106f91e (2014-08-17, "Working and converting from tables"). It was used only forhasClass('empty-row')/prev()until 752f0e3 reused it for the button lookup. Neither #312 nor 752f0e3 shows any cross-field reset intent. I searched withgit log -Son both strings, read the full 752f0e3 diff, and read #312's comments in full. → changed (toclosest()); the need (find the clicked row) is kept.$parent.find('.cmb-add-row-button')in the reset path → 752f0e3 (2017, related #312). Built to reset the last row instead of removing it. → changed. Reset-to-default is kept (P1DEF-A, P2TOP-DEF), but the lookup is now sibling-only (F2).- The visible-row reset itself (not hiding) → #312 (jtsternberg: hiding "a radio input with 3 values… will still get set in the DB, and the user will not understand why"). → kept.
cmb.resetRow.resettingconfirm suppression → b15d588 (2021, fixes #1427). → kept, untouched. OnlyremoveGroupRow()reads it, and field-row Remove buttons carry nodata-confirm(includes/CMB2_Types.php:408), so this PR's path never prompts.- Add-then-Remove reset order in
resetRow()→ 752f0e3. → kept. - The
number <= 2threshold (one row plus the hiddenempty-row) → 752f0e3. → kept.
Claims from an earlier private review, checked
- #312 rationale, 752f0e3, b15d588: confirmed (above).
- "Keeps reset-to-default, visible row, add/remove events, confirm suppression": confirmed (P1/P2/P5). The events now fire once instead of once per table.
- "Broad selector dates to 2014, no deliberate cross-field requirement": confirmed, with one detail. The selector is from 2014, but its use for the Add-button lookup is from 2017 (752f0e3).
- "Sibling selector matches standard markup": confirmed.
CMB2_Types::render_repeatable_field()(includes/CMB2_Types.php:338-345) is the only emitter of.cmb-add-row-button. - "Relocated button → recursion": confirmed (P3), and it's a regression against develop. The
data-selector+ empty-guard suggestion was validated by the patched run. The guard should also coverdisabled(F3). - "min actually rebuilt": confirmed byte-identical.
Verification run
- PHPUnit, full suite on head, isolated wp-env (port 2633, PHP 8.3):
OK (328 tests, 1073 assertions). That matches the PR body. No PHP changed, so the fixture ([]arrays) is the only PHP the diff adds, and it's 7.4-safe. The 7.4 floor is only proven by CI (C7). - Playwright
group-repeatable.spec.js(chromium,WP_BASE_URL=http://localhost:2633 SKIP_WP_SERVER=1): passes on head and fails on develop's min file (Expected: 2, Received: 3). - Full Playwright suite: inconclusive. One full chromium run hit 9 failures, all
net::ERR_ABORTED/ 30s navigation timeouts with no assertion failures, at host load average 24 (other test environments running in parallel). That includedgroup-repeatable.spec.js, which passed in three separate targeted runs. Not evidence for or against the PR.
Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.
…led button When the add row button is not a sibling of the repeat table, look it up by its data-selector. Skip the reset when the add button is missing or disabled, so the remove click cannot call resetRow again forever. Addresses PR feedback. Refs CMB2#1573
… button Check that the clicked field resets to its default, that other fields keep their values, and that only one add and one remove event fire. Add a top-level repeatable field to cover a wrapped table and a disabled add button. Addresses PR feedback. Refs CMB2#1573
# Conflicts: # CHANGELOG.md
|
Thanks for the review. Updated in 0f6aa36 and f4430a6.
I also merged develop to fix the CHANGELOG conflict. The PR description is updated too. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1573 +/- ##
==========================================
Coverage 72.16% 72.16%
Complexity 1911 1911
==========================================
Files 53 53
Lines 5034 5034
==========================================
Hits 3633 3633
Misses 1401 1401
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Props/Fixes order and @handle credit like the other entries, plus the two changes integrations can see: cmb2_add_row fires once per reset, and Remove with a disabled Add Row button is now a no-op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpfVcxMH9VE8CRmGRvjJPS Co-Authored-By: jtsternberg-bot <me+jtbot@jtsternberg.com>
Description
When a repeatable field has only one row, clicking its Remove button "resets" the row:
removeAjaxRow()clicks the field's Add Row button and then the Remove button again. The Add Row button was looked up like this:Before (
js/cmb2.js,removeAjaxRow()):After:
And
cmb.resetRow()now stops early:.parents('.cmb-row')returns every ancestor with that class. Inside a group, the group entry (.cmb-repeatable-grouping) and the group wrapper (.cmb-repeat-group-wrap) also havecmb-row, so.find()matched the Add Row button of every repeatable field in the whole group. Now we take the button next to the field's own.cmb-repeat-table(it is rendered as a siblingp.cmb-add-rowinCMB2_Types::render_repeatable_field()). If a plugin or theme wraps the table so the button is no longer a sibling, we fall back to the Add Row button whosedata-selectoris this table's id.resetRow()clicks Add and then Remove. If the Add button is missing or disabled, no row is added, so the Remove click callsresetRow()again and the page hitsMaximum call stack size exceeded. NowresetRow()does nothing in that case.js/cmb2.min.jsis rebuilt in a separate commit (npm run build:js). The e2e env runs withSCRIPT_DEBUGoff, so the new Playwright test needs the min file. Feel free to drop that commit if you rather rebuild at release.Motivation and Context
Fixes #1515.
With a group that has two or more repeatable fields, removing the only row of one field added an empty row to all the other repeatable fields in that group, and in the other group entries too.
Risk Level
Low. Admin JS only, and only the path where a repeatable field's only row is removed.
removeAjaxRow()is only bound to.cmb-remove-row-buttonclicks. The group reset inremoveGroupRow()also usesresetRow(), so it gets the same missing or disabled button guard. With a normal button it works the same.data-selectorfallback only runs when the sibling lookup finds nothing.$parentis now just the clicked row. After the reset branch it is only used for theempty-rowcheck, and only repeat rows get that class (CMB2_Types::render_repeatable_field()), so that path works the same.cmb2_add_rowonce for every repeatable field in the group. Now it fires once, for the clicked field only.Testing procedure
Manual, in wp-admin with a group that has two repeatable text fields plus a top-level repeatable text field:
jQuery('#top_repeat_repeat').wrap('<div></div>')) and remove its only row. It resets to its default, no error.jQuery('[data-selector="top_repeat_repeat"]').prop('disabled', true)) and remove its only row. Nothing happens, no error.Automated:
tests/playwright/group-repeatable.spec.js, with a new box intests/playwright/fixtures/test-fields.php(a repeatable group with two repeatable fields, plus a top-level repeatable fieldtop_repeat). Three tests:DEF-A, all five repeat tables still have one row plus the hidden empty row, the other fields keep their values, and only onecmb2_add_rowand onecmb2_remove_rowevent fire.top_repeattable so the Add Row button is not a sibling, then removes its only row. It resets toTOP-DEFwith no page errors.top_repeatAdd Row button, then removes its only row. The value stays and there are no page errors.npx playwright test group-repeatable.spec.js --project=chromiumgit show develop:js/cmb2.min.js > js/cmb2.min.jsthe first and the disabled-button tests fail. With the min file fromb23737f9(before the data-selector fallback and the guard) the wrapped-table and disabled-button tests fail.npm run phptests(wp-env, PHP 8.3, WP latest): OK, 328 tests, 1073 assertions.npm run build:js: eslint passes. Before the rebuild I checked that a fresh build ofdevelopmatches the committedcmb2.min.js, so the min diff only has this fix.Types of changes
Checklist:
Screenshots
No visual change. The only difference is that other fields no longer get an extra empty row.