diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdc2372b..dd282426 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,12 @@ Changelogs prior to v2.0 is pruned, but was available in the v2.x releases
This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence.
+## [Unreleased]
+
+### Fixed
+
+* Reusing a `CalDAVSearcher` across multiple `search()` calls could yield inconsistent results: the first call would return only pending tasks (correct), but subsequent calls would change behaviour because `icalendar_searcher.Searcher.check_component()` mutated the `include_completed` field from `None` to `False` as a side-effect. Fixed by passing a copy with `include_completed` already resolved to `filter_search_results()`, leaving the original searcher object unchanged. Fixes https://github.com/python-caldav/caldav/issues/650
+
## [3.1.0] - 2026-03-19
Highlights:
diff --git a/caldav/search.py b/caldav/search.py
index b1a639e7..856df01e 100644
--- a/caldav/search.py
+++ b/caldav/search.py
@@ -807,9 +807,17 @@ def filter(
:param server_expand: Whether server was asked to expand recurrences
:return: Filtered and/or split list of CalendarObjectResource objects
"""
+ ## icalendar_searcher.Searcher.check_component() mutates include_completed from
+ ## None to the effective default (not self.todo) on first use, and also mutates
+ ## event/journal/todo flags from None to True/False. This breaks reuse of a
+ ## CalDAVSearcher instance across multiple search() calls (issue #650).
+ ## Use a copy with include_completed already resolved so the original is unchanged.
+ searcher = self
+ if self.include_completed is None:
+ searcher = replace(self, include_completed=not self.todo if self.todo else True)
return filter_search_results(
objects=objects,
- searcher=self,
+ searcher=searcher,
post_filter=post_filter,
split_expanded=split_expanded,
server_expand=server_expand,
diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py
index 5777a73a..94aa4d38 100755
--- a/tests/test_caldav_unit.py
+++ b/tests/test_caldav_unit.py
@@ -159,6 +159,47 @@
END:VTODO
END:VCALENDAR"""
+## Mock response with 2 pending and 2 completed todos, for testing include_completed behavior
+## https://github.com/python-caldav/caldav/issues/650
+mixed_todos_response = """
+
+ /calendar/pending1.ics
+
+
+ BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:pending1\r\nSUMMARY:Pending 1\r\nSTATUS:NEEDS-ACTION\r\nDTSTAMP:20250101T000000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n
+
+ HTTP/1.1 200 OK
+
+
+
+ /calendar/pending2.ics
+
+
+ BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:pending2\r\nSUMMARY:Pending 2\r\nDTSTAMP:20250101T000000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n
+
+ HTTP/1.1 200 OK
+
+
+
+ /calendar/completed1.ics
+
+
+ BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:completed1\r\nSUMMARY:Completed 1\r\nSTATUS:COMPLETED\r\nDTSTAMP:20250101T000000Z\r\nCOMPLETED:20250101T120000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n
+
+ HTTP/1.1 200 OK
+
+
+
+ /calendar/completed2.ics
+
+
+ BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:completed2\r\nSUMMARY:Completed 2\r\nSTATUS:COMPLETED\r\nDTSTAMP:20250101T000000Z\r\nCOMPLETED:20250101T120000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n
+
+ HTTP/1.1 200 OK
+
+
+"""
+
## from https://github.com/python-caldav/caldav/issues/495
recurring_task_response = """
@@ -342,11 +383,54 @@ def testSearchForRecurringTask(self):
expand=True,
start=datetime(2025, 1, 1),
end=datetime(2025, 6, 5),
- ## TODO - TEMP workaround for compatibility issues! post_filter should not be needed!
- post_filter=True,
)
assert len(mytasks) == 9
+ def testSearcherReuseConsistency_Issue650(self):
+ """
+ Regression test for https://github.com/python-caldav/caldav/issues/650
+
+ A CalDAVSearcher with todo=True and default include_completed (None) should
+ return consistent results across multiple search() calls. Previously, the
+ icalendar_searcher library would mutate include_completed from None to False
+ during the first search(), changing which code path subsequent calls took.
+ """
+ client = MockedDAVClient(mixed_todos_response)
+ calendar = Calendar(client, url="/calendar/issue650/")
+
+ ## Test 1: searcher with include_completed=None (default) should give consistent results
+ searcher = calendar.searcher(todo=True)
+ assert searcher.include_completed is None, "include_completed should start as None"
+
+ first_result = searcher.search()
+ assert len(first_result) == 2, f"Expected 2 pending todos, got {len(first_result)}"
+
+ ## After calling search(), include_completed must not have been mutated
+ assert searcher.include_completed is None, (
+ "include_completed was mutated from None during search() - "
+ "this breaks reuse of the searcher object (issue #650)"
+ )
+
+ second_result = searcher.search()
+ assert len(second_result) == 2, (
+ f"Second search() call returned {len(second_result)} results, "
+ f"expected 2 - inconsistent behavior after searcher reuse (issue #650)"
+ )
+
+ ## Test 2: explicit include_completed=False should also give correct results
+ searcher_false = calendar.searcher(todo=True, include_completed=False)
+ result_false = searcher_false.search()
+ assert len(result_false) == 2, (
+ f"include_completed=False returned {len(result_false)} results, expected 2"
+ )
+
+ ## Test 3: include_completed=True should return all todos
+ searcher_true = calendar.searcher(todo=True, include_completed=True)
+ result_true = searcher_true.search()
+ assert len(result_true) == 4, (
+ f"include_completed=True returned {len(result_true)} results, expected 4"
+ )
+
def testLoadByMultiGet404(self):
xml = """