Skip to content

Copying a page tree with "Make cloned children unpublished?" silently empties repeater fields #2328

Description

@WebWorkingMan

Hey @ryancramerdesign

a customer of mine mentioned a strange bug that occurred while copying unpublished pages. Since I couldn't find any obvious errors, I gave Claude-Code access to the log files and the database, and here is his error report.

I hope this approach to report an issue is fine to you.

Cheers Jens


Short description of the issue

When a page tree is copied with "Make cloned children unpublished?" checked,
ProcessPageClone registers a cloneReady hook that adds statusUnpublished to every
page cloned during that request. Repeater item pages are cloned in the same request by
FieldtypeRepeater::___savePageField() via $pages->clone() — so they are unpublished too,
although they are not children in the page tree.

The items end up at status = 2049 (statusOn + statusUnpublished, without
statusHidden) and drop out of the field's value list: field_<name>.data for the copy stays
empty and count is 0. The content itself is fully intact in the database — text in all
languages, images, settings — it is just no longer linked, and therefore invisible in the
admin and in the frontend. No error, no warning.

ProcessPageClone.module:384–389:

if($cloneTree && $input->post('clone_tree_unpublished')) {
    $this->wire()->pages->addHook('cloneReady', function(HookEvent $event) use($page) {
        $child = $event->arguments(1); /** @var Page $child */
        if($page->id !== $child->id) $child->addStatus(Page::statusUnpublished);
    });
}

FieldtypeRepeater.module:1608–1615 — the clone that gets caught by it. It runs because the
copied page's repeater items still point at the original page's repeater parent:

if($p->parent->id != $parent_id) {
    // clone the individual repeater pages
    $value->remove($p);
    $p = $pages->clone($p, $repeaterParent, false, $saveOptions);
    $value->add($p);
    continue;
}

The $page->id !== $child->id guard does not help here — see the separate note on it below —
so repeater fields on the copied page are affected in the same way as those on its children.

Expected behavior

"Make cloned children unpublished?" unpublishes the cloned children, grandchildren etc. in the
page tree. Repeater item pages below /admin/repeaters/ are internal storage and keep the
status they had on the original.

Actual behavior

Every page cloned in the request is unpublished, repeater item pages included. Repeater and
RepeaterMatrix fields on the copy silently come out empty.

What makes this hard to notice: FieldtypeRepeater.module:1628–1631 re-adopts unpublished,
non-hidden items on the next save and publishes them (line 1652) once _repeater_processed is
set — which only the repeater editor in the admin does. So every section that happens to be
opened and saved after the copy heals itself, and every other one stays empty. The damage
therefore looks random rather than systematic, which is what delays the diagnosis.

On a production site this hit 170 repeater items across three page trees, at every nesting
level (repeaters inside repeater items included). Some of the affected items were still being
edited by the team four months later without anyone realising they were not rendering.

Steps to reproduce the issue

  1. Create page A with a child page B. Give B a Repeater (or RepeaterMatrix) field with
    2–3 filled items.
  2. In the page list, choose Copy on A, check "Copy children too?" and check
    "Make cloned children unpublished?", then save.
  3. Open the copy of B in the page editor → the repeater field is empty, while B's repeater
    field is unchanged.
  4. In the database, the items are still there:
    SELECT id, status FROM pages WHERE parent_id = <repeater parent of the copy>
    status = 2049. And SELECT data FROM field_<repeatername> WHERE pages_id = <copy of B>
    → no rows.
  5. Publish the copy, then open its repeater field in the editor and save once → the items
    reappear and are published (the self-healing path described above). Any section not touched
    this way stays empty indefinitely.

Measured on a real page tree (5 sections, 38 repeater items at section level, 268 pages
created by the clone), by registering exactly the hook from ProcessPageClone above and then
calling $pages->clone($page, $parent, true):

items at status = 2049
directly after the clone 206
after publishing the copy, as an editor would 138 — permanently lost

The difference of 68 are FieldtypeFieldsetPage items, and those are not part of this bug:
FieldtypeFieldsetPage::___savePageField() (around line 446) intentionally mirrors the owner
page's unpublished/hidden status onto its fieldset item and removes it again when the owner is
published. Only the FieldtypeRepeater items stay behind.

Optional: Suggestion for a possible fix

Exclude repeater pages from the hook:

$this->wire()->pages->addHook('cloneReady', function(HookEvent $event) use($page) {
    $child = $event->arguments(1); /** @var Page $child */
    if($page->id === $child->id) return;
    if($child instanceof RepeaterPage) return; // internal repeater storage, not a tree child
    $child->addStatus(Page::statusUnpublished);
});

A parent-based check would work as well (whether $child->parent lives below the repeaters
root, FieldtypeRepeater::repeatersRootPageName / name prefix
FieldtypeRepeater::repeaterPageNamePrefix) — you will know which of the two is the canonical
test here.

Possibly related in mechanism: #2212 (a status bit propagating onto repeater item pages that
should not receive it).

Side note: the $page->id !== $child->id guard never matches

PagesEditor::_clone() sets the copy's id to 0 before firing cloneReady
($copy->setQuietly('id', $options['forceID'] > 1 ? (int) $options['forceID'] : 0);,
wire/core/Pages/PagesEditor.php:1593 in 3.0.271 dev; cloneReady is called at line 1620).
$page->id !== $child->id is therefore always true, and the top-level copy itself gets
unpublished as well — not only the children, as the option's label suggests. That may be
intended, but if it is, the guard is dead code; if it is not, it is a second small bug in the
same five lines.

Workaround for anyone hitting this before a fix ships

A userland hook in site/ready.php, verified against the reproduction above. The core hook is
registered at request time with the default priority of 100, so a higher number is required in
order to run after it (WireHooks sorts ascending and re-sorts on every addHook()):

$this->wire()->pages->addHookAfter('cloneReady', function(HookEvent $event) {
    $source = $event->arguments(0);
    $copy = $event->arguments(1);
    if(!$copy instanceof RepeaterPage) return;      // repeater items only
    if($source->isUnpublished()) return;            // source itself unpublished: leave it
    if(!$source->hasStatus(Page::statusOn)) return; // deliberately switched off: leave it
    if(!$copy->isUnpublished()) return;
    $copy->removeStatus(Page::statusUnpublished);
}, array('priority' => 200));

⚠ Note for anyone writing repair code for existing damage

Status Meaning Treatment
2049 = statusOn + unpublished lost by the copy bug repair
2048 = unpublished without statusOn deliberately switched off by an editor do not touch
3073 = + hidden empty "ready page" ignore

The clone hook cannot produce 2048 (it never removes statusOn), so the distinction is
provable rather than merely plausible. On the site mentioned above, a repair run without this
check would have re-enabled 93 deliberately disabled elements in a single page tree.

Setup/Environment

  • ProcessWire version: observed on 3.0.255, reproduced on 3.0.246; the code in question is
    unchanged on the current dev branch (3.0.271, checked 2026-08-31)
  • (Optional) PHP version: 8.x
  • (Optional) Any 3rd party modules that are installed and could be related to the issue:
    FieldtypeRepeaterMatrix (the same happens with the core Repeater fieldtype)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions