Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/Component.php
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,9 @@ public function __unset(string $name): void
*/
public function __clone()
{
if (null !== self::$cloneRoot) {
$this->root = self::$cloneRoot;
}
foreach ($this->children as $childName => $childGroup) {
foreach ($childGroup as $key => $child) {
$clonedChild = clone $child;
Expand Down
17 changes: 17 additions & 0 deletions lib/Document.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ public function __construct()
parent::__construct($this, $name, $children, $defaults);
}

/**
* This method is automatically called when the object is cloned.
*
* The copy becomes the root of all its cloned nodes, so it does not depend
* on the original document (e.g. to resolve a TZID against its VTIMEZONE).
*/
public function __clone()
{
$previousCloneRoot = self::$cloneRoot;
self::$cloneRoot = $this;
try {
parent::__clone();
} finally {
self::$cloneRoot = $previousCloneRoot;
}
}

/**
* Returns the current document type.
*/
Expand Down
8 changes: 8 additions & 0 deletions lib/Node.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ abstract class Node implements \IteratorAggregate, \ArrayAccess, \Countable, \Js
*/
protected ?Component $root = null;

/**
* The copy under construction while a Document is being cloned.
*
* Every node cloned as part of that copy takes it as its root, instead of
* keeping a reference to the original document.
*/
protected static ?Document $cloneRoot = null;

/**
* Serializes the node into a mimedir format.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/Property.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,9 +453,13 @@ public function offsetUnset($offset): void
*/
public function __clone()
{
if (null !== self::$cloneRoot) {
$this->root = self::$cloneRoot;
}
foreach ($this->parameters as $key => $child) {
$this->parameters[$key] = clone $child;
$this->parameters[$key]->parent = $this;
$this->parameters[$key]->root = $this->root;
}
}

Expand Down
118 changes: 118 additions & 0 deletions tests/VObject/DocumentCloneTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace Sabre\VObject;

use PHPUnit\Framework\TestCase;

class DocumentCloneTest extends TestCase
{
private const ICS = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Sabre//Sabre VObject//EN
BEGIN:VTIMEZONE
TZID:Custom Eastern
X-LIC-LOCATION:America/New_York
BEGIN:STANDARD
TZOFFSETFROM:-0400
TZOFFSETTO:-0400
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:clone-test
DTSTAMP:20260101T000000Z
DTSTART;TZID=Custom Eastern:20260322T090000
SUMMARY:Test
ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION:mailto:attendee@example.org
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER;RELATED=START:-PT15M
DESCRIPTION:Reminder
END:VALARM
END:VEVENT
END:VCALENDAR
ICS;

public function testClonedDocumentIsTheRootOfAllItsNodes(): void
{
$doc = Reader::read(self::ICS);

$copy = clone $doc;

$nodes = $this->collectNodes($copy);
self::assertContains($copy->VEVENT->VALARM, $nodes);
self::assertContains($copy->VEVENT->VALARM->TRIGGER['RELATED'], $nodes);
foreach ($nodes as $node) {
self::assertSame($copy, $this->getRoot($node), $node->name.' should belong to the copy');
}
}

public function testClonedDocumentDoesNotChangeTheOriginal(): void
{
$doc = Reader::read(self::ICS);

clone $doc;

foreach ($this->collectNodes($doc) as $node) {
self::assertSame($doc, $this->getRoot($node), $node->name.' should belong to the original');
}
}

public function testClonedDocumentResolvesCustomTimezoneAfterOriginalIsDestroyed(): void
{
$doc = Reader::read(self::ICS);

$copy = clone $doc;
$doc->destroy();

self::assertEquals(
new \DateTimeImmutable('2026-03-22T13:00:00Z'),
$copy->VEVENT->DTSTART->getDateTime()
);
}

public function testClonedComponentKeepsTheOriginalRoot(): void
{
$doc = Reader::read(self::ICS);

$event = clone $doc->VEVENT;

foreach ($this->collectNodes($event) as $node) {
self::assertSame($doc, $this->getRoot($node), $node->name.' should belong to the original document');
}
}

public function testClonedPropertyKeepsTheOriginalRoot(): void
{
$doc = Reader::read(self::ICS);

$dtStart = clone $doc->VEVENT->DTSTART;

self::assertSame($doc, $this->getRoot($dtStart));
self::assertSame($doc, $this->getRoot($dtStart['TZID']));
self::assertSame($dtStart, $dtStart['TZID']->parent);
}

/**
* @return Node[]
*/
private function collectNodes(Node $node): array
{
$nodes = [$node];
if ($node instanceof Component) {
foreach ($node->children() as $child) {
array_push($nodes, ...$this->collectNodes($child));
}
} elseif ($node instanceof Property) {
array_push($nodes, ...array_values($node->parameters()));
}

return $nodes;
}

private function getRoot(Node $node): ?Component
{
return (new \ReflectionProperty(Node::class, 'root'))->getValue($node);
}
}