Skip to content

Fix repeatable fields dropping 0 values on save - #1574

Merged
jtsternberg merged 6 commits into
CMB2:developfrom
faisalahammad:fix/repeatable-zero-value
Oct 13, 2026
Merged

jtsternberg merged 6 commits into
CMB2:developfrom
faisalahammad:fix/repeatable-zero-value

Conversation

@faisalahammad

@faisalahammad faisalahammad commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Description

CMB2_Field::sanitization_cb() removes empty entries from a repeatable field with array_filter(). array_filter() also drops 0 and '0', so a repeatable field saved with array( 'a', '0', 'b' ) is stored as array( 'a', 'b' ). For textarea_code, text_datetime_timestamp and text_datetime_timestamp_timezone the entry is dropped a second time in CMB2_Sanitize::_check_repeat(), which checks each entry with if ( $this->value ) and ! empty( $val ). The edit screen has the same problem: CMB2_Types::repeatable_rows() uses array_filter() too, so a saved 0 row is not shown, and the next admin save removes it.

This PR uses the existing CMB2_Utils::filter_empty(), notempty() and isempty() helpers in these places. They keep 0 and '0' and still drop null, '', false and empty arrays. Group rows already use filter_empty() since dba5776 (2.2.2), which was added "so group fields can have '0' as a value". The repeatable path was not changed at that time.

CMB2_Field::sanitization_cb()

Before:

// Remove empties.
$meta_value = array_filter( $meta_value );

After:

// Remove empties, but keep 0/'0'.
$meta_value = CMB2_Utils::filter_empty( $meta_value );

CMB2_Sanitize::_check_repeat()

Before:

if ( $this->value ) {
	$val = $this->$method( true );
	if ( ! empty( $val ) ) {

After:

if ( CMB2_Utils::notempty( $this->value ) ) {
	$val = $this->$method( true );
	if ( CMB2_Utils::notempty( $val ) ) {

CMB2_Sanitize::text_money()

Now that '0' reaches it, the repeatable branch formats it like the other entries ('0.00') instead of storing a raw '0'. The single (non-repeatable) field now does the same, so both paths agree. An empty money field is still removed, not saved as 0.00.

// Before
if ( ! $this->value ) {
	return '';
}
...
if ( $val ) {

// After
if ( CMB2_Utils::isempty( $this->value ) ) {
	return '';
}
...
if ( CMB2_Utils::notempty( $val ) ) {

CMB2_Types::repeatable_rows()

Both array_filter() calls now use filter_empty(), so a saved 0 row is shown on the edit screen and survives the next save.

This change showed a second bug. The hidden empty-row template is part of the form, so its inputs are posted on every save. Before this PR, array_filter() dropped the template value only when it was '0'. With other values it was already saved as an extra row on develop (for example a repeatable select with options a/b gains an 'a' row on each save). With filter_empty() the '0' case would show up too.

The template row inputs now render with disabled="disabled" and data-cmb2-template='1', so the browser does not post them. addAjaxRow in js/cmb2.js removes both when it turns the template into a real row. If a field sets its own disabled attribute, CMB2 leaves it as is.

The PR also adds a C14 entry to CONVENTIONS.md and two CHANGELOG entries.

Motivation and Context

There is no open issue for this. It is the same root cause as #1535 (fixed in #1572), but in the repeatable save and render paths.

Repro: register a repeatable text field, add three rows a, 0 and b, and save. After the page reloads only a and b are left.

Risk Level

Low to medium. The PHP change is small. The template row change touches JS that every repeatable field uses.

sanitization_cb() has three callers: CMB2_Field::save_field() (form saves on every object type, options pages and the REST API) and CMB2::save_group_field() (group rows and each sub-field). The changed line only runs for repeatable fields. repeatable_rows() renders every repeatable field and repeatable group sub-field.

What changes: a 0 or '0' entry in a repeatable field is now kept and passed to the field type's sanitizer, like any other entry. I saved '0' through each repeatable type before and after the fix:

  • Kept as '0': text, text_small, hidden, textarea, textarea_small, textarea_code, select, oembed, text_date, text_time, and repeatable sub-fields in a group.
  • text_money: saved as '0.00', repeatable or not.
  • text_url: its sanitizer treats '0' as an invalid URL, so the entry becomes the field default, or false when there is no default.
  • text_email: the entry becomes '', same as any other invalid email.
  • text_date_timestamp: saved as timestamp 0, same as a non-repeatable field.
  • text_datetime_timestamp(_timezone): a '0' entry is still dropped as an invalid date. A real epoch 0 is now kept on save.
  • colorpicker and file_list: no change, their own checks still drop it.

Custom sanitization_cb callbacks, cmb2_sanitize_{$type} filters and sanitization_cb => false now get '0' entries in repeatable arrays. There are no extra database writes and no hook fires a different number of times. Data that is already saved is not touched.

Known limit: a repeatable date timestamp row stored as 0 renders blank, because get_timestamp_format() treats 0 as empty. So the next admin save drops that row. Before this PR the row was never stored. Fixing the render would change how every date field shows a stored 0, so I left it out and noted it in C14 and the CHANGELOG.

Back-compat for the template row: custom JS that copies .empty-row by itself (not through addAjaxRow) has to remove disabled from the copied inputs. The CHANGELOG says this.

Testing procedure

  • Tests in Test_CMB2_Field. All of them go through save_field():
    • test_repeatable_field_keeps_zero_value: a text field saves array( 'a', '0', '', 'b' ) and gets array( 'a', '0', 'b' ). This also checks that '' is still dropped.
    • test_repeatable_textarea_code_keeps_zero_value: the same for textarea_code, which covers _check_repeat().
    • test_repeatable_text_money_formats_zero_value: array( '1', '0', '', '2' ) gives array( '1.00', '0.00', '2.00' ).
    • test_repeatable_field_keeps_zero_only_value: array( '0' ) gives array( '0' ).
    • test_repeatable_text_money_empty_rows_are_removed: array( '', '' ) stores nothing.
    • test_repeatable_field_without_sanitization_keeps_zero_value: sanitization_cb => false keeps '0'.
    • test_repeatable_field_sanitization_cb_receives_zero_value: a custom callback gets the '0' entry.
    • test_repeatable_text_datetime_timestamp_keeps_epoch_zero: an epoch 0 row is stored as 0.
  • Tests in Test_CMB2_Types:
    • test_repeatable_field_renders_zero_value_row: saves array( 'a', '0', 'b' ), renders the field, and checks the value="0" row.
    • test_repeatable_field_form_save_does_not_add_template_row: renders the field, posts the inputs a browser would post, saves again, and checks nothing was added. It covers a No/Yes select, an a/b select, a text field with default '0', and a field with its own disabled attribute.
    • test_text_money_field_value_update: a single money field with '0' now gives '0.00'. An empty value still removes the meta.
    • Two HTML snapshot tests now include the disabled and data-cmb2-template attributes on the template row.
  • I checked that the new tests fail with the fixes reverted. With only CMB2_Sanitize.php reverted, both the textarea_code and the text_money tests fail.
  • Full PHPUnit suite in wp-env (PHP 8.3, WordPress latest): 340 tests, 1096 assertions, all pass. composer phpcs and ESLint are clean. js/cmb2.min.js is rebuilt.
  • Manual test: add rows a, 0, b, save, reload, save again, reload. All three rows stay. A repeatable select with one row keeps one row after each save. Add Row still gives a working, enabled row.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist:

Screenshots

No UI changes.

@jtsternberg jtsternberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.

PR #1574: Fix repeatable fields dropping 0 values on save

Head 65d78ab, base develop. Reviewed in a worktree (cmb2-pr-1574) with its own wp-env (port 2634, PHP 8.3, WP latest).

Briefing

  • Where it comes from: the same bug class as #1535 (fixed by #1572), but in the repeatable save path. No issue was filed.
  • Root cause: CMB2_Field::sanitization_cb() strips empty repeatable entries with a callback-less array_filter(), which also drops 0/'0'. CMB2_Sanitize::_check_repeat() drops them a second time with a truthiness check. The diagnosis is correct for the save path.
  • The fix: use CMB2_Utils::filter_empty() / notempty() in those two places and in the text_money repeatable loop, and add convention entry C14.
  • Trade-offs: a '0' entry now reaches each type's sanitizer, the custom sanitization_cb, and cmb2_sanitize_{$type}. Some types turn it into a junk entry (false, '', 0). The render path still drops 0, so in the admin UI the saved value disappears again on the next save.

Verdict: REQUEST CHANGES

One blocker (F1). The PR's own repro ("add rows a, 0, b, save, reload") still shows only a and b after the fix. The 0 is in the database, but the next save of the post deletes it. The other findings are should-fix and nit items that can land in the same pass.

Findings

F1 — Scope/correctness · High · blocking — includes/CMB2_Types.php:358, :364

CMB2_Types::repeatable_rows() builds the edit-screen rows with two callback-less array_filter() calls, so a stored '0' row is never rendered. The fix therefore stores the 0 only until someone next saves the post through the admin UI, and that save removes it. Programmatic and REST writes keep the 0, but the admin round trip still loses it. The description's repro is not fixed.

Evidence: probe Test_Probe_1574::test_probe_render_round_trip (save, render, re-submit the rendered rows):

[PROBE] stored after save 1: array ( 0 => 'a', 1 => '0', 2 => 'b', )
[PROBE] rows rendered on edit screen: array ( 0 => 'a', 1 => 'b', )
[PROBE] stored after re-saving rendered form: array ( 0 => 'a', 1 => 'b', )
1) Test_Probe_1574::test_probe_render_round_trip — Failed asserting that an array contains '0'.

This affects repeatable sub-fields in groups too, because they render through the same method. The repeatable text_date_timestamp epoch-0 row (F4) is also hidden by it.

Recommendation: switch both filters in repeatable_rows() to CMB2_Utils::filter_empty(). Add a regression test that saves array( 'a', '0', 'b' ), renders the field, and asserts that a value="0" row is present outside the hidden empty-row. Note: repeatable_rows() probably needs the same "only entry is '0'" decision as F2 (today array( '0' ) renders as one default row).

F2 — Design · Medium · should-fix — CONVENTIONS.md (C14 canonical examples), includes/CMB2_Sanitize.php:125, :616

C14 and the PR description say "a repeatable field whose only entry is '0' is still removed by _is_empty_array()". That is true only for types that go through default_sanitization() (text, select, hidden, …). Types with their own sanitizer never reach that check, so with this PR alone a '0'-only field behaves differently by type:

[PROBE] ['0'] only, text:                exists=false: ''
[PROBE] ['0'] only, select:              exists=false: ''
[PROBE] ['0'] only, textarea:            exists=true: array ( 0 => '0', )
[PROBE] ['0'] only, textarea_code:       exists=true: array ( 0 => '0', )
[PROBE] ['0'] only, text_money:          exists=true: array ( 0 => '0.00', )
[PROBE] ['0'] only, text_date_timestamp: exists=true: array ( 0 => 0, )
[PROBE] ['0'] only, text_url:            exists=true: array ( 0 => false, )
[PROBE] ['0'] only, text_email:          exists=true: array ( 0 => '', )

On develop every one of these is exists=false. #1572 changes _is_empty_array() to filter_empty(), which removes the split.

Recommendation: #1572 is now merged into develop, so rebase onto it — that removes the split. Then correct the C14 sentence so it describes what the code actually does, and add a zero-only repeatable test (array( '0' ) → array( '0' )).

F3 — Correctness · Medium · should-fix / question — includes/CMB2_Sanitize.php:259

text_money keeps the scalar early return if ( ! $this->value ) return '';. With this PR, a non-repeatable '0' money field is removed while a repeatable '0' entry is saved as '0.00':

[PROBE] non-repeatable money "0" metadata_exists: false
[PROBE] repeatable money ["0"] stored: array ( 0 => '0.00', )

That contradicts C14's "Not staged" reasoning that "non-repeatable fields already keep it". For text_money they don't. History: jrfnl's 2167d89 (PR #577) guarded only '' === $this->value, to stop empty money fields being saved as 0.00. The later commit 29e3248 (@jtsternberg, "A little cleanup and give props") widened that guard to truthiness, which also catches '0'. Neither the commit nor PR #577 gives a reason to drop 0.

Question for the author: should scalar money become CMB2_Utils::isempty(), which keeps jrfnl's empty guard and saves '0' as '0.00'? Or should repeatable money match the scalar behavior? Either choice is defensible, but the two paths shouldn't disagree. Whatever is chosen, fix the C14 claim.

F4 — Back-compat · Low · should-fix — CONVENTIONS.md C14 "Blast radius" / missing CHANGELOG

Who now sees a '0' that they didn't see before (confirmed by probe on head vs develop):

  • Custom sanitization_cb and cmb2_sanitize_text now receive array( 0=>'a', 1=>'0', 2=>'b' ) instead of array( 0=>'a', 2=>'b' ).
  • sanitization_cb => false now stores array( 'a', '0', 'b' ).
  • Stored data shape: junk entries now appear where nothing used to be. Repeatable text_url gets false, text_email gets '', and text_date_timestamp gets 0 (epoch). For example [ 'http://a.test', false ]. Anything that reads the data directly sees them: get_post_meta() callers, REST output, and the metadata_exists() result for '0'-only fields. C14 lists these, but the CHANGELOG has to say so too.
  • Not affected: hook firing counts and database write counts. save_field() makes the same single update_data() / remove_data() call.

A claim from an earlier private review, that this PR moved the filter ahead of those seams, is refuted: The filter already ran before sanitization_cb, the false path, and cmb2_sanitize_{$type} on develop (array_filter sits in the same place, from 106f91e in 2014). Only which entries survive it changed. The contract half of the claim holds.

C3: Justin confirmed this doesn't need staging. It restores data the user typed, and the observers are filters, not a stored-value reinterpretation. But C14's "No configuration relies on a saved 0 vanishing" can't be verified. Reword it to the actual argument: silent data loss, plus the group precedent in dba5776.

Recommendation: add the CHANGELOG entry before merge (the author deferred it until #1572 landed; it has now). It should name the three sanitizer seams that now receive '0'. Also consider whether a repeatable text_url/text_email should drop entries that sanitize to false/'' instead of storing them.

F5 — Testing · Medium · should-fix — tests/test-cmb-field.php:302-336

The PR's tests are real: all three fail with includes/ reverted to develop. With only CMB2_Sanitize.php reverted, two fail (textarea_code and text_money). The description mentions only textarea_code. Coverage that's missing:

  • Render round trip (F1).
  • Empty-only repeatable money. Probe: array( '', '' ) → metadata_exists false, so 2167d89's fence holds. Pin it with a test next to the explicit-zero money test.
  • sanitization_cb => false and a custom callback receiving '0' (F4).
  • Datetime: a repeatable text_datetime_timestamp epoch-0 row is now stored as 0. My probe got array( 0 => 0, 1 => 1577959200 ); on develop that row is dropped. No test covers it.

The fcb112c repeatable timezone-in-group fence is still covered by its own Test_CMB2_Types::test_save_group, which passes on head.

F6 — Clarity · nit — CONVENTIONS.md:415

#1571 also adds ## C14 (taxonomy value_field). Whichever PR lands second needs to be renumbered to C15.

F7 — Clarity · nit — includes/CMB2_Sanitize.php:275

After filter_empty() runs upstream, the notempty( $val ) guard in the money loop only matters for callers that build CMB2_Sanitize directly. That's fine to keep. No change needed unless you want to note it inline.

Fences checked

  • array_filter( $meta_value ) in sanitization_cb() → 106f91e (2014, original tables-to-classes conversion). Never revisited for 0: 7e521ed (#135) moved the scalar checks to is_null, and dba5776 (2.2.2) fixed groups only. Changed. Its empty-removal need is kept.
  • if ( $this->value ) / ! empty( $val ) in _check_repeat() → fcb112c (#330: repeatable datetime saving the current time). It exists to skip blank rows and failed parses (''/false). Losing 0 was collateral: I checked the commit message, the diff, and the #330 body, and none of them mention zero. Changed. The skip for blank rows and failed parses is kept. The UTC sidecar stays aligned, because a '0' entry returns '' before $utc_values is pushed.
  • if ( $val ) in the text_money repeatable loop → 2167d89 (jrfnl, PR #577, '' === $val), widened to truthiness in 29e3248 (cleanup). Changed back toward the original intent. The empty guard is kept (probe: ['',''] stores nothing).
  • if ( ! $this->value ) return ''; scalar money early return → same pair of commits. Kept, which creates the F3 inconsistency.
  • array_filter() in CMB2_Types::repeatable_rows() → 934078f / 4d11c11 (2014, and older code before that). It skips blank rows and falls back to default. Not touched (F1).
  • _is_empty_array()'s array_filter → 2.0.6 era. Not touched. #1572 changes it (F2).

Not covered: PHP 7.4 wasn't run locally (C7). I read the diff by eye and it uses no syntax newer than 5.x. Full suite on head: OK (331 tests, 1076 assertions).

Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.

Repeatable text, textarea_code and text_money fields drop or skip a
'0' entry on save. These tests fail until the fix lands.
sanitization_cb() used array_filter() and _check_repeat() used
truthiness checks, so a '0' entry was removed like an empty one.
Use CMB2_Utils::filter_empty() and notempty() instead, the same way
group fields keep '0' since 2.2.2. A repeatable text_money '0' entry
is now formatted as '0.00' like its siblings.
repeatable_rows() used array_filter(), so a saved 0 row was hidden on
the edit screen and dropped on the next save. Use filter_empty() there
too.

A single text_money field now saves '0' as '0.00', the same as a
repeatable entry. An empty value is still removed.

Add tests for 0-only values, empty money rows, sanitization_cb false,
a custom sanitization_cb, and an epoch-0 datetime row.
The hidden empty-row template was submitted with every form save. Its
value (a '0' default, or the first select option) was saved as an
extra row.

Render the template inputs as disabled with data-cmb2-template, so the
browser does not submit them. addAjaxRow enables them when a new row
is added. A field that sets its own disabled attribute is left as is.
@faisalahammad
faisalahammad force-pushed the fix/repeatable-zero-value branch from 65d78ab to 71a0094 Compare October 12, 2026 11:23
@faisalahammad

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I rebased onto develop (with #1572) and pushed. New head is 71a0094.

F1 (render path). Fixed. repeatable_rows() now uses filter_empty() in both places. test_repeatable_field_renders_zero_value_row saves a, 0, b, renders the field and checks the value="0" row.

This fix showed one more bug. The hidden empty-row template is posted with every form save. array_filter() hid it only when the template value was '0'. With filter_empty(), a repeatable select with options No/Yes stored array( '1', '0' ) after one save. On develop the same bug already happens with other values: an a/b select gets an extra 'a' row on each save.

So the template row inputs now render with disabled and data-cmb2-template, and the browser does not post them. addAjaxRow enables them when the row is added. A field with its own disabled attribute is left as is. test_repeatable_field_form_save_does_not_add_template_row renders the field, posts what a browser would post, and saves again. It failed before the fix. This goes a bit past F1, so tell me if you want it in a separate PR.

F2 ('0'-only field). The rebase onto #1572 removes the split by type. I fixed the C14 sentence: a '0'-only field is now kept, unless the type's own sanitizer drops it (colorpicker, file_list). Added test_repeatable_field_keeps_zero_only_value.

F3 (scalar money). I picked CMB2_Utils::isempty(). A single money field with '0' now saves '0.00', same as a repeatable entry. jrfnl's empty guard stays: an empty field is still removed. This changes one assertion in test_text_money_field_value_update (from adc75f6), and I added a check there that '' still removes the meta.

F4 (CHANGELOG and C14 wording). Added a CHANGELOG entry. It names sanitization_cb, cmb2_sanitize_{$type} and sanitization_cb => false, the text_url/text_email/timestamp entries, and money 0.00. C14's "not staged" part now gives the real reason: silent data loss, plus the group precedent in dba5776.

I did not change how text_url/text_email store invalid entries. A '0' now acts like any other invalid input, and develop already stores false/'' for those. Changing that would affect every invalid value, not just 0.

One limit I found: a repeatable date timestamp row stored as 0 renders blank, because get_timestamp_format() treats 0 as empty. So the next admin save drops it. Before this PR that row was never stored, so this is not a regression. Fixing it would change how every date field shows a stored 0, so I only noted it in C14 and the CHANGELOG.

F5 (tests). Added tests for empty-only money (array( '', '' ) stores nothing), sanitization_cb => false, a custom callback getting '0', and an epoch 0 datetime row. The PR description now says that with only CMB2_Sanitize.php reverted, both the textarea_code and text_money tests fail.

F6 (C14 number). No change for now. #1571 is still open. Whichever PR lands second will rename its entry to C15.

F7. No change.

Full suite in wp-env (PHP 8.3, WP latest): 340 tests, 1096 assertions, all pass. composer phpcs and ESLint are clean. I did not run PHP 7.4 locally. CI covers it.

@codecov

codecov Bot commented Oct 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.08%. Comparing base (c244c31) to head (71a0094).

Additional details and impacted files
@@              Coverage Diff              @@
##             develop    #1574      +/-   ##
=============================================
+ Coverage      72.16%   73.08%   +0.91%     
- Complexity      1911     1912       +1     
=============================================
  Files             53       53              
  Lines           5034     5041       +7     
=============================================
+ Hits            3633     3684      +51     
+ Misses          1401     1357      -44     
Flag Coverage Δ
phpunit 73.08% <100.00%> (+0.91%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jtsternberg

Copy link
Copy Markdown
Member

Justin's agent here (Claude Code), on behalf of @jtsternberg.

@faisalahammad, Justin is merging this now. No further changes are needed from you.

In a follow-up commit on develop, the maintainers will remove one piece: a single (non-repeatable) text_money field saving 0 as 0.00. The history on #457 and #577 (empty money fields must not be saved as 0.00) pulls the other way, so that field keeps its current behavior. The repeatable 0 fix, the render fix, and the hidden template-row fix all stay as they are.

Justin's agent here (Claude Code), on behalf of @jtsternberg.

@jtsternberg
jtsternberg merged commit f1010c7 into CMB2:develop Oct 13, 2026
18 checks passed
jtsternberg added a commit that referenced this pull request Oct 13, 2026
#1574 also made a single text_money field save '0' as '0.00'.
Reverted here: the history on #457 and #577 is that an empty money
field must not be saved as 0.00. Repeatable '0' entries still format as
'0.00'. The test, changelog and C14 now say so.

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>
jtsternberg added a commit that referenced this pull request Oct 13, 2026
…-row-reset

Merged locally: CHANGELOG.md and js/cmb2.min.js conflicted with #1574.
Kept both changelog entries; rebuilt cmb2.min.js from the merged
cmb2.js with build:js:minify.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants