diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php
index 5acd65cc..95b190ce 100644
--- a/src/Plugin/search_api/datasource/ContentEntity.php
+++ b/src/Plugin/search_api/datasource/ContentEntity.php
@@ -666,6 +666,13 @@ protected function getEntityBundles() {
    * {@inheritdoc}
    */
   public function getPartialItemIds($page = NULL, array $bundles = NULL, array $languages = NULL) {
+    // These would be pretty pointless calls, but for the sake of completeness
+    // we should check for them and return early. (Otherwise makes the rest of
+    // the code more complicated.)
+    if (($bundles === [] && !$languages) || ($languages === [] && !$bundles)) {
+      return NULL;
+    }
+
     $select = $this->getEntityTypeManager()
       ->getStorage($this->getEntityTypeId())
       ->getQuery();
@@ -698,6 +705,9 @@ public function getPartialItemIds($page = NULL, array $bundles = NULL, array $la
       $page_size = $this->getConfigValue('tracking_page_size');
       assert('$page_size', 'Tracking page size is not set.');
       $select->range($page * $page_size, $page_size);
+      // For paging to reliably work, a sort should be present.
+      $entity_id = $this->getEntityType()->getKey('id');
+      $select->sort($entity_id);
     }
 
     $entity_ids = $select->execute();
@@ -723,10 +733,12 @@ public function getPartialItemIds($page = NULL, array $bundles = NULL, array $la
     /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
     foreach ($this->getEntityStorage()->loadMultiple($entity_ids) as $entity_id => $entity) {
       $translations = array_keys($entity->getTranslationLanguages());
-      if (!isset($bundles) || in_array($entity->bundle(), $bundles)) {
-        $translations = array_intersect($translations, $enabled_languages);
-      }
-      else {
+      $translations = array_intersect($translations, $enabled_languages);
+      // If only languages were specified, keep only those translations matching
+      // them. If bundles were also specified, keep all (enabled) translations
+      // for those entities that match those bundles.
+      if ($languages !== NULL
+          && (!$bundles || !in_array($entity->bundle(), $bundles))) {
         $translations = array_intersect($translations, $languages);
       }
       foreach ($translations as $langcode) {
diff --git a/src/Plugin/search_api/datasource/EntityDatasourceInterface.php b/src/Plugin/search_api/datasource/EntityDatasourceInterface.php
index 82bf4878..db92c895 100644
--- a/src/Plugin/search_api/datasource/EntityDatasourceInterface.php
+++ b/src/Plugin/search_api/datasource/EntityDatasourceInterface.php
@@ -35,9 +35,11 @@ public static function getIndexesForEntity(ContentEntityInterface $entity);
    *   (optional) The languages for which all item IDs should be returned; or
    *   NULL to retrieve IDs from all enabled languages in this datasource.
    *
-   * @return string[]
-   *   An array of all item IDs matching these conditions. In case both bundles
-   *   and languages are specified, they are combined with OR.
+   * @return string[]|null
+   *   An array of all item IDs matching these conditions; or NULL if a page was
+   *   specified and there are no more items for that and all following pages.
+   *   In case both bundles and languages are specified, they are combined with
+   *   OR.
    */
   public function getPartialItemIds($page = NULL, array $bundles = NULL, array $languages = NULL);
 
diff --git a/tests/src/Kernel/ContentEntityDatasourceTest.php b/tests/src/Kernel/ContentEntityDatasourceTest.php
index 04ce3592..6b90ba9c 100644
--- a/tests/src/Kernel/ContentEntityDatasourceTest.php
+++ b/tests/src/Kernel/ContentEntityDatasourceTest.php
@@ -53,6 +53,13 @@ class ContentEntityDatasourceTest extends KernelTestBase {
   protected $datasource;
 
   /**
+   * The item IDs of all items that can be part of the datasource.
+   *
+   * @var string[]
+   */
+  protected $allItemIds;
+
+  /**
    * {@inheritdoc}
    */
   public function setUp() {
@@ -89,14 +96,7 @@ public function setUp() {
     $this->datasource = $this->index->getDatasource('entity:' . $this->testEntityTypeId);
 
     $this->setUpExampleStructure();
-  }
 
-  /**
-   * Tests entity loading.
-   *
-   * @covers ::loadMultiple
-   */
-  public function testEntityLoading() {
     foreach (['item', 'article'] as $i => $bundle) {
       $entity = EntityTestMulRevChanged::create([
         'id' => $i + 1,
@@ -107,10 +107,17 @@ public function testEntityLoading() {
       $entity->addTranslation('l1')->save();
     }
 
-    $all_item_ids = ['1:l0', '1:l1', '2:l0', '2:l1'];
+    $this->allItemIds = ['1:l0', '1:l1', '2:l0', '2:l1'];
+  }
 
-    $loaded_items = $this->datasource->loadMultiple($all_item_ids);
-    $this->assertCorrectItems($all_item_ids, $loaded_items);
+  /**
+   * Tests entity loading.
+   *
+   * @covers ::loadMultiple
+   */
+  public function testEntityLoading() {
+    $loaded_items = $this->datasource->loadMultiple($this->allItemIds);
+    $this->assertCorrectItems($this->allItemIds, $loaded_items);
 
     $this->datasource->setConfiguration([
       'bundles' => [
@@ -122,7 +129,7 @@ public function testEntityLoading() {
         'selected' => ['l0'],
       ],
     ]);
-    $loaded_items = $this->datasource->loadMultiple($all_item_ids);
+    $loaded_items = $this->datasource->loadMultiple($this->allItemIds);
     $this->assertCorrectItems(['1:l1'], $loaded_items);
 
     $this->datasource->setConfiguration([
@@ -135,7 +142,7 @@ public function testEntityLoading() {
         'selected' => ['l0', 'l1'],
       ],
     ]);
-    $loaded_items = $this->datasource->loadMultiple($all_item_ids);
+    $loaded_items = $this->datasource->loadMultiple($this->allItemIds);
     $this->assertCorrectItems(['2:l0', '2:l1'], $loaded_items);
   }
 
@@ -162,4 +169,89 @@ protected function assertCorrectItems(array $expected_ids, array $loaded_items)
     }
   }
 
+  /**
+   * Verifies that paged item discovery works correctly.
+   *
+   * @covers ::getPartialItemIds
+   */
+  public function testItemDiscovery() {
+    // Set page size to 1 to also test paging.
+    \Drupal::configFactory()
+      ->getEditable('search_api.settings')
+      ->set('tracking_page_size', 1)
+      ->save();
+
+    // Test item discovery with various bundle/language combinations.
+    $discovered_ids = $this->getItemIds();
+    $this->assertEquals($this->allItemIds, $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(['item']);
+    $this->assertEquals(['1:l0', '1:l1'], $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(['item'], []);
+    $this->assertEquals(['1:l0', '1:l1'], $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(NULL, ['l0']);
+    $this->assertEquals(['1:l0', '2:l0'], $discovered_ids);
+
+    $discovered_ids = $this->getItemIds([], ['l0']);
+    $this->assertEquals(['1:l0', '2:l0'], $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(['item'], ['l0']);
+    $this->assertEquals(['1:l0', '1:l1', '2:l0'], $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(['item', 'article'], ['l0']);
+    $this->assertEquals($this->allItemIds, $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(['item'], ['l0', 'l1']);
+    $this->assertEquals($this->allItemIds, $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(['item', 'article'], []);
+    $this->assertEquals($this->allItemIds, $discovered_ids);
+
+    $discovered_ids = $this->getItemIds([], ['l0', 'l1']);
+    $this->assertEquals($this->allItemIds, $discovered_ids);
+
+    $discovered_ids = $this->getItemIds([], []);
+    $this->assertEquals([], $discovered_ids);
+
+    $discovered_ids = $this->getItemIds([], NULL);
+    $this->assertEquals([], $discovered_ids);
+
+    $discovered_ids = $this->getItemIds(NULL, []);
+    $this->assertEquals([], $discovered_ids);
+  }
+
+  /**
+   * Retrieves the IDs of all matching items from the test datasource.
+   *
+   * Will automatically use paging to go through the entire result set.
+   *
+   * If both $bundles and $languages are specified, they are combined with OR.
+   *
+   * @param string[]|null $bundles
+   *   (optional) The bundles for which all item IDs should be returned; or NULL
+   *   to retrieve IDs from all enabled bundles in this datasource.
+   * @param string[]|null $languages
+   *   (optional) The languages for which all item IDs should be returned; or
+   *   NULL to retrieve IDs from all enabled languages in this datasource.
+   *
+   * @return string[]
+   *   All discovered item IDs.
+   *
+   * @see \Drupal\search_api\Plugin\search_api\datasource\EntityDatasourceInterface::getPartialItemIds()
+   */
+  protected function getItemIds(array $bundles = NULL, array $languages = NULL) {
+    $discovered_ids = [];
+    for ($page = 0; ; ++$page) {
+      $new_ids = $this->datasource->getPartialItemIds($page, $bundles, $languages);
+      if ($new_ids === NULL) {
+        break;
+      }
+      $discovered_ids = array_merge($discovered_ids, $new_ids);
+    }
+    sort($discovered_ids);
+    return $discovered_ids;
+  }
+
 }
