Merge "Implement IndirectTouchScrollable in glimmer" into androidx-main
diff --git a/activity/activity-compose-lint/build.gradle b/activity/activity-compose-lint/build.gradle
index 92d6783..bde0e9d 100644
--- a/activity/activity-compose-lint/build.gradle
+++ b/activity/activity-compose-lint/build.gradle
@@ -43,7 +43,7 @@
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
     testRuntimeOnly(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
+    testImplementation(libs.kotlinStdlib)
     testImplementation(libs.androidLint)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/activity/activity-lint/build.gradle b/activity/activity-lint/build.gradle
index 6e6d4dc..a6bfccf 100644
--- a/activity/activity-lint/build.gradle
+++ b/activity/activity-lint/build.gradle
@@ -37,7 +37,6 @@
 
     testImplementation(libs.kotlinStdlib)
     testRuntimeOnly(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLint)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
index 531aa22..9958dee 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
@@ -890,6 +890,193 @@
     }
 
     @Test
+    @RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+    public void testReset_withBlob() throws Exception {
+        mAppSearchImpl = AppSearchImpl.create(
+                mAppSearchDir,
+                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                        new LocalStorageIcingOptionsConfig()),
+                /*initStatsBuilder=*/ null,
+                /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+                /*icingSearchEngine=*/ null,
+                ALWAYS_OPTIMIZE);
+        File blobFilesDir = new File(mAppSearchDir, "blob_dir/blob_files");
+
+        // Insert schema
+        List schemas = ImmutableList.of(
+                new AppSearchSchema.Builder("Type1").build(),
+                new AppSearchSchema.Builder("Type2").build());
+        InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                schemas,
+                /*visibilityConfigs=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /* setSchemaStatsBuilder= */ null);
+        assertThat(internalSetSchemaResponse.isSuccess()).isTrue();
+
+        // Insert a valid doc
+        GenericDocument validDoc =
+                new GenericDocument.Builder<>("namespace1", "id1", "Type1").build();
+        mAppSearchImpl.putDocument(
+                mContext.getPackageName(),
+                "database1",
+                validDoc,
+                /*sendChangeNotifications=*/ false,
+                /*logger=*/null);
+        // Query it via global query. We use the same code again later so this is to make sure we
+        // have our global query configured right.
+        SearchResultPage results = mAppSearchImpl.globalQuery(
+                /*queryExpression=*/ "",
+                new SearchSpec.Builder().addFilterSchemas("Type1").build(),
+                mSelfCallerAccess,
+                /*logger=*/ null);
+        assertThat(results.getResults()).hasSize(1);
+        assertThat(results.getResults().get(0).getGenericDocument()).isEqualTo(validDoc);
+
+        // Put a blob
+        byte[] blobData = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] blobDigest = calculateDigest(blobData);
+        AppSearchBlobHandle blobHandle = AppSearchBlobHandle.createWithSha256(
+                blobDigest, mContext.getPackageName(), "database1", "namespace1");
+        try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob(
+                mContext.getPackageName(), "database1", blobHandle);
+                OutputStream outputStream = new ParcelFileDescriptor
+                        .AutoCloseOutputStream(writePfd)) {
+            outputStream.write(blobData);
+            outputStream.flush();
+        }
+        // Commit and read the blob.
+        mAppSearchImpl.commitBlob(mContext.getPackageName(), "database1", blobHandle);
+        byte[] readBytes = new byte[20 * 1024];
+        try (ParcelFileDescriptor readPfd = mAppSearchImpl.openReadBlob(
+                mContext.getPackageName(), "database1", blobHandle);
+                InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPfd)) {
+            inputStream.read(readBytes);
+        }
+        assertThat(readBytes).isEqualTo(blobData);
+        // Check that the blob file is created by AppSearch.
+        if (Flags.enableAppSearchManageBlobFiles()) {
+            assertThat(blobFilesDir.list()).asList().hasSize(1);
+        }
+
+        // Create a doc with a malformed namespace
+        DocumentProto invalidDoc = DocumentProto.newBuilder()
+                .setNamespace("invalidNamespace")
+                .setUri("id2")
+                .setSchema(mContext.getPackageName() + "$database1/Type1")
+                .build();
+        AppSearchException e = assertThrows(
+                AppSearchException.class,
+                () -> PrefixUtil.getPrefix(invalidDoc.getNamespace()));
+        assertThat(e).hasMessageThat().isEqualTo(
+                "The prefixed value \"invalidNamespace\" doesn't contain a valid database name");
+
+        // Insert the invalid doc with an invalid namespace right into icing
+        PutResultProto putResultProto = mAppSearchImpl.mIcingSearchEngineLocked.put(invalidDoc);
+        assertThat(putResultProto.getStatus().getCode()).isEqualTo(StatusProto.Code.OK);
+
+        // Initialize AppSearchImpl. This should cause a reset.
+        InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder();
+        mAppSearchImpl.close();
+        mAppSearchImpl = AppSearchImpl.create(
+                mAppSearchDir,
+                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                        new LocalStorageIcingOptionsConfig()),
+                initStatsBuilder,
+                /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+                /*icingSearchEngine=*/ null,
+                ALWAYS_OPTIMIZE);
+
+        // Check recovery state
+        InitializeStats initStats = initStatsBuilder.build();
+        assertThat(initStats).isNotNull();
+        assertThat(initStats.getStatusCode()).isEqualTo(AppSearchResult.RESULT_INTERNAL_ERROR);
+        assertThat(initStats.hasDeSync()).isFalse();
+        assertThat(initStats.getNativeDocumentStoreRecoveryCause())
+                .isEqualTo(InitializeStats.RECOVERY_CAUSE_NONE);
+        assertThat(initStats.getNativeIndexRestorationCause())
+                .isEqualTo(InitializeStats.RECOVERY_CAUSE_NONE);
+        assertThat(initStats.getNativeSchemaStoreRecoveryCause())
+                .isEqualTo(InitializeStats.RECOVERY_CAUSE_NONE);
+        assertThat(initStats.getNativeDocumentStoreDataStatus())
+                .isEqualTo(InitializeStats.DOCUMENT_STORE_DATA_STATUS_NO_DATA_LOSS);
+        assertThat(initStats.hasReset()).isTrue();
+        assertThat(initStats.getResetStatusCode()).isEqualTo(AppSearchResult.RESULT_OK);
+
+        // Make sure all our data is gone
+        assertThat(mAppSearchImpl.getSchema(
+                        /*packageName=*/mContext.getPackageName(),
+                        /*databaseName=*/"database1",
+                        /*callerAccess=*/mSelfCallerAccess)
+                .getSchemas())
+                .isEmpty();
+        results = mAppSearchImpl.globalQuery(
+                /*queryExpression=*/ "",
+                new SearchSpec.Builder().addFilterSchemas("Type1").build(),
+                mSelfCallerAccess,
+                /*logger=*/ null);
+        assertThat(results.getResults()).isEmpty();
+
+        // Make sure blob files are deleted.
+        if (Flags.enableAppSearchManageBlobFiles()) {
+            assertThat(blobFilesDir.list()).isEmpty();
+        }
+
+        // Make sure the index can now be used successfully
+        internalSetSchemaResponse = mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                Collections.singletonList(new AppSearchSchema.Builder("Type1").build()),
+                /*visibilityConfigs=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /* setSchemaStatsBuilder= */ null);
+        assertThat(internalSetSchemaResponse.isSuccess()).isTrue();
+
+        // Insert a valid doc
+        mAppSearchImpl.putDocument(
+                mContext.getPackageName(),
+                "database1",
+                validDoc,
+                /*sendChangeNotifications=*/ false,
+                /*logger=*/null);
+        // Query it via global query.
+        results = mAppSearchImpl.globalQuery(
+                /*queryExpression=*/ "",
+                new SearchSpec.Builder().addFilterSchemas("Type1").build(),
+                mSelfCallerAccess,
+                /*logger=*/ null);
+        assertThat(results.getResults()).hasSize(1);
+        assertThat(results.getResults().get(0).getGenericDocument()).isEqualTo(validDoc);
+
+        // Put a blob
+        try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob(
+                mContext.getPackageName(), "database1", blobHandle);
+                OutputStream outputStream = new ParcelFileDescriptor
+                        .AutoCloseOutputStream(writePfd)) {
+            outputStream.write(blobData);
+            outputStream.flush();
+        }
+        // Commit and read the blob.
+        mAppSearchImpl.commitBlob(mContext.getPackageName(), "database1", blobHandle);
+        readBytes = new byte[20 * 1024];
+        try (ParcelFileDescriptor readPfd = mAppSearchImpl.openReadBlob(
+                mContext.getPackageName(), "database1", blobHandle);
+                InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPfd)) {
+            inputStream.read(readBytes);
+        }
+        assertThat(readBytes).isEqualTo(blobData);
+        // Check that the blob file is created by AppSearch.
+        if (Flags.enableAppSearchManageBlobFiles()) {
+            assertThat(blobFilesDir.list()).asList().hasSize(1);
+        }
+    }
+
+    @Test
     public void testResetWithSchemaDatabaseMigration() throws Exception {
         IcingSearchEngineOptions.Builder optionsBuilder =
                 IcingSearchEngineOptions.newBuilder(mUnlimitedConfig.toIcingSearchEngineOptions(
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
index 2c12d01..d28c5ce 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
@@ -3833,6 +3833,37 @@
     }
 
     /**
+     * Deletes all blob files managed by AppSearch.
+     *
+     * @throws AppSearchException if an I/O error occurs.
+     */
+    @GuardedBy("mReadWriteLock")
+    private void deleteBlobFilesLocked() throws AppSearchException {
+        if (!Flags.enableAppSearchManageBlobFiles()) {
+            return;
+        }
+        if (mBlobFilesDir.isFile() && !mBlobFilesDir.delete()) {
+            throw new AppSearchException(AppSearchResult.RESULT_IO_ERROR,
+                    "The blob file directory is a file and cannot delete it.");
+        }
+        if (!mBlobFilesDir.exists() && !mBlobFilesDir.mkdirs()) {
+            throw new AppSearchException(AppSearchResult.RESULT_IO_ERROR,
+                    "The blob file directory does not exist and cannot create a new one.");
+        }
+        File[] blobFiles = mBlobFilesDir.listFiles();
+        if (blobFiles == null) {
+            throw new AppSearchException(AppSearchResult.RESULT_IO_ERROR,
+                    "Cannot list the blob files.");
+        }
+        for (int i = 0; i < blobFiles.length; i++) {
+            File blobFile = blobFiles[i];
+            if (!blobFile.delete()) {
+                Log.e(TAG, "Cannot delete the blob file: " + blobFile.getName());
+            }
+        }
+    }
+
+    /**
      * Clears documents and schema across all packages and databaseNames.
      *
      * 

This method belongs to mutate group. @@ -3872,6 +3903,9 @@ } checkSuccess(resetResultProto.getStatus()); + + // Delete all blob files if AppSearch manages them. + deleteBlobFilesLocked(); } /** Wrapper around schema changes */

diff --git a/camera/camera-core/api/1.5.0-beta02.txt b/camera/camera-core/api/1.5.0-beta02.txt
index be3133e..76271a5 100644
--- a/camera/camera-core/api/1.5.0-beta02.txt
+++ b/camera/camera-core/api/1.5.0-beta02.txt
@@ -59,6 +59,7 @@
     method public default androidx.lifecycle.LiveData getTorchStrengthLevel();
     method public androidx.lifecycle.LiveData getZoomState();
     method public boolean hasFlashUnit();
+    method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig);
     method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction);
     method public default boolean isLogicalMultiCameraSupported();
     method public default boolean isLowLightBoostSupported();
@@ -638,12 +639,24 @@
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup, optional java.util.List preferredFeatureGroup);
     method public final java.util.List getEffects();
+    method public final androidx.core.util.Consumer> getFeatureSelectionListener();
+    method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor();
     method public final android.util.Range getFrameRateRange();
+    method public final java.util.List getPreferredFeatureGroup();
+    method public final java.util.Set getRequiredFeatureGroup();
     method public final java.util.List getUseCases();
     method public final androidx.camera.core.ViewPort? getViewPort();
+    method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener);
+    method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener);
     property public final java.util.List effects;
+    property public final androidx.core.util.Consumer> featureSelectionListener;
+    property public final java.util.concurrent.Executor featureSelectionListenerExecutor;
     property public final android.util.Range frameRateRange;
+    property public final java.util.List preferredFeatureGroup;
+    property public final java.util.Set requiredFeatureGroup;
     property public final java.util.List useCases;
     property public final androidx.camera.core.ViewPort? viewPort;
   }
@@ -654,6 +667,8 @@
     method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect);
     method public androidx.camera.core.SessionConfig build();
     method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.Range frameRateRange);
+    method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
+    method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
     method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort);
   }
 
@@ -765,6 +780,34 @@
 
 }
 
+package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup {
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature {
+    method public final int getFeatureType();
+    field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion;
+    field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0
+    field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1
+    field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3
+    field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2
+    field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion {
+    property public static int FEATURE_TYPE_DYNAMIC_RANGE;
+    property public static int FEATURE_TYPE_FPS_RANGE;
+    property public static int FEATURE_TYPE_IMAGE_FORMAT;
+    property public static int FEATURE_TYPE_VIDEO_STABILIZATION;
+    property public androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+}
+
 package androidx.camera.core.resolutionselector {
 
   public final class AspectRatioStrategy {
diff --git a/camera/camera-core/api/current.txt b/camera/camera-core/api/current.txt
index be3133e..76271a5 100644
--- a/camera/camera-core/api/current.txt
+++ b/camera/camera-core/api/current.txt
@@ -59,6 +59,7 @@
     method public default androidx.lifecycle.LiveData getTorchStrengthLevel();
     method public androidx.lifecycle.LiveData getZoomState();
     method public boolean hasFlashUnit();
+    method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig);
     method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction);
     method public default boolean isLogicalMultiCameraSupported();
     method public default boolean isLowLightBoostSupported();
@@ -638,12 +639,24 @@
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup, optional java.util.List preferredFeatureGroup);
     method public final java.util.List getEffects();
+    method public final androidx.core.util.Consumer> getFeatureSelectionListener();
+    method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor();
     method public final android.util.Range getFrameRateRange();
+    method public final java.util.List getPreferredFeatureGroup();
+    method public final java.util.Set getRequiredFeatureGroup();
     method public final java.util.List getUseCases();
     method public final androidx.camera.core.ViewPort? getViewPort();
+    method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener);
+    method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener);
     property public final java.util.List effects;
+    property public final androidx.core.util.Consumer> featureSelectionListener;
+    property public final java.util.concurrent.Executor featureSelectionListenerExecutor;
     property public final android.util.Range frameRateRange;
+    property public final java.util.List preferredFeatureGroup;
+    property public final java.util.Set requiredFeatureGroup;
     property public final java.util.List useCases;
     property public final androidx.camera.core.ViewPort? viewPort;
   }
@@ -654,6 +667,8 @@
     method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect);
     method public androidx.camera.core.SessionConfig build();
     method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.Range frameRateRange);
+    method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
+    method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
     method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort);
   }
 
@@ -765,6 +780,34 @@
 
 }
 
+package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup {
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature {
+    method public final int getFeatureType();
+    field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion;
+    field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0
+    field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1
+    field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3
+    field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2
+    field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion {
+    property public static int FEATURE_TYPE_DYNAMIC_RANGE;
+    property public static int FEATURE_TYPE_FPS_RANGE;
+    property public static int FEATURE_TYPE_IMAGE_FORMAT;
+    property public static int FEATURE_TYPE_VIDEO_STABILIZATION;
+    property public androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+}
+
 package androidx.camera.core.resolutionselector {
 
   public final class AspectRatioStrategy {
diff --git a/camera/camera-core/api/restricted_1.5.0-beta02.txt b/camera/camera-core/api/restricted_1.5.0-beta02.txt
index be3133e..76271a5 100644
--- a/camera/camera-core/api/restricted_1.5.0-beta02.txt
+++ b/camera/camera-core/api/restricted_1.5.0-beta02.txt
@@ -59,6 +59,7 @@
     method public default androidx.lifecycle.LiveData getTorchStrengthLevel();
     method public androidx.lifecycle.LiveData getZoomState();
     method public boolean hasFlashUnit();
+    method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig);
     method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction);
     method public default boolean isLogicalMultiCameraSupported();
     method public default boolean isLowLightBoostSupported();
@@ -638,12 +639,24 @@
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup, optional java.util.List preferredFeatureGroup);
     method public final java.util.List getEffects();
+    method public final androidx.core.util.Consumer> getFeatureSelectionListener();
+    method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor();
     method public final android.util.Range getFrameRateRange();
+    method public final java.util.List getPreferredFeatureGroup();
+    method public final java.util.Set getRequiredFeatureGroup();
     method public final java.util.List getUseCases();
     method public final androidx.camera.core.ViewPort? getViewPort();
+    method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener);
+    method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener);
     property public final java.util.List effects;
+    property public final androidx.core.util.Consumer> featureSelectionListener;
+    property public final java.util.concurrent.Executor featureSelectionListenerExecutor;
     property public final android.util.Range frameRateRange;
+    property public final java.util.List preferredFeatureGroup;
+    property public final java.util.Set requiredFeatureGroup;
     property public final java.util.List useCases;
     property public final androidx.camera.core.ViewPort? viewPort;
   }
@@ -654,6 +667,8 @@
     method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect);
     method public androidx.camera.core.SessionConfig build();
     method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.Range frameRateRange);
+    method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
+    method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
     method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort);
   }
 
@@ -765,6 +780,34 @@
 
 }
 
+package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup {
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature {
+    method public final int getFeatureType();
+    field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion;
+    field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0
+    field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1
+    field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3
+    field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2
+    field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion {
+    property public static int FEATURE_TYPE_DYNAMIC_RANGE;
+    property public static int FEATURE_TYPE_FPS_RANGE;
+    property public static int FEATURE_TYPE_IMAGE_FORMAT;
+    property public static int FEATURE_TYPE_VIDEO_STABILIZATION;
+    property public androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+}
+
 package androidx.camera.core.resolutionselector {
 
   public final class AspectRatioStrategy {
diff --git a/camera/camera-core/api/restricted_current.txt b/camera/camera-core/api/restricted_current.txt
index be3133e..76271a5 100644
--- a/camera/camera-core/api/restricted_current.txt
+++ b/camera/camera-core/api/restricted_current.txt
@@ -59,6 +59,7 @@
     method public default androidx.lifecycle.LiveData getTorchStrengthLevel();
     method public androidx.lifecycle.LiveData getZoomState();
     method public boolean hasFlashUnit();
+    method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig);
     method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction);
     method public default boolean isLogicalMultiCameraSupported();
     method public default boolean isLowLightBoostSupported();
@@ -638,12 +639,24 @@
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects);
     ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup);
+    ctor public SessionConfig(java.util.List useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List effects, optional android.util.Range frameRateRange, optional java.util.Set requiredFeatureGroup, optional java.util.List preferredFeatureGroup);
     method public final java.util.List getEffects();
+    method public final androidx.core.util.Consumer> getFeatureSelectionListener();
+    method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor();
     method public final android.util.Range getFrameRateRange();
+    method public final java.util.List getPreferredFeatureGroup();
+    method public final java.util.Set getRequiredFeatureGroup();
     method public final java.util.List getUseCases();
     method public final androidx.camera.core.ViewPort? getViewPort();
+    method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener);
+    method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener);
     property public final java.util.List effects;
+    property public final androidx.core.util.Consumer> featureSelectionListener;
+    property public final java.util.concurrent.Executor featureSelectionListenerExecutor;
     property public final android.util.Range frameRateRange;
+    property public final java.util.List preferredFeatureGroup;
+    property public final java.util.Set requiredFeatureGroup;
     property public final java.util.List useCases;
     property public final androidx.camera.core.ViewPort? viewPort;
   }
@@ -654,6 +667,8 @@
     method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect);
     method public androidx.camera.core.SessionConfig build();
     method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.Range frameRateRange);
+    method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
+    method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features);
     method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort);
   }
 
@@ -765,6 +780,34 @@
 
 }
 
+package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup {
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature {
+    method public final int getFeatureType();
+    field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion;
+    field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0
+    field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1
+    field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3
+    field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2
+    field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+  @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion {
+    property public static int FEATURE_TYPE_DYNAMIC_RANGE;
+    property public static int FEATURE_TYPE_FPS_RANGE;
+    property public static int FEATURE_TYPE_IMAGE_FORMAT;
+    property public static int FEATURE_TYPE_VIDEO_STABILIZATION;
+    property public androidx.camera.core.featuregroup.GroupableFeature FPS_60;
+    property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10;
+    property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR;
+    property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION;
+  }
+
+}
+
 package androidx.camera.core.resolutionselector {
 
   public final class AspectRatioStrategy {
diff --git a/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt b/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt
index 7785b97..9dca23c 100644
--- a/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt
+++ b/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt
@@ -20,7 +20,9 @@
 
 import androidx.annotation.Sampled
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.DynamicRange
 import androidx.camera.core.ExperimentalSessionConfig
+import androidx.camera.core.Preview
 import androidx.camera.core.SessionConfig
 import androidx.camera.core.UseCase
 import androidx.camera.core.featuregroup.GroupableFeature
@@ -31,6 +33,36 @@
 import androidx.lifecycle.LifecycleOwner
 
 @Sampled
+fun configureSessionConfigWithFeatureGroups() {
+    // Create a session config where HDR is mandatory while 60 FPS (higher priority) and preview
+    // stabilization are optional
+    SessionConfig(
+        useCases = listOf(Preview.Builder().build()),
+        requiredFeatureGroup = setOf(HDR_HLG10),
+        preferredFeatureGroup = listOf(FPS_60, PREVIEW_STABILIZATION),
+    )
+
+    // Creating the following SessionConfig will throw an exception as there are conflicting
+    // information. HDR is mentioned once as required and then again as preferred, it can't be both!
+    SessionConfig(
+        useCases = listOf(Preview.Builder().build()),
+        requiredFeatureGroup = setOf(HDR_HLG10),
+        preferredFeatureGroup = listOf(FPS_60, HDR_HLG10),
+    )
+
+    // Creating the following SessionConfig will throw an exception as a groupable feature (HDR) is
+    // configured with non-grouping API while using grouping API as well. HDR is configured to the
+    // with a non-grouping API through the Preview use case, so it can't be grouped together
+    // properly with the other groupable features. Instead, it should be configured like the first
+    // SessionConfig construction in this code snippet.
+    SessionConfig(
+        useCases =
+            listOf(Preview.Builder().apply { setDynamicRange(DynamicRange.HLG_10_BIT) }.build()),
+        preferredFeatureGroup = listOf(FPS_60, PREVIEW_STABILIZATION),
+    )
+}
+
+@Sampled
 fun startCameraWithSomeHighQualityFeatures(
     cameraProvider: ProcessCameraProvider,
     lifecycleOwner: LifecycleOwner,
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
index 14a7901..19bb161 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
@@ -556,13 +556,38 @@
      * Returns if the {@link GroupableFeature} groups set to the provided {@link SessionConfig} is
      * supported.
      *
+     * 

This API can be used before calling `bindToLifecycle` API to know if binding a + * {@link SessionConfig} with some given combination of feature groups will work or not. + * + *

The following pseudo-code shows an example of how to use this API: + *

{@code
+     * // Disable the unsupported feature options in app feature menu UI once some features have
+     * // already been selected and adding these features will lead to an unsupported configuration.
+     * void disableUnsupportedFeatures(Set selectedFeatures,
+     *         Set appFeatureOptions) {
+     *     for (GroupableFeature featureOption : appFeatureOptions) {
+     *         if (selectedFeatures.contains(featureOption)) { continue; }
+     *
+     *         List combinedFeatures = new ArrayList<>(selectedFeatures);
+     *         combinedFeatures.add(featureOption);
+     *         SessionConfig sessionConfig =
+     *             new SessionConfig.Builder(useCases)
+     *                 .addRequiredFeatureGroup(combinedFeatures.toArray(new Feature[0]))
+     *                 .build();
+     *
+     *         if (!cameraInfo.isFeatureGroupSupported(sessionConfig)) {
+     *             disableFeatureOptionInUi(featureOption); // e.g. app logic to disable a menu item
+     *         }
+     *     }
+     * }}
+ * * @param sessionConfig The {@link SessionConfig} containing some required or preferred * feature groups. * @return Whether the feature group is supported or not. * @throws IllegalArgumentException If some features conflict with each other by having * different values for the same feature type and can thus never be supported together. + * @see androidx.camera.core.featuregroup.GroupableFeature */ - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // TODO: Expose the API for public release. @ExperimentalSessionConfig default boolean isFeatureGroupSupported(@NonNull SessionConfig sessionConfig) { return false;
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt b/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt
index b281133..a39b8c1 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt
+++ b/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt
@@ -38,12 +38,26 @@
  * and common properties like the field-of-view defined by [ViewPort], the [CameraEffect], frame
  * rate, required or preferred [GroupableFeature] groups etc.
  *
+ * When configuring a session config with `GroupableFeature`s (i.e. [requiredFeatureGroup] or
+ * [preferredFeatureGroup] is used), avoid using non-grouping APIs for any feature that is groupable
+ * (see [GroupableFeature] to know which features are groupable). Doing so can lead to conflicting
+ * configurations and an [IllegalArgumentException]. The following code sample explains this
+ * further.
+ *
+ * @sample androidx.camera.core.samples.configureSessionConfigWithFeatureGroups
  * @property useCases The list of [UseCase] to be attached to the camera and receive camera data.
  *   This can't be empty.
  * @property viewPort The [ViewPort] to be applied on the camera session. If not set, the default is
  *   no viewport.
  * @property effects The list of [CameraEffect] to be applied on the camera session. If not set, the
  *   default is no effects.
+ * @property requiredFeatureGroup A set of [GroupableFeature] that are mandatory for the camera
+ *   session configuration. If not set, the default is an empty set. See
+ *   [SessionConfig.Builder.setRequiredFeatureGroup] for more info.
+ * @property preferredFeatureGroup A list of preferred [GroupableFeature] ordered according to
+ *   priority in descending order, i.e. a feature with a lower index in the list is considered to
+ *   have a higher priority. If not set, the default is an empty list. See
+ *   [SessionConfig.Builder.setPreferredFeatureGroup] for more info.
  * @property frameRateRange The desired frame rate range for the camera session. The value must be
  *   one of the supported frame rates queried by [CameraInfo.getSupportedFrameRateRanges] with a
  *   specific [SessionConfig], or an [IllegalArgumentException] will be thrown during
@@ -59,14 +73,7 @@
  *   ensures a stable frame rate crucial for **video recording**, though it can lead to darker,
  *   noisier video in low light due to shorter exposure times.
  * @throws IllegalArgumentException If the combination of config options are conflicting or
- *   unsupported, e.g.
- *     - if any of the required features is not supported on the device
- *     - if same feature is present multiple times in [preferredFeatures]
- *     - if same feature is present in both [requiredFeatures] and [preferredFeatures]
- *     - if [ImageAnalysis] use case is added with [requiredFeatures] or [preferredFeatures]
- *     - if a [CameraEffect] is set with [requiredFeatures] or [preferredFeatures]
- *     - if the frame rate is not supported with the [SessionConfig]
- *
+ *   unsupported.
  * @See androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle
  */
 @ExperimentalSessionConfig
@@ -77,46 +84,33 @@
     public val viewPort: ViewPort? = null,
     public val effects: List = emptyList(),
     public val frameRateRange: Range = FRAME_RATE_RANGE_UNSPECIFIED,
+    public val requiredFeatureGroup: Set = emptySet(),
+    public val preferredFeatureGroup: List = emptyList(),
 ) {
     public val useCases: List = useCases.distinct()
 
-    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public var requiredFeatureGroup: Set = emptySet()
-        private set
-
-    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public var preferredFeatureGroup: List = emptyList()
-        private set
-
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public open val isLegacy: Boolean = false
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public open val sessionType: Int = SESSION_TYPE_REGULAR
 
-    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    /**
+     * Gets the feature selection listener set to this session config.
+     *
+     * @see setFeatureSelectionListener
+     */
     public var featureSelectionListener: Consumer> =
         Consumer> {}
         private set
 
-    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    /**
+     * Gets the executor set to this session config for feature selection listener invocation.
+     *
+     * @see setFeatureSelectionListener
+     */
     public var featureSelectionListenerExecutor: Executor = CameraXExecutors.mainThreadExecutor()
         private set
 
-    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public constructor(
-        useCases: List,
-        viewPort: ViewPort? = null,
-        effects: List = emptyList(),
-        frameRateRange: Range = FRAME_RATE_RANGE_UNSPECIFIED,
-        requiredFeatureGroup: Set = emptySet(),
-        preferredFeatureGroup: List = emptyList(),
-    ) : this(
-        useCases = useCases,
-        viewPort = viewPort,
-        effects = effects,
-        frameRateRange = frameRateRange,
-    ) {
-        this.requiredFeatureGroup = requiredFeatureGroup
-        this.preferredFeatureGroup = preferredFeatureGroup
+    init {
         validateFeatureCombination()
     }
 
@@ -175,17 +169,17 @@
      *
      * Both the required and the selected preferred features are notified to the listener. The
      * listener is invoked when this session config is bound to camera (e.g. when the
-     * `androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle` API is invoked).
+     * `androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle` API is invoked). It is
+     * invoked even when no preferred features are selected, providing either the required features
+     * or an empty set (if no feature was set as required).
      *
      * Alternatively, the [CameraInfo.isFeatureGroupSupported] API can be used to query if a set of
      * features is supported before binding.
      *
-     * @param executor The executor in which the listener will be invoked, main thread by default.
+     * @param executor The executor in which the listener will be invoked. If not set, the main
+     *   thread is used by default.
      * @param listener The consumer to accept the final set of features when they are selected.
      */
-    // TODO: b/384404392 - Remove when feature combo impl. is ready. The feature combo params should
-    //   be kept restricted until then.
-    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     @JvmOverloads
     public fun setFeatureSelectionListener(
         executor: Executor = CameraXExecutors.mainThreadExecutor(),
@@ -233,34 +227,60 @@
         /**
          * Sets the list of [GroupableFeature] that are mandatory for the camera configuration.
          *
-         * If all the features are not supported, an [IllegalStateException] will be thrown during
-         * camera configuration.
+         * If any of the features are not supported or if the features are not supported together as
+         * a combination, an [IllegalArgumentException] will be thrown when the [SessionConfig] is
+         * bound to a lifecycle (e.g. when the
+         * `androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle` API is invoked).
+         *
+         * To avoid setting an unsupported feature as required, the [setPreferredFeatureGroup] API
+         * can be used since the features from the preferred features are selected on a best-effort
+         * basis according to the priority defined by the ordering of features in the list.
+         * Alternatively, the [CameraInfo.isFeatureGroupSupported] API can be used before binding to
+         * check if the features are supported or not.
+         *
+         * Note that [CameraEffect] or [ImageAnalysis] use case is currently not supported when a
+         * feature is set to a session config.
          *
          * @param features The vararg of `GroupableFeature` objects to add to the required features.
          * @return The [Builder] instance, allowing for method chaining.
-         * @see androidx.camera.core.SessionConfig.requiredFeatureGroup
          */
-        // TODO: b/384404392 - Remove when feature combo impl. is ready.
-        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
         public fun setRequiredFeatureGroup(vararg features: GroupableFeature): Builder {
+            requiredFeatureGroup.clear()
             requiredFeatureGroup.addAll(features)
             return this
         }
 
         /**
-         * Sets the list of preferred [GroupableFeature] that is ordered according to priority in
-         * descending order.
+         * Sets the list of preferred [GroupableFeature], ordered by priority in descending order.
          *
-         * These features will be selected on a best-effort basis according to the priority. The
-         * feature that is ordered first in the list (i.e. has a lower index) will be prioritized
-         * higher than a feature ordered later in the list.
+         * Features are evaluated for support based on this specified priority. The feature with a
+         * lower index (listed first) is considered higher priority. The system attempts to enable
+         * preferred features on a best-effort basis:
+         * - It starts with the highest priority feature.
+         * - If a feature is supported (considering device capabilities and any other already
+         *   selected preferred features or required features), it's added to the selection.
+         * - If a preferred feature is *not* supported, it's skipped, and the system proceeds to
+         *   evaluate the next feature in the preferred list.
+         *
+         * For example, consider the following scenarios where [SessionConfig.requiredFeatureGroup]
+         * is empty:
+         *
+         * |Preferred List                  |Device Support              |Selected Features       |
+         * |--------------------------------|----------------------------|------------------------|
+         * |`[HDR_HLG10, FPS_60, ULTRA_HDR]`|HLG10 + 60 FPS not supported|`[HDR_HLG10, ULTRA_HDR]`|
+         * |`[FPS_60, HDR_HLG10, ULTRA_HDR]`|HLG10 + 60 FPS not supported|`[FPS_60, ULTRA_HDR]`   |
+         * |`[HDR_HLG10, FPS_60, ULTRA_HDR]`|HLG10 is not supported      |`[FPS_60, ULTRA_HDR]`   |
+         * |`[HDR_HLG10, FPS_60]`           |Both supported together     |`[HDR_HLG10, FPS_60]`   |
+         *
+         * The final set of selected features will be notified to the listener set by the
+         * [SessionConfig.setFeatureSelectionListener] API.
+         *
+         * Note that [CameraEffect] or [ImageAnalysis] use case is currently not supported when a
+         * feature is set to a session config.
          *
          * @param features The list of preferred features, ordered by preference.
          * @return The [Builder] instance, allowing for method chaining.
-         * @see androidx.camera.core.SessionConfig.preferredFeatureGroup
          */
-        // TODO: b/384404392 - Remove when feature combo impl. is ready.
-        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
         public fun setPreferredFeatureGroup(vararg features: GroupableFeature): Builder {
             preferredFeatureGroup.clear()
             preferredFeatureGroup.addAll(features)
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt
index 0846858..2a5ca07 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt
+++ b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt
@@ -38,10 +38,18 @@
 import androidx.camera.core.featuregroup.impl.feature.VideoStabilizationFeature.StabilizationMode
 
 /**
- * Base [GroupableFeature] class for all features that can be grouped together for configuring a
- * camera session.
+ * Represents distinct, groupable camera functionalities that can be requested for a camera session.
+ *
+ * CameraX provides various implementations of this class as objects to denote various groupable
+ * features, i.e. [HDR_HLG10], [FPS_60] [PREVIEW_STABILIZATION], [IMAGE_ULTRA_HDR]. These features
+ * can be configured as a group in a [androidx.camera.core.SessionConfig] to ensure compatibility
+ * when they are used together. Additionally, the
+ * [androidx.camera.core.CameraInfo.isFeatureGroupSupported] API can be used to check if a group of
+ * features is supported together on a device.
  *
  * @sample androidx.camera.core.samples.startCameraWithSomeHighQualityFeatures
+ * @see androidx.camera.core.SessionConfig.Builder.setRequiredFeatureGroup
+ * @see androidx.camera.core.SessionConfig.Builder.setPreferredFeatureGroup
  */
 @ExperimentalSessionConfig
 public abstract class GroupableFeature
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java
index ba4f628..d9d07eb 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // TODO: Expose the features
+@ExperimentalSessionConfig
 package androidx.camera.core.featuregroup;
 
-import androidx.annotation.RestrictTo;
+import androidx.camera.core.ExperimentalSessionConfig;
\ No newline at end of file
diff --git a/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt b/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt
index 6130cbe..d1488f5 100644
--- a/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt
+++ b/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt
@@ -17,6 +17,7 @@
 package androidx.camera.lifecycle.samples
 
 import android.content.Context
+import android.util.Log
 import androidx.annotation.OptIn as JavaOptIn
 import androidx.annotation.Sampled
 import androidx.camera.camera2.Camera2Config
@@ -28,6 +29,9 @@
 import androidx.camera.core.Preview
 import androidx.camera.core.SessionConfig
 import androidx.camera.core.UseCase
+import androidx.camera.core.featuregroup.GroupableFeature.Companion.FPS_60
+import androidx.camera.core.featuregroup.GroupableFeature.Companion.HDR_HLG10
+import androidx.camera.core.featuregroup.GroupableFeature.Companion.PREVIEW_STABILIZATION
 import androidx.camera.lifecycle.ExperimentalCameraProviderConfiguration
 import androidx.camera.lifecycle.LifecycleCameraProvider
 import androidx.camera.lifecycle.ProcessCameraProvider
@@ -109,3 +113,35 @@
         sessionConfigNewEffect,
     )
 }
+
+@Sampled
+@OptIn(ExperimentalSessionConfig::class)
+fun bindSessionConfigWithFeatureGroupsToLifecycle(
+    cameraProvider: ProcessCameraProvider,
+    lifecycleOwner: LifecycleOwner,
+    useCases: List,
+) {
+    // Starts the camera with feature groups configured.
+    cameraProvider.bindToLifecycle(
+        lifecycleOwner,
+        CameraSelector.DEFAULT_BACK_CAMERA,
+        // HDR is mandatory in this camera configuration and an exception will be thrown if it's not
+        // supported. 60 FPS and preview stabilization are optional and used if they are also
+        // supported, with the 60 FPS having higher priority over preview stabilization.
+        SessionConfig(
+                useCases = useCases,
+                requiredFeatureGroup = setOf(HDR_HLG10),
+                preferredFeatureGroup = listOf(FPS_60, PREVIEW_STABILIZATION),
+            )
+            .apply {
+                setFeatureSelectionListener { features ->
+                    Log.d(
+                        "TAG",
+                        "Features selected as per priority and device capabilities: $features",
+                    )
+
+                    // Update app UI based on the selected features if required
+                }
+            },
+    )
+}
diff --git a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt
index 489e535..a3fd9c6 100644
--- a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt
+++ b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt
@@ -271,14 +271,19 @@
      * can't resolve a valid camera under the requirements, an IllegalArgumentException will be
      * thrown.
      *
+     * The following code example shows various aspects of binding a session config.
+     *
+     * @sample androidx.camera.lifecycle.samples.bindSessionConfigToLifecycle
+     *
+     * The following code snippet demonstrates binding a session config with feature groups.
+     *
+     * @sample androidx.camera.lifecycle.samples.bindSessionConfigWithFeatureGroupsToLifecycle
      * @throws UnsupportedOperationException If the camera is configured in concurrent mode. For
      *   example, if a list of [SingleCameraConfig]s was bound to the lifecycle already.
      * @throws IllegalStateException if either of the following conditions is met:
      * - A [UseCase] or [SessionConfig] is already bound to the same [LifecycleOwner].
      * - A [UseCase] contained within the [SessionConfig] is already bound to a different
      *   [LifecycleOwner].
-     *
-     * @sample androidx.camera.lifecycle.samples.bindSessionConfigToLifecycle
      */
     @ExperimentalSessionConfig
     public fun bindToLifecycle(
diff --git a/camera/integration-tests/featurecombotestapp/lint-baseline.xml b/camera/integration-tests/featurecombotestapp/lint-baseline.xml
index a3c834b..6921006 100644
--- a/camera/integration-tests/featurecombotestapp/lint-baseline.xml
+++ b/camera/integration-tests/featurecombotestapp/lint-baseline.xml
@@ -3,96 +3,6 @@
 
     
         id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val requiredFeatures: Set<GroupableFeature> = emptySet(),"
-        errorLine2="                                  ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val requiredFeatures: Set<GroupableFeature> = emptySet(),"
-        errorLine2="                                  ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val requiredFeatures: Set<GroupableFeature> = emptySet(),"
-        errorLine2="                                  ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val requiredFeatures: Set<GroupableFeature> = emptySet(),"
-        errorLine2="                                  ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val preferredFeatures: List<GroupableFeature> = emptyList(),"
-        errorLine2="                                    ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val preferredFeatures: List<GroupableFeature> = emptyList(),"
-        errorLine2="                                    ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val preferredFeatures: List<GroupableFeature> = emptyList(),"
-        errorLine2="                                    ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="        val preferredFeatures: List<GroupableFeature> = emptyList(),"
-        errorLine2="                                    ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="SessionConfig can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="            SessionConfig("
-        errorLine2="            ~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="SessionConfig.setFeatureSelectionListener can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                    setFeatureSelectionListener { features ->"
-        errorLine2="                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
         message="Logger.d can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
         errorLine1="                        Logger.d(TAG, "Selected features: $features")"
         errorLine2="                               ~">
@@ -111,69 +21,6 @@
 
     
         id="RestrictedApiAndroidX"
-        message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                            GroupableFeature.HDR_HLG10,"
-        errorLine2="                                             ~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                            GroupableFeature.FPS_60,"
-        errorLine2="                                             ~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                            GroupableFeature.PREVIEW_STABILIZATION,"
-        errorLine2="                                             ~~~~~~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.IMAGE_ULTRA_HDR can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                            GroupableFeature.IMAGE_ULTRA_HDR,"
-        errorLine2="                                             ~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                            GroupableFeature.HDR_HLG10,"
-        errorLine2="                                             ~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                            GroupableFeature.PREVIEW_STABILIZATION,"
-        errorLine2="                                             ~~~~~~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                            GroupableFeature.FPS_60,"
-        errorLine2="                                             ~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
         message="Logger.d can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
         errorLine1="            Logger.d(TAG, "updateUnsupportedFeatures: duration = $duration")"
         errorLine2="                   ~">
@@ -199,103 +46,4 @@
             file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
     
 
-    
-        id="RestrictedApiAndroidX"
-        message="CameraInfo.isFeatureGroupSupported can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                .isFeatureGroupSupported("
-        errorLine2="                 ~~~~~~~~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="SessionConfig can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                    SessionConfig(useCases, requiredFeatureGroup = requiredFeatures)"
-        errorLine2="                    ~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="    private fun AppFeatures.toCameraXFeatures(): Set<GroupableFeature> {"
-        errorLine2="                                                     ~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.IMAGE_ULTRA_HDR can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                features.add(GroupableFeature.IMAGE_ULTRA_HDR)"
-        errorLine2="                                              ~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="            features.add(GroupableFeature.HDR_HLG10)"
-        errorLine2="                                          ~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="            features.add(GroupableFeature.FPS_60)"
-        errorLine2="                                          ~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="            features.add(GroupableFeature.PREVIEW_STABILIZATION)"
-        errorLine2="                                          ~~~~~~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                GroupableFeature.HDR_HLG10 -> {"
-        errorLine2="                                 ~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                GroupableFeature.FPS_60 -> {"
-        errorLine2="                                 ~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                GroupableFeature.PREVIEW_STABILIZATION -> {"
-        errorLine2="                                 ~~~~~~~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
-    
-        id="RestrictedApiAndroidX"
-        message="GroupableFeature.IMAGE_ULTRA_HDR can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
-        errorLine1="                GroupableFeature.IMAGE_ULTRA_HDR -> {"
-        errorLine2="                                 ~~~~~~~~~~~~~~~">
-        
-            file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/>
-    
-
 
diff --git a/camera/viewfinder/viewfinder-compose/build.gradle b/camera/viewfinder/viewfinder-compose/build.gradle
index 7d58650..255aeae 100644
--- a/camera/viewfinder/viewfinder-compose/build.gradle
+++ b/camera/viewfinder/viewfinder-compose/build.gradle
@@ -33,7 +33,6 @@
 dependencies {
     api(project(":camera:viewfinder:viewfinder-core"))
     implementation("androidx.compose.foundation:foundation-layout:1.6.1")
-    implementation("androidx.compose.foundation:foundation:1.6.1")
     implementation("androidx.compose.runtime:runtime:1.6.1")
     implementation("androidx.compose.ui:ui:1.7.8")
     implementation("androidx.compose.ui:ui-util:1.7.8")
@@ -56,6 +55,7 @@
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinCoroutinesCore)
     androidTestImplementation(libs.testExtJunit)
+    androidTestImplementation(project(":compose:foundation:foundation"))
     androidTestImplementation(project(":compose:runtime:runtime"))
     androidTestImplementation(project(":compose:ui:ui"))
     androidTestImplementation(project(":compose:ui:ui-geometry"))
diff --git a/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt b/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt
index b9677b0..775281c 100644
--- a/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt
+++ b/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt
@@ -18,50 +18,54 @@
 
 import android.os.Build
 import android.util.Size
-import android.view.Surface
-import androidx.annotation.RequiresApi
 import androidx.camera.viewfinder.core.ImplementationMode
 import androidx.camera.viewfinder.core.TransformationInfo
 import androidx.camera.viewfinder.core.ViewfinderSurfaceRequest
+import androidx.camera.viewfinder.core.ViewfinderSurfaceSessionScope
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Matrix
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.CompletableDeferred
+import com.google.common.truth.TruthJUnit.assume
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.runBlocking
-import org.junit.Ignore
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
 
 @MediumTest
-@RunWith(AndroidJUnit4::class)
-class ViewfinderTest {
+@RunWith(Parameterized::class)
+class ViewfinderTest(private val implementationMode: ImplementationMode) {
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "implementationMode = {0}")
+        fun data(): Array =
+            arrayOf(ImplementationMode.EXTERNAL, ImplementationMode.EMBEDDED)
+    }
+
     @get:Rule val rule = createComposeRule()
 
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M)
-    @Test
-    fun canRetrievePerformanceSurface() = runBlocking {
-        assertCanRetrieveSurface(implementationMode = ImplementationMode.EXTERNAL)
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M)
-    @Test
-    fun canRetrieveCompatibleSurface() = runBlocking {
-        assertCanRetrieveSurface(implementationMode = ImplementationMode.EMBEDDED)
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M)
     @Test
     fun coordinatesTransformationSameSizeNoRotation(): Unit = runBlocking {
         val coordinateTransformer = MutableCoordinateTransformer()
@@ -84,7 +88,6 @@
         assertThat(coordinateTransformer.transformMatrix.values).isEqualTo(expectedMatrix.values)
     }
 
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M)
     @Test
     fun coordinatesTransformationSameSizeWithHalfCrop(): Unit = runBlocking {
         // Viewfinder size: 1080x1920
@@ -97,12 +100,6 @@
             with(LocalDensity.current) {
                 TestViewfinder(
                     modifier = Modifier.size(540.toDp(), 960.toDp()),
-                    surfaceRequest =
-                        ViewfinderSurfaceRequest(
-                            width = ViewfinderTestParams.Default.sourceResolution.width,
-                            height = ViewfinderTestParams.Default.sourceResolution.height,
-                            implementationMode = ImplementationMode.EXTERNAL,
-                        ),
                     transformationInfo =
                         TransformationInfo(
                             sourceRotation = 0,
@@ -127,115 +124,239 @@
         assertThat(coordinateTransformer.transformMatrix.values).isEqualTo(expectedMatrix.values)
     }
 
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) // Needed for Surface.lockHardwareCanvas()
+    @Test
+    fun canRetrieveSurface() = runBlocking {
+        testWithSession {
+            val surface = awaitSurfaceSession().surface
+            surface.lockHardwareCanvas().apply {
+                try {
+                    assertThat(Size(width, height))
+                        .isEqualTo(ViewfinderTestParams.Default.sourceResolution)
+                } finally {
+                    surface.unlockCanvasAndPost(this)
+                }
+            }
+        }
+    }
+
     @Test
     fun verifySurfacesAreReleased_surfaceRequestReleased_thenComposableDestroyed(): Unit =
         runBlocking {
-            val surfaceDeferred = CompletableDeferred()
-            val sessionCompleteDeferred = CompletableDeferred()
+            testHideableWithSession {
+                val surface = awaitSurfaceSession().surface
+                assertThat(surface.isValid).isTrue()
 
-            val showViewfinder = mutableStateOf(true)
+                allowNextSessionCompletion()
+                rule.awaitIdle()
+                assertThat(surface.isValid).isTrue()
 
-            rule.setContent {
-                val showView by remember { showViewfinder }
-                TestViewfinder(showViewfinder = showView) {
-                    onSurfaceSession {
-                        surfaceDeferred.complete(surface)
-                        sessionCompleteDeferred.await()
-                    }
-                }
+                hideViewfinder()
+                rule.awaitIdle()
+                assertThat(surface.isValid).isFalse()
             }
-
-            val surface = surfaceDeferred.await()
-            assertThat(surface.isValid).isTrue()
-
-            sessionCompleteDeferred.complete(Unit)
-            rule.awaitIdle()
-            assertThat(surface.isValid).isTrue()
-
-            showViewfinder.value = false
-            rule.awaitIdle()
-            assertThat(surface.isValid).isFalse()
         }
 
-    @Ignore("b/390508238: Surface release needs to be delayed by TextureView/SurfaceView ")
     @Test
     fun verifySurfacesAreReleased_composableDestroyed_thenSurfaceRequestReleased(): Unit =
         runBlocking {
-            val surfaceDeferred = CompletableDeferred()
-            val sessionCompleteDeferred = CompletableDeferred()
+            assume()
+                .withMessage(
+                    "EXTERNAL implamentation on API < 29 is not yet able to delay surface destruction by the Viewfinder."
+                )
+                .that(
+                    Build.VERSION.SDK_INT >= 29 || implementationMode != ImplementationMode.EXTERNAL
+                )
+                .isTrue()
+            testHideableWithSession {
+                val surface = awaitSurfaceSession().surface
+                assertThat(surface.isValid).isTrue()
 
-            val showViewfinder = mutableStateOf(true)
+                hideViewfinder()
+                rule.awaitIdle()
+                assertThat(surface.isValid).isTrue()
 
-            rule.setContent {
-                val showView by remember { showViewfinder }
-                TestViewfinder(showViewfinder = showView) {
-                    onSurfaceSession { surfaceDeferred.complete(surface) }
-                }
+                allowNextSessionCompletion()
+                rule.awaitIdle()
+                assertThat(surface.isValid).isFalse()
             }
-
-            val surface = surfaceDeferred.await()
-            assertThat(surface.isValid).isTrue()
-
-            showViewfinder.value = false
-            rule.awaitIdle()
-            assertThat(surface.isValid).isTrue()
-
-            sessionCompleteDeferred.complete(Unit)
-            rule.awaitIdle()
-            assertThat(surface.isValid).isFalse()
         }
 
-    @RequiresApi(Build.VERSION_CODES.M) // Needed for Surface.lockHardwareCanvas()
-    private suspend fun assertCanRetrieveSurface(implementationMode: ImplementationMode) {
-        val surfaceDeferred = CompletableDeferred()
-        val surfaceRequest =
+    @Test
+    fun movableContentOf_afterMove_validSurfaceIsAvailable(): Unit = runBlocking {
+        testMovableWithSession {
+            val surfaceSession = awaitSurfaceSession()
+            assertThat(surfaceSession.surface.isValid).isTrue()
+
+            moveViewfinder()
+            rule.awaitIdle()
+
+            when (implementationMode) {
+                ImplementationMode.EMBEDDED -> assertThat(surfaceSession.surface.isValid).isTrue()
+                ImplementationMode.EXTERNAL ->
+                    if (Build.VERSION.SDK_INT >= 29) {
+                        assertThat(surfaceSession.surface.isValid).isTrue()
+                    } else {
+                        // A new surface would need to be created on API 28 and lower, wait for
+                        // the new surface session
+                        allowNextSessionCompletion()
+
+                        val newSurfaceSession =
+                            withTimeoutOrNull(5.seconds) {
+                                awaitSurfaceSession { it !== surfaceSession }
+                            }
+                        assertThat(newSurfaceSession).isNotNull()
+                        assertThat(newSurfaceSession!!.surface.isValid).isTrue()
+                    }
+            }
+        }
+    }
+
+    private interface SessionTestScope {
+
+        suspend fun awaitSurfaceSession(
+            predicate: ((ViewfinderSurfaceSessionScope) -> Boolean)? = null
+        ): ViewfinderSurfaceSessionScope
+
+        fun allowNextSessionCompletion()
+    }
+
+    private interface HideableSessionTestScope : SessionTestScope {
+        fun hideViewfinder()
+    }
+
+    private interface MovableSessionTestScope : SessionTestScope {
+        fun moveViewfinder()
+    }
+
+    private suspend fun  testWithSessionInternal(
+        scopeProvider: (SessionTestScope) -> T,
+        composeContent: @Composable (onInit: ViewfinderInitScope.() -> Unit) -> Unit,
+        block: suspend T.() -> Unit,
+    ) {
+        val surfaceSessionFlow = MutableStateFlow(null)
+        val sessionCompleteCount = MutableStateFlow(0)
+
+        var numSessions = 0
+        val onInit: ViewfinderInitScope.() -> Unit = {
+            onSurfaceSession {
+                numSessions++
+                surfaceSessionFlow.value = this@onSurfaceSession
+                withContext(NonCancellable) { sessionCompleteCount.first { it >= numSessions } }
+            }
+        }
+
+        rule.setContent { composeContent(onInit) }
+
+        val baseSessionTestScope = BaseSessionTestScope(surfaceSessionFlow, sessionCompleteCount)
+        val specificSessionTestScope = scopeProvider(baseSessionTestScope)
+
+        try {
+            block.invoke(specificSessionTestScope)
+        } finally {
+            sessionCompleteCount.value = Int.MAX_VALUE
+        }
+    }
+
+    private suspend fun testMovableWithSession(block: suspend MovableSessionTestScope.() -> Unit) {
+        val moveViewfinderState = mutableStateOf(false)
+
+        testWithSessionInternal(
+            scopeProvider = { baseScope ->
+                object : SessionTestScope by baseScope, MovableSessionTestScope {
+                    override fun moveViewfinder() {
+                        moveViewfinderState.value = true
+                    }
+                }
+            },
+            composeContent = { onInit ->
+                var moveView by remember { moveViewfinderState }
+                val content = remember { movableContentOf { TestViewfinder(onInit = onInit) } }
+
+                Column {
+                    if (moveView) {
+                        content()
+                    } else {
+                        content()
+                    }
+                }
+            },
+            block = block,
+        )
+    }
+
+    private suspend fun testWithSession(block: suspend SessionTestScope.() -> Unit) {
+        testWithSessionInternal(
+            scopeProvider = { it },
+            composeContent = { onInit -> TestViewfinder(onInit = onInit) },
+            block = block,
+        )
+    }
+
+    private suspend fun testHideableWithSession(
+        block: suspend HideableSessionTestScope.() -> Unit
+    ) {
+        val showViewfinderState = mutableStateOf(true)
+
+        testWithSessionInternal(
+            scopeProvider = { baseScope ->
+                object : SessionTestScope by baseScope, HideableSessionTestScope {
+                    override fun hideViewfinder() {
+                        showViewfinderState.value = false
+                    }
+                }
+            },
+            composeContent = { onInit ->
+                val showView by remember { showViewfinderState }
+                if (showView) {
+                    TestViewfinder(onInit = onInit)
+                }
+            },
+            block = block,
+        )
+    }
+
+    class BaseSessionTestScope(
+        val surfaceFlow: StateFlow,
+        val sessionCompletionCount: MutableStateFlow,
+    ) : SessionTestScope {
+        override suspend fun awaitSurfaceSession(
+            predicate: ((ViewfinderSurfaceSessionScope) -> Boolean)?
+        ): ViewfinderSurfaceSessionScope {
+            return surfaceFlow.filterNotNull().run {
+                if (predicate != null) {
+                    this.first(predicate)
+                } else {
+                    this.first()
+                }
+            }
+        }
+
+        override fun allowNextSessionCompletion() {
+            sessionCompletionCount.update { it + 1 }
+        }
+    }
+
+    @Composable
+    fun TestViewfinder(
+        modifier: Modifier = Modifier.size(ViewfinderTestParams.Default.viewfinderSize),
+        transformationInfo: TransformationInfo = ViewfinderTestParams.Default.transformationInfo,
+        surfaceRequest: ViewfinderSurfaceRequest = remember {
             ViewfinderSurfaceRequest(
                 width = ViewfinderTestParams.Default.sourceResolution.width,
                 height = ViewfinderTestParams.Default.sourceResolution.height,
                 implementationMode = implementationMode,
             )
-        rule.setContent {
-            TestViewfinder(surfaceRequest = surfaceRequest) {
-                onSurfaceSession { surfaceDeferred.complete(surface) }
-            }
-        }
-
-        val surface = surfaceDeferred.await()
-        surface.lockHardwareCanvas().apply {
-            try {
-                assertThat(Size(width, height))
-                    .isEqualTo(ViewfinderTestParams.Default.sourceResolution)
-            } finally {
-                surface.unlockCanvasAndPost(this)
-            }
-        }
-    }
-}
-
-@Composable
-fun TestViewfinder(
-    modifier: Modifier = Modifier.size(ViewfinderTestParams.Default.viewfinderSize),
-    showViewfinder: Boolean = true,
-    transformationInfo: TransformationInfo = ViewfinderTestParams.Default.transformationInfo,
-    surfaceRequest: ViewfinderSurfaceRequest = remember {
-        ViewfinderSurfaceRequest(
-            width = ViewfinderTestParams.Default.sourceResolution.width,
-            height = ViewfinderTestParams.Default.sourceResolution.height,
-            implementationMode = ImplementationMode.EXTERNAL,
+        },
+        coordinateTransformer: MutableCoordinateTransformer? = null,
+        onInit: ViewfinderInitScope.() -> Unit,
+    ) {
+        Viewfinder(
+            modifier = modifier,
+            surfaceRequest = surfaceRequest,
+            transformationInfo = transformationInfo,
+            coordinateTransformer = coordinateTransformer,
+            onInit = onInit,
         )
-    },
-    coordinateTransformer: MutableCoordinateTransformer? = null,
-    onInit: ViewfinderInitScope.() -> Unit,
-) {
-    Column {
-        if (showViewfinder) {
-            Viewfinder(
-                modifier = modifier,
-                surfaceRequest = surfaceRequest,
-                transformationInfo = transformationInfo,
-                coordinateTransformer = coordinateTransformer,
-                onInit = onInit,
-            )
-        }
     }
 }
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt
index d106aa3..9092747 100644
--- a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt
+++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt
@@ -20,6 +20,9 @@
 import android.util.Size
 import android.util.SizeF
 import android.view.Surface
+import androidx.camera.viewfinder.compose.internal.ViewfinderEmbeddedExternalSurface
+import androidx.camera.viewfinder.compose.internal.ViewfinderExternalSurface
+import androidx.camera.viewfinder.compose.internal.ViewfinderExternalSurfaceScope
 import androidx.camera.viewfinder.core.ImplementationMode
 import androidx.camera.viewfinder.core.TransformationInfo
 import androidx.camera.viewfinder.core.TransformationInfo.Companion.DEFAULT
@@ -31,9 +34,6 @@
 import androidx.camera.viewfinder.core.impl.ScaleFactorF
 import androidx.camera.viewfinder.core.impl.Transformations
 import androidx.camera.viewfinder.core.impl.ViewfinderSurfaceSessionImpl
-import androidx.compose.foundation.AndroidEmbeddedExternalSurface
-import androidx.compose.foundation.AndroidExternalSurface
-import androidx.compose.foundation.AndroidExternalSurfaceScope
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
@@ -62,9 +62,10 @@
  * [ViewfinderSurfaceSessionScope] of the callback registered via
  * [ViewfinderInitScope.onSurfaceSession] in [onInit].
  *
- * This has two underlying implementations either using an [AndroidEmbeddedExternalSurface] for
- * [ImplementationMode.EMBEDDED] or an [AndroidExternalSurface] for [ImplementationMode.EXTERNAL].
- * These can be set by the [ImplementationMode] argument in the [surfaceRequest] constructor. If
+ * This has two underlying implementations based on
+ * [androidx.compose.foundation.AndroidEmbeddedExternalSurface] for [ImplementationMode.EMBEDDED] or
+ * on [androidx.compose.foundation.AndroidExternalSurface] for [ImplementationMode.EXTERNAL]. These
+ * can be set by the [ImplementationMode] argument in the [surfaceRequest] constructor. If
  * `implementationMode` is `null`, [ImplementationMode.EXTERNAL] is chosen by default, switching to
  * [ImplementationMode.EMBEDDED] on API levels 24 and below, or on devices with known compatibility
  * issues with the `EXTERNAL` mode.
@@ -119,18 +120,11 @@
                 // Register callback from onInit()
                 onInit.invoke(viewfinderInitScope)
 
-                onSurface { newSurface, _, _ ->
-                    val refCountedSurface = RefCounted { it.release() }
-                    refCountedSurface.initialize(newSurface)
-
-                    // TODO(b/390508238): Stop underlying View from releasing the Surface
-                    // automatically. It should wait for the RefCount to get to 0.
-                    newSurface.onDestroyed { refCountedSurface.release() }
-
-                    // TODO(b/322420176): Properly handle onSurfaceChanged()
-
+                onSurface { viewfinderSurfaceHolder ->
                     // Dispatch surface to registered onSurfaceSession callback
-                    viewfinderInitScope.dispatchOnSurfaceSession(refCountedSurface)
+                    viewfinderInitScope.dispatchOnSurfaceSession(
+                        viewfinderSurfaceHolder.refCountedSurface
+                    )
                 }
             }
         }
@@ -146,7 +140,7 @@
     coordinateTransformer: MutableCoordinateTransformer?,
     alignment: Alignment,
     contentScale: ContentScale,
-    onInit: AndroidExternalSurfaceScope.() -> Unit,
+    onInit: ViewfinderExternalSurfaceScope.() -> Unit,
 ) {
     val layoutDirection = LocalConfiguration.current.layoutDirection
     val surfaceModifier =
@@ -195,7 +189,7 @@
 
     when (implementationMode) {
         ImplementationMode.EXTERNAL -> {
-            AndroidExternalSurface(modifier = surfaceModifier, onInit = onInit)
+            ViewfinderExternalSurface(modifier = surfaceModifier, onInit = onInit)
         }
         ImplementationMode.EMBEDDED -> {
             val displayRotationDegrees =
@@ -218,7 +212,7 @@
                 )
             }
 
-            AndroidEmbeddedExternalSurface(
+            ViewfinderEmbeddedExternalSurface(
                 modifier = surfaceModifier,
                 transform = correctionMatrix,
                 onInit = onInit,
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/BaseViewfinderExternalSurfaceState.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/BaseViewfinderExternalSurfaceState.kt
new file mode 100644
index 0000000..a48a9c2
--- /dev/null
+++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/BaseViewfinderExternalSurfaceState.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.viewfinder.compose.internal
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+/** Base class for [ViewfinderExternalSurface] and [ViewfinderEmbeddedExternalSurface] state. */
+internal abstract class BaseViewfinderExternalSurfaceState(val scope: CoroutineScope) :
+    ViewfinderExternalSurfaceScope {
+
+    private var onSurface:
+        (suspend ViewfinderSurfaceCoroutineScope.(
+            viewfinderSurfaceHolder: ViewfinderSurfaceHolder
+        ) -> Unit)? =
+        null
+
+    private var job: Job? = null
+
+    override fun onSurface(
+        onSurface:
+            suspend ViewfinderSurfaceCoroutineScope.(
+                viewfinderSurfaceHolder: ViewfinderSurfaceHolder
+            ) -> Unit
+    ) {
+        this.onSurface = onSurface
+    }
+
+    /**
+     * Dispatch a surface creation event by launching a new coroutine in [scope]. Any previous job
+     * from a previous surface creation dispatch is cancelled.
+     */
+    fun dispatchSurfaceCreated(holder: ViewfinderSurfaceHolder) {
+        if (onSurface != null) {
+            job =
+                scope.launch(start = CoroutineStart.UNDISPATCHED) {
+                    job?.apply {
+                        cancel("Surface replaced")
+                        join()
+                    }
+                    if (isActive) {
+                        val receiver =
+                            object : ViewfinderSurfaceCoroutineScope, CoroutineScope by this {}
+                        onSurface?.invoke(receiver, holder)
+                    }
+                }
+        }
+    }
+}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderEmbeddedExternalSurface.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderEmbeddedExternalSurface.kt
new file mode 100644
index 0000000..bc43f804
--- /dev/null
+++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderEmbeddedExternalSurface.kt
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.viewfinder.compose.internal
+
+import android.graphics.SurfaceTexture
+import android.util.Log
+import android.view.Surface
+import android.view.TextureView
+import androidx.camera.viewfinder.core.impl.RefCounted
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.setFrom
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.CoroutineScope
+
+private const val TAG = "VfEmbeddedSurface"
+
+private class ViewfinderEmbeddedExternalSurfaceHolder(private val surfaceTexture: SurfaceTexture) :
+    ViewfinderSurfaceHolder {
+    override val refCountedSurface: RefCounted = RefCounted {
+        it.release()
+        surfaceTexture.release()
+    }
+
+    var isDetached = false
+        private set
+
+    init {
+        refCountedSurface.initialize(Surface(surfaceTexture))
+    }
+
+    override fun detach() {
+        if (!isDetached) {
+            refCountedSurface.release()
+            isDetached = true
+        }
+    }
+
+    fun tryAttach(textureView: TextureView) {
+        if (isDetached) {
+            refCountedSurface.acquire()?.let {
+                textureView.setSurfaceTexture(surfaceTexture)
+                Log.d(TAG, "Reattached $surfaceTexture to $textureView")
+                isDetached = false
+            }
+                ?: run {
+                    Log.d(
+                        TAG,
+                        "Unable to reattach $surfaceTexture to $textureView. Already released.",
+                    )
+                }
+        } else {
+            Log.d(TAG, "Unable to reattach $surfaceTexture to $textureView. Still attached.")
+        }
+    }
+}
+
+private class ViewfinderEmbeddedExternalSurfaceState(scope: CoroutineScope) :
+    BaseViewfinderExternalSurfaceState(scope), TextureView.SurfaceTextureListener {
+
+    var surfaceSize = IntSize.Zero
+    val matrix = android.graphics.Matrix()
+
+    lateinit var viewfinderSurfaceHolder: ViewfinderEmbeddedExternalSurfaceHolder
+
+    override fun onSurfaceTextureAvailable(
+        surfaceTexture: SurfaceTexture,
+        width: Int,
+        height: Int,
+    ) {
+        viewfinderSurfaceHolder = ViewfinderEmbeddedExternalSurfaceHolder(surfaceTexture)
+
+        if (surfaceSize != IntSize.Zero) {
+            surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
+        }
+
+        dispatchSurfaceCreated(viewfinderSurfaceHolder)
+    }
+
+    override fun onSurfaceTextureSizeChanged(
+        surfaceTexture: SurfaceTexture,
+        width: Int,
+        height: Int,
+    ) {
+        if (surfaceSize != IntSize.Zero) {
+            surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
+        }
+    }
+
+    override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
+        viewfinderSurfaceHolder.detach()
+        // If the composable hasn't yet been disposed, this surface could be reattached, so we won't
+        // stop the surface job here.
+
+        // Do not release the SurfaceTexture. It will be released by the refCountedSurface when
+        // the ref count reaches zero.
+        return false
+    }
+
+    override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
+        // onSurfaceTextureUpdated is called when the content of the SurfaceTexture
+        // has changed, which is not relevant to us since we are the producer here
+    }
+
+    fun tryReattachViewfinderSurfaceHolder(textureView: TextureView) {
+        if (::viewfinderSurfaceHolder.isInitialized) {
+            viewfinderSurfaceHolder.tryAttach(textureView)
+        }
+    }
+}
+
+@Composable
+private fun rememberViewfinderEmbeddedExternalSurfaceState():
+    ViewfinderEmbeddedExternalSurfaceState {
+    val scope = rememberCoroutineScope()
+    return remember { ViewfinderEmbeddedExternalSurfaceState(scope) }
+}
+
+/**
+ * This is a modified version of the [androidx.compose.foundation.AndroidEmbeddedExternalSurface]
+ * composable.
+ *
+ * It has been adapted to:
+ * - Remove functionality not required by the Viewfinder, such as direct callbacks for
+ *   `onSurfaceCreated` and `onSurfaceDestroyed` with raw [Surface] objects.
+ * - Add specific functionality to support the Viewfinder's API guarantees. Notably, it ensures that
+ *   underlying surface resources are not released prematurely, but rather managed in coordination
+ *   with the [androidx.camera.viewfinder.core.ViewfinderSurfaceSessionScope] to guarantee the
+ *   surface is valid until the session scope is completed.
+ */
+@Composable
+internal fun ViewfinderEmbeddedExternalSurface(
+    modifier: Modifier = Modifier,
+    isOpaque: Boolean = true,
+    surfaceSize: IntSize = IntSize.Zero,
+    transform: Matrix? = null,
+    onInit: ViewfinderExternalSurfaceScope.() -> Unit,
+) {
+    val state = rememberViewfinderEmbeddedExternalSurfaceState()
+
+    AndroidView(
+        factory = {
+            object : TextureView(it) {
+                override fun onAttachedToWindow() {
+                    super.onAttachedToWindow()
+                    state.tryReattachViewfinderSurfaceHolder(this)
+                }
+            }
+        },
+        modifier = modifier,
+        onReset = {},
+        update = { view ->
+            if (surfaceSize != IntSize.Zero) {
+                view.surfaceTexture?.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
+            }
+            state.surfaceSize = surfaceSize
+            if (view.surfaceTextureListener !== state) {
+                state.onInit()
+                view.surfaceTextureListener = state
+            }
+            view.isOpaque = isOpaque
+            // If transform is null, we'll call setTransform(null) which sets the
+            // identity transform on the TextureView
+            view.setTransform(transform?.let { state.matrix.apply { setFrom(transform) } })
+        },
+    )
+}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderExternalSurface.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderExternalSurface.kt
new file mode 100644
index 0000000..0a62ec6
--- /dev/null
+++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderExternalSurface.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.viewfinder.compose.internal
+
+import android.graphics.PixelFormat
+import android.util.Log
+import android.view.Surface
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import androidx.camera.viewfinder.core.impl.RefCounted
+import androidx.camera.viewfinder.core.impl.SurfaceControlCompat
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.CoroutineScope
+
+private const val TAG = "VfExternalSurface"
+
+private class ViewfinderExternalSurfaceHolder(
+    initialSurface: Surface,
+    width: Int,
+    height: Int,
+    initialParent: SurfaceControlCompat,
+) : ViewfinderSurfaceHolder {
+    private val surfaceControl: SurfaceControlCompat =
+        SurfaceControlCompat.create(
+            initialParent,
+            width,
+            height,
+            "ViewfinderExternalSurfaceHolder-${hashCode()}",
+        )
+
+    override val refCountedSurface: RefCounted
+
+    var isDetached = false
+        private set
+
+    init {
+        val surface = surfaceControl.newSurface() ?: initialSurface
+        refCountedSurface = RefCounted {
+            surfaceControl.detach()
+            // Only release the surface if it's not the parent surface. i.e., it's a new surface
+            // we've created from SurfaceControl.
+            // On some API levels, SurfaceControlCompat is a no-op wrapper, and we don't have
+            // control over the surface lifecycle and should leave it to the SurfaceView.
+            if (surface != initialSurface) {
+                surface.release()
+            }
+        }
+        refCountedSurface.initialize(surface)
+    }
+
+    override fun detach() {
+        if (!isDetached) {
+            surfaceControl.detach()
+            // Release for refCountedSurface.initialize()
+            refCountedSurface.release()
+            isDetached = true
+        }
+    }
+
+    fun tryAttach(parent: SurfaceControlCompat): Boolean {
+        check(isDetached) { "tryAttach() can only be called when detached" }
+        return refCountedSurface.acquire()?.let {
+            if (surfaceControl.reparent(parent)) {
+                Log.d(TAG, "Reattached $it to $parent")
+                isDetached = false
+                true
+            } else {
+                // In this else-condition, it's likely the API level doesn't support SurfaceControl
+                // Release the refcount we just acquired.
+                Log.d(TAG, "Unable to attach $it to $parent")
+                refCountedSurface.release()
+                false
+            }
+        } == true
+    }
+}
+
+/** Implementation of [BaseViewfinderExternalSurfaceState] for [ViewfinderExternalSurface]. */
+private class ViewfinderExternalSurfaceState(scope: CoroutineScope) :
+    BaseViewfinderExternalSurfaceState(scope), SurfaceHolder.Callback {
+
+    var lastWidth = -1
+    var lastHeight = -1
+    lateinit var surfaceView: SurfaceView
+    lateinit var viewfinderSurfaceHolder: ViewfinderExternalSurfaceHolder
+
+    fun initInternal(surfaceView: SurfaceView) {
+        this.surfaceView = surfaceView
+    }
+
+    override fun surfaceCreated(holder: SurfaceHolder) {
+        val frame = holder.surfaceFrame
+        lastWidth = frame.width()
+        lastHeight = frame.height()
+
+        val parent = SurfaceControlCompat.wrap(surfaceView)
+        if (
+            !::viewfinderSurfaceHolder.isInitialized || !viewfinderSurfaceHolder.tryAttach(parent)
+        ) {
+            viewfinderSurfaceHolder =
+                ViewfinderExternalSurfaceHolder(holder.surface, lastWidth, lastHeight, parent)
+
+            dispatchSurfaceCreated(viewfinderSurfaceHolder)
+        }
+    }
+
+    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
+        lastWidth = width
+        lastHeight = height
+    }
+
+    override fun surfaceDestroyed(holder: SurfaceHolder) {
+        viewfinderSurfaceHolder.detach()
+    }
+}
+
+@Composable
+private fun rememberViewfinderExternalSurfaceState(): ViewfinderExternalSurfaceState {
+    val scope = rememberCoroutineScope()
+    return remember { ViewfinderExternalSurfaceState(scope) }
+}
+
+/**
+ * This is a modified version of the [androidx.compose.foundation.AndroidExternalSurface]
+ * composable.
+ *
+ * It has been adapted to:
+ * - Remove functionality not required by the Viewfinder, such as direct callbacks for
+ *   `onSurfaceCreated` and `onSurfaceDestroyed` with raw [Surface] objects.
+ * - Remove ZOrder API as it isn't used by the Viewfinder.
+ * - Add specific functionality to support the Viewfinder's API guarantees. Notably, it ensures that
+ *   underlying surface resources are not released prematurely, but rather managed in coordination
+ *   with the [androidx.camera.viewfinder.core.ViewfinderSurfaceSessionScope] to guarantee the
+ *   surface is valid until the session scope is completed (on supported API levels).
+ */
+@Composable
+internal fun ViewfinderExternalSurface(
+    modifier: Modifier = Modifier,
+    isOpaque: Boolean = true,
+    surfaceSize: IntSize = IntSize.Zero,
+    isSecure: Boolean = false,
+    onInit: ViewfinderExternalSurfaceScope.() -> Unit,
+) {
+    val state = rememberViewfinderExternalSurfaceState()
+
+    AndroidView(
+        factory = { context ->
+            SurfaceView(context).apply {
+                state.initInternal(this)
+                state.onInit()
+                holder.addCallback(state)
+            }
+        },
+        modifier = modifier,
+        onReset = {},
+        update = { view ->
+            if (surfaceSize != IntSize.Zero) {
+                view.holder.setFixedSize(surfaceSize.width, surfaceSize.height)
+            } else {
+                view.holder.setSizeFromLayout()
+            }
+
+            view.holder.setFormat(
+                if (isOpaque) {
+                    PixelFormat.OPAQUE
+                } else {
+                    PixelFormat.TRANSLUCENT
+                }
+            )
+
+            view.setSecure(isSecure)
+        },
+    )
+}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceHolder.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceHolder.kt
new file mode 100644
index 0000000..e60ef4c
--- /dev/null
+++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceHolder.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.viewfinder.compose.internal
+
+import android.view.Surface
+import androidx.camera.viewfinder.core.impl.RefCounted
+
+/**
+ * Manages ownership and lifecycle of a [Surface] used by the Viewfinder.
+ *
+ * This interface facilitates the sharing and management of the underlying [Surface]. It provides
+ * access to a [RefCounted] [Surface], ensuring that the surface is released only when all clients
+ * have finished using it.
+ *
+ * It also allows the [Surface] to be explicitly detached from the view that originally created or
+ * provided it, which is important for proper resource cleanup and lifecycle management, especially
+ * when the view might be destroyed before the surface is no longer needed by other components.
+ */
+internal interface ViewfinderSurfaceHolder {
+    /**
+     * Provides the reference-counted [Surface].
+     *
+     * Clients should increment the reference count when they start using the surface and decrement
+     * it when they are done. The surface will be released when its reference count drops to zero.
+     */
+    val refCountedSurface: RefCounted
+
+    /**
+     * Detaches the [Surface] from its originating view or source.
+     *
+     * This typically involves operations like reparenting a [android.view.SurfaceControl] or
+     * signaling that the surface is no longer tied to the view's lifecycle. This method should be
+     * called to ensure proper cleanup when the view destroys the surface.
+     */
+    fun detach()
+}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceScope.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceScope.kt
new file mode 100644
index 0000000..8725971
--- /dev/null
+++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceScope.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.viewfinder.compose.internal
+
+import kotlinx.coroutines.CoroutineScope
+
+internal interface ViewfinderSurfaceCoroutineScope : CoroutineScope
+
+internal interface ViewfinderExternalSurfaceScope {
+    /**
+     * Invokes [onSurface] when a new [ViewfinderSurfaceHolder] is created. The [onSurface] lambda
+     * is invoked on the main thread as part of a [ViewfinderSurfaceCoroutineScope] to provide a
+     * coroutine context. Always invoked on the main thread.
+     *
+     * @param onSurface Callback invoked when a new [ViewfinderSurfaceHolder] is created.
+     */
+    fun onSurface(
+        onSurface:
+            suspend ViewfinderSurfaceCoroutineScope.(
+                viewfinderSurfaceHolder: ViewfinderSurfaceHolder
+            ) -> Unit
+    )
+}
diff --git a/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt b/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt
index d437b554..6ef7e4a 100644
--- a/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt
+++ b/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt
@@ -41,10 +41,23 @@
     /** Reparent the surface control to null. */
     fun detach()
 
+    /**
+     * Reparents this surface control to a new parent [SurfaceControlCompat].
+     *
+     * On older API levels, this is a no-op and will return `false`.
+     *
+     * @param newParent The new parent [SurfaceControlCompat].
+     * @return `true` if the reparent operation was performed, `false` otherwise.
+     */
+    fun reparent(newParent: SurfaceControlCompat): Boolean
+
     companion object {
         /**
          * Creates a SurfaceControl or a stub implementation.
          *
+         * This method creates a *new* SurfaceControl that is parented to the provided
+         * [SurfaceView].
+         *
          * @param parent The SurfaceView to use as a parent.
          * @param format The format to use for the SurfaceControl (on newer APIs).
          * @param width The width to set on the SurfaceControl or the Surface.
@@ -65,28 +78,99 @@
             } else {
                 SurfaceControlStub
             }
+
+        /**
+         * Creates a SurfaceControl or a stub implementation.
+         *
+         * This method creates a *new* SurfaceControl that is parented to another
+         * [SurfaceControlCompat].
+         *
+         * @param parent The SurfaceControlCompat to use as a parent.
+         * @param width The width to set on the SurfaceControl or the Surface.
+         * @param height The height to set on the SurfaceControl or the Surface.
+         * @param name The name of the SurfaceControl to create.
+         * @return a compat implementation of [SurfaceControlCompat].
+         */
+        @JvmStatic
+        fun create(
+            parent: SurfaceControlCompat,
+            width: Int,
+            height: Int,
+            name: String,
+        ): SurfaceControlCompat =
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                SurfaceControlApi29Impl(parent, width, height, name)
+            } else {
+                SurfaceControlStub
+            }
+
+        /**
+         * Wraps a SurfaceView's SurfaceControl if available.
+         *
+         * This method wraps an *existing* SurfaceControl obtained from the provided [SurfaceView].
+         * It does not create a new SurfaceControl.
+         *
+         * @param surfaceView The SurfaceView to wrap the SurfaceControl from.
+         * @return a compat implementation of [SurfaceControlCompat] or a stub if the SurfaceView
+         *   does not have a SurfaceControl.
+         */
+        @JvmStatic
+        fun wrap(surfaceView: SurfaceView): SurfaceControlCompat =
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                SurfaceControlApi29Impl(surfaceControl = surfaceView.surfaceControl)
+            } else {
+                SurfaceControlStub
+            }
     }
 
     /** API 29+ implementation of [SurfaceControlCompat]. */
     @RequiresApi(Build.VERSION_CODES.Q)
     private class SurfaceControlApi29Impl(
-        parent: SurfaceView,
-        format: Int,
-        width: Int,
-        height: Int,
-        name: String,
+        // Primary constructor now wraps an existing SurfaceControl
+        private val surfaceControl: SurfaceControl
     ) : SurfaceControlCompat {
-        private val surfaceControl: SurfaceControl =
+        // Secondary constructor for creating a new SurfaceControl with a parent SurfaceView
+        constructor(
+            parent: SurfaceView,
+            format: Int,
+            width: Int,
+            height: Int,
+            name: String,
+        ) : this( // Calls the primary constructor with the newly built SurfaceControl
             SurfaceControl.Builder()
                 .setName(name)
                 .setFormat(format)
                 .setBufferSize(width, height)
                 .setParent(parent.surfaceControl)
                 .build()
+        ) {
+            initializeNewSurfaceControl()
+        }
 
-        init {
+        // Secondary constructor for creating a new SurfaceControl with a parent
+        // SurfaceControlCompat
+        constructor(
+            parent: SurfaceControlCompat,
+            width: Int,
+            height: Int,
+            name: String,
+        ) : this( // Calls the primary constructor with the newly built SurfaceControl
+            SurfaceControl.Builder()
+                .setName(name)
+                .setBufferSize(width, height)
+                .setParent((parent as SurfaceControlApi29Impl).surfaceControl)
+                .build()
+        ) {
+            // Call the common initialization logic here
+            initializeNewSurfaceControl()
+        }
+
+        /**
+         * Contains initialization logic that should only run when a new SurfaceControl is created.
+         */
+        private fun initializeNewSurfaceControl() {
             SurfaceControl.Transaction().use { transaction ->
-                transaction.setVisibility(surfaceControl, true).apply()
+                transaction.setVisibility(this.surfaceControl, true).apply()
             }
         }
 
@@ -109,6 +193,21 @@
                 transaction.reparent(surfaceControl, null).apply()
             }
         }
+
+        override fun reparent(newParent: SurfaceControlCompat): Boolean {
+            if (surfaceControl.isValid) {
+                SurfaceControl.Transaction().use { transaction ->
+                    transaction
+                        .reparent(
+                            surfaceControl,
+                            (newParent as SurfaceControlApi29Impl).surfaceControl,
+                        )
+                        .apply()
+                }
+                return true
+            }
+            return false
+        }
     }
 
     /** Stub implementation of [SurfaceControlCompat] for older APIs. */
@@ -126,5 +225,7 @@
         override fun detach() {
             // No-op for older APIs
         }
+
+        override fun reparent(newParent: SurfaceControlCompat) = false // No-op for older APIs
     }
 }
diff --git a/compose/runtime/runtime-annotation/bcv/native/current.txt b/compose/runtime/runtime-annotation/bcv/native/current.txt
index 761247d..77e0b8b 100644
--- a/compose/runtime/runtime-annotation/bcv/native/current.txt
+++ b/compose/runtime/runtime-annotation/bcv/native/current.txt
@@ -1,5 +1,5 @@
 // Klib ABI Dump
-// Targets: [iosArm64, iosSimulatorArm64, iosX64, linuxArm64, linuxX64, macosArm64, macosX64, tvosArm64, tvosSimulatorArm64, tvosX64, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64]
+// Targets: [iosArm64, iosSimulatorArm64, iosX64, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64]
 // Rendering settings:
 // - Signature version: 2
 // - Show manifest properties: true
diff --git a/compose/runtime/runtime-annotation/build.gradle b/compose/runtime/runtime-annotation/build.gradle
index cef7792..026f4bf 100644
--- a/compose/runtime/runtime-annotation/build.gradle
+++ b/compose/runtime/runtime-annotation/build.gradle
@@ -36,9 +36,11 @@
     jvm()
     mac()
     linux()
+    mingwX64()
     ios()
     watchos()
     tvos()
+    js()
     wasmJs()
 
     defaultPlatform(PlatformIdentifier.JVM)
@@ -50,12 +52,21 @@
             }
         }
 
+        jsMain {
+            dependsOn(commonMain)
+            dependencies {
+                // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1):
+                // https://youtrack.jetbrains.com/issue/KT-71032
+                implementation(libs.kotlinStdlibJs)
+            }
+        }
+
         wasmJsMain {
             dependsOn(commonMain)
             dependencies {
                 // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1):
                 // https://youtrack.jetbrains.com/issue/KT-71032
-                api(libs.kotlinStdlibWasm)
+                implementation(libs.kotlinStdlibWasm)
             }
         }
     }
diff --git a/compose/runtime/runtime-test-utils/build.gradle b/compose/runtime/runtime-test-utils/build.gradle
index 7a6c022..5cda752 100644
--- a/compose/runtime/runtime-test-utils/build.gradle
+++ b/compose/runtime/runtime-test-utils/build.gradle
@@ -13,8 +13,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import androidx.build.SoftwareType
+
 import androidx.build.PlatformIdentifier
+import androidx.build.SoftwareType
+import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
+import org.jetbrains.kotlin.konan.target.Family
 
 plugins {
     id("AndroidXPlugin")
@@ -25,8 +28,14 @@
     androidLibrary {
         namespace = "androidx.compose.runtime.testutils"
     }
-    jvmStubs()
-    linuxX64Stubs()
+    desktop()
+    mingwX64()
+    linux()
+    mac()
+    ios()
+    tvos()
+    watchos()
+    js()
     wasmJs()
 
     defaultPlatform(PlatformIdentifier.ANDROID)
@@ -36,7 +45,7 @@
             dependencies {
                 implementation(libs.kotlinStdlibCommon)
                 implementation(project(":compose:runtime:runtime"))
-                implementation kotlin("test")
+                implementation(libs.kotlinTest)
                 implementation(libs.kotlinCoroutinesTest)
                 implementation(libs.kotlinReflect)
             }
@@ -44,31 +53,65 @@
 
         jvmMain {
             dependsOn(commonMain)
-            dependencies {
-            }
         }
 
         androidMain {
             dependsOn(jvmMain)
+        }
+
+        desktopMain {
+            dependsOn(jvmMain)
+        }
+
+        nativeMain {
+            dependsOn(commonMain)
+        }
+
+        unixMain {
+            dependsOn(nativeMain)
+        }
+
+        darwinMain {
+            dependsOn(unixMain)
+        }
+
+        linuxMain {
+            dependsOn(unixMain)
+        }
+
+        webMain {
+            dependsOn(commonMain)
+        }
+
+        jsMain {
+            dependsOn(webMain)
             dependencies {
+                // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1):
+                // https://youtrack.jetbrains.com/issue/KT-71032
+                implementation(libs.kotlinStdlibJs)
             }
         }
 
-        commonStubsMain {
-            dependsOn(commonMain)
-        }
-        jvmStubsMain {
-            dependsOn(jvmMain)
-        }
-        linuxx64StubsMain {
-            dependsOn(commonStubsMain)
-        }
         wasmJsMain {
-            dependsOn(commonStubsMain)
+            dependsOn(webMain)
             dependencies {
+                // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1):
+                // https://youtrack.jetbrains.com/issue/KT-71032
                 implementation(libs.kotlinStdlibWasm)
             }
         }
+
+        targets.configureEach { target ->
+            if (target.platformType == KotlinPlatformType.native) {
+                if (target.konanTarget.family.appleFamily) {
+                    target.compilations["main"].defaultSourceSet.dependsOn(darwinMain)
+                } else if (target.konanTarget.family == Family.LINUX) {
+                    target.compilations["main"].defaultSourceSet.dependsOn(linuxMain)
+                } else {
+                    target.compilations["main"].defaultSourceSet.dependsOn(nativeMain)
+                }
+            }
+        }
     }
 }
 
diff --git a/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt b/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt
index 0337a1e..2f27586 100644
--- a/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt
+++ b/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt
@@ -13,10 +13,48 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+@file:OptIn(ExperimentalContracts::class)
 
 package androidx.compose.runtime.mock
 
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * A [SynchronizedObject] provides a mechanism for thread coordination. Instances of this class are
+ * used within [synchronized] functions to establish mutual exclusion, guaranteeing that only one
+ * thread accesses a protected resource or code block at a time.
+ */
 internal expect class SynchronizedObject()
 
-@PublishedApi
-internal expect inline fun  synchronized(lock: SynchronizedObject, block: () -> R): R
+/**
+ * Executes the given function [action] while holding the monitor of the given object [lock].
+ *
+ * The implementation is platform specific:
+ * - JVM: implemented via `synchronized`, `ReentrantLock` is avoided for performance reasons.
+ * - Native: implemented via POSIX mutex with `PTHREAD_MUTEX_RECURSIVE` flag.
+ */
+@Suppress("LEAKED_IN_PLACE_LAMBDA", "WRONG_INVOCATION_KIND") // KT-29963
+internal inline fun  synchronized(lock: SynchronizedObject, crossinline action: () -> T): T {
+    contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) }
+    return synchronizedImpl(lock, action)
+}
+
+/**
+ * Executes the given function [action] while holding the monitor of the given object [lock].
+ *
+ * The implementation is platform specific:
+ * - JVM: implemented via `synchronized`, `ReentrantLock` is avoided for performance reasons.
+ * - Native: implemented via POSIX mutex with `PTHREAD_MUTEX_RECURSIVE` flag.
+ *
+ * **This is a private API and should not be used from general code.** This function exists
+ * primarily as a workaround for a Kotlin issue
+ * ([KT-29963](https://youtrack.jetbrains.com/issue/KT-29963)).
+ *
+ * You **MUST** use [synchronized] instead.
+ */
+internal expect inline fun  synchronizedImpl(
+    lock: SynchronizedObject,
+    crossinline action: () -> T,
+): T
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime-test-utils/src/darwinMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.darwin.kt
similarity index 82%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime-test-utils/src/darwinMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.darwin.kt
index 3bf4637..ceba43f 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime-test-utils/src/darwinMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.darwin.kt
@@ -14,6 +14,6 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.mock
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+internal actual val PTHREAD_MUTEX_RECURSIVE: Int = platform.posix.PTHREAD_MUTEX_RECURSIVE
diff --git a/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt b/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt
index e5733da..a5c1c26 100644
--- a/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt
+++ b/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt
@@ -14,13 +14,11 @@
  * limitations under the License.
  */
 
-@file:JvmName("ActualJvm_jvmKt")
-@file:JvmMultifileClass
-
 package androidx.compose.runtime.mock
 
 internal actual typealias SynchronizedObject = Any
 
-@PublishedApi
-internal actual inline fun  synchronized(lock: SynchronizedObject, block: () -> R): R =
-    kotlin.synchronized(lock, block)
+internal actual inline fun  synchronizedImpl(
+    lock: SynchronizedObject,
+    crossinline action: () -> T,
+): T = kotlin.synchronized(lock, action)
diff --git a/compose/runtime/runtime-test-utils/src/linuxMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.linux.kt b/compose/runtime/runtime-test-utils/src/linuxMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.linux.kt
new file mode 100644
index 0000000..9c27da4
--- /dev/null
+++ b/compose/runtime/runtime-test-utils/src/linuxMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.linux.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.mock
+
+internal actual val PTHREAD_MUTEX_RECURSIVE: Int = platform.posix.PTHREAD_MUTEX_RECURSIVE.toInt()
diff --git a/compose/runtime/runtime-test-utils/src/mingwX64Main/kotlin/androidx/compose/runtime/mock/SynchronizedObject.mingwX64.kt b/compose/runtime/runtime-test-utils/src/mingwX64Main/kotlin/androidx/compose/runtime/mock/SynchronizedObject.mingwX64.kt
new file mode 100644
index 0000000..4f094ed
--- /dev/null
+++ b/compose/runtime/runtime-test-utils/src/mingwX64Main/kotlin/androidx/compose/runtime/mock/SynchronizedObject.mingwX64.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.mock
+
+import kotlinx.cinterop.Arena
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.ptr
+import platform.posix.pthread_mutex_destroy
+import platform.posix.pthread_mutex_init
+import platform.posix.pthread_mutex_lock
+import platform.posix.pthread_mutex_tVar
+import platform.posix.pthread_mutex_unlock
+import platform.posix.pthread_mutexattr_destroy
+import platform.posix.pthread_mutexattr_init
+import platform.posix.pthread_mutexattr_settype
+import platform.posix.pthread_mutexattr_tVar
+
+internal actual val PTHREAD_MUTEX_RECURSIVE: Int = platform.posix.PTHREAD_MUTEX_RECURSIVE
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual class SynchronizedObjectImpl {
+    private val arena = Arena()
+    private val attr: pthread_mutexattr_tVar = arena.alloc()
+    private val mutex: pthread_mutex_tVar = arena.alloc()
+
+    init {
+        pthread_mutexattr_init(attr.ptr)
+        pthread_mutexattr_settype(attr.ptr, PTHREAD_MUTEX_RECURSIVE)
+        pthread_mutex_init(mutex.ptr, attr.ptr)
+    }
+
+    internal actual fun lock(): Int = pthread_mutex_lock(mutex.ptr)
+
+    internal actual fun unlock(): Int = pthread_mutex_unlock(mutex.ptr)
+
+    internal actual fun dispose() {
+        pthread_mutex_destroy(mutex.ptr)
+        pthread_mutexattr_destroy(attr.ptr)
+        arena.clear()
+    }
+}
diff --git a/compose/runtime/runtime-test-utils/src/nativeMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.native.kt b/compose/runtime/runtime-test-utils/src/nativeMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.native.kt
new file mode 100644
index 0000000..c0471e6
--- /dev/null
+++ b/compose/runtime/runtime-test-utils/src/nativeMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.native.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.mock
+
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+import kotlin.experimental.ExperimentalNativeApi
+import kotlin.native.ref.createCleaner
+
+/**
+ * Wrapper for platform.posix.PTHREAD_MUTEX_RECURSIVE which is represented as kotlin.Int on darwin
+ * platforms and kotlin.UInt on linuxX64 See: https://youtrack.jetbrains.com/issue/KT-41509
+ */
+internal expect val PTHREAD_MUTEX_RECURSIVE: Int
+
+internal expect class SynchronizedObjectImpl() {
+    internal fun lock(): Int
+
+    internal fun unlock(): Int
+
+    internal fun dispose()
+}
+
+internal actual class SynchronizedObject actual constructor() {
+    private val impl = SynchronizedObjectImpl()
+
+    @Suppress("unused") // The returned Cleaner must be assigned to a property
+    @OptIn(ExperimentalNativeApi::class)
+    private val cleaner = createCleaner(impl, SynchronizedObjectImpl::dispose)
+
+    fun lock() {
+        impl.lock()
+    }
+
+    fun unlock() {
+        impl.unlock()
+    }
+}
+
+@OptIn(ExperimentalContracts::class)
+internal actual inline fun  synchronizedImpl(
+    lock: SynchronizedObject,
+    crossinline action: () -> T,
+): T {
+    contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) }
+    lock.lock()
+    return try {
+        action()
+    } finally {
+        lock.unlock()
+    }
+}
diff --git a/compose/runtime/runtime-test-utils/src/unixMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.unix.kt b/compose/runtime/runtime-test-utils/src/unixMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.unix.kt
new file mode 100644
index 0000000..abcf94b
--- /dev/null
+++ b/compose/runtime/runtime-test-utils/src/unixMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.unix.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.mock
+
+import kotlinx.cinterop.Arena
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.ptr
+import platform.posix.pthread_mutex_destroy
+import platform.posix.pthread_mutex_init
+import platform.posix.pthread_mutex_lock
+import platform.posix.pthread_mutex_t
+import platform.posix.pthread_mutex_unlock
+import platform.posix.pthread_mutexattr_destroy
+import platform.posix.pthread_mutexattr_init
+import platform.posix.pthread_mutexattr_settype
+import platform.posix.pthread_mutexattr_t
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual class SynchronizedObjectImpl actual constructor() {
+    private val arena: Arena = Arena()
+    private val attr: pthread_mutexattr_t = arena.alloc()
+    private val mutex: pthread_mutex_t = arena.alloc()
+
+    init {
+        pthread_mutexattr_init(attr.ptr)
+        pthread_mutexattr_settype(attr.ptr, PTHREAD_MUTEX_RECURSIVE)
+        pthread_mutex_init(mutex.ptr, attr.ptr)
+    }
+
+    internal actual fun lock(): Int = pthread_mutex_lock(mutex.ptr)
+
+    internal actual fun unlock(): Int = pthread_mutex_unlock(mutex.ptr)
+
+    internal actual fun dispose() {
+        pthread_mutex_destroy(mutex.ptr)
+        pthread_mutexattr_destroy(attr.ptr)
+        arena.clear()
+    }
+}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime-test-utils/src/webMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.web.kt
similarity index 72%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime-test-utils/src/webMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.web.kt
index 3bf4637..62b5bc5 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime-test-utils/src/webMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.web.kt
@@ -14,6 +14,11 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.mock
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+internal actual class SynchronizedObject actual constructor()
+
+internal actual inline fun  synchronizedImpl(
+    lock: SynchronizedObject,
+    crossinline action: () -> T,
+): T = action()
diff --git a/compose/runtime/runtime/bcv/native/current.txt b/compose/runtime/runtime/bcv/native/current.txt
index 7e0ace3..b15feee 100644
--- a/compose/runtime/runtime/bcv/native/current.txt
+++ b/compose/runtime/runtime/bcv/native/current.txt
@@ -1,5 +1,5 @@
 // Klib ABI Dump
-// Targets: [linuxX64.linuxx64Stubs]
+// Targets: [iosArm64, iosSimulatorArm64, iosX64, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64]
 // Rendering settings:
 // - Signature version: 2
 // - Show manifest properties: true
@@ -712,6 +712,11 @@
         final fun (): kotlin/Boolean // androidx.compose.runtime/ProvidedValue.canOverride.|(){}[0]
 }
 
+final class androidx.compose.runtime.platform/SynchronizedObject { // androidx.compose.runtime.platform/SynchronizedObject|null[0]
+    final fun lock() // androidx.compose.runtime.platform/SynchronizedObject.lock|lock(){}[0]
+    final fun unlock() // androidx.compose.runtime.platform/SynchronizedObject.unlock|unlock(){}[0]
+}
+
 final class androidx.compose.runtime.snapshots/SnapshotApplyConflictException : kotlin/Exception { // androidx.compose.runtime.snapshots/SnapshotApplyConflictException|null[0]
     constructor (androidx.compose.runtime.snapshots/Snapshot) // androidx.compose.runtime.snapshots/SnapshotApplyConflictException.|(androidx.compose.runtime.snapshots.Snapshot){}[0]
 
@@ -1273,8 +1278,6 @@
 final fun (androidx.compose.runtime/State).androidx.compose.runtime/asFloatState(): androidx.compose.runtime/FloatState // androidx.compose.runtime/asFloatState|[email protected](){}[0]
 final fun (androidx.compose.runtime/State).androidx.compose.runtime/asIntState(): androidx.compose.runtime/IntState // androidx.compose.runtime/asIntState|[email protected](){}[0]
 final fun (androidx.compose.runtime/State).androidx.compose.runtime/asLongState(): androidx.compose.runtime/LongState // androidx.compose.runtime/asLongState|[email protected](){}[0]
-final fun (kotlin/Long).androidx.compose.runtime.snapshots/toInt(): kotlin/Int // androidx.compose.runtime.snapshots/toInt|[email protected](){}[0]
-final fun (kotlin/Long).androidx.compose.runtime.snapshots/toLong(): kotlin/Long // androidx.compose.runtime.snapshots/toLong|[email protected](){}[0]
 final fun <#A: #B, #B: kotlin/Any?> (kotlinx.coroutines.flow/Flow<#A>).androidx.compose.runtime/collectAsState(#B, kotlin.coroutines/CoroutineContext?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State<#B> // androidx.compose.runtime/collectAsState|[email protected]<0:0>(0:1;kotlin.coroutines.CoroutineContext?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§<0:1>;1§}[0]
 final fun <#A: androidx.compose.runtime.snapshots/StateRecord> (#A).androidx.compose.runtime.snapshots/readable(androidx.compose.runtime.snapshots/StateObject): #A // androidx.compose.runtime.snapshots/readable|readable@0:0(androidx.compose.runtime.snapshots.StateObject){0§}[0]
 final fun <#A: androidx.compose.runtime.snapshots/StateRecord> (#A).androidx.compose.runtime.snapshots/readable(androidx.compose.runtime.snapshots/StateObject, androidx.compose.runtime.snapshots/Snapshot): #A // androidx.compose.runtime.snapshots/readable|readable@0:0(androidx.compose.runtime.snapshots.StateObject;androidx.compose.runtime.snapshots.Snapshot){0§}[0]
@@ -1447,6 +1450,7 @@
 final fun androidx.compose.runtime.internal/androidx_compose_runtime_internal_WeakReference$stableprop_getter(): kotlin/Int // androidx.compose.runtime.internal/androidx_compose_runtime_internal_WeakReference$stableprop_getter|androidx_compose_runtime_internal_WeakReference$stableprop_getter(){}[0]
 final fun androidx.compose.runtime.internal/composableLambda(androidx.compose.runtime/Composer, kotlin/Int, kotlin/Boolean, kotlin/Any): androidx.compose.runtime.internal/ComposableLambda // androidx.compose.runtime.internal/composableLambda|composableLambda(androidx.compose.runtime.Composer;kotlin.Int;kotlin.Boolean;kotlin.Any){}[0]
 final fun androidx.compose.runtime.internal/composableLambdaInstance(kotlin/Int, kotlin/Boolean, kotlin/Any): androidx.compose.runtime.internal/ComposableLambda // androidx.compose.runtime.internal/composableLambdaInstance|composableLambdaInstance(kotlin.Int;kotlin.Boolean;kotlin.Any){}[0]
+final fun androidx.compose.runtime.internal/identityHashCode(kotlin/Any?): kotlin/Int // androidx.compose.runtime.internal/identityHashCode|identityHashCode(kotlin.Any?){}[0]
 final fun androidx.compose.runtime.internal/illegalDecoyCallException(kotlin/String): kotlin/Nothing // androidx.compose.runtime.internal/illegalDecoyCallException|illegalDecoyCallException(kotlin.String){}[0]
 final fun androidx.compose.runtime.internal/rememberComposableLambda(kotlin/Int, kotlin/Boolean, kotlin/Any, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime.internal/ComposableLambda // androidx.compose.runtime.internal/rememberComposableLambda|rememberComposableLambda(kotlin.Int;kotlin.Boolean;kotlin.Any;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
 final fun androidx.compose.runtime.platform/androidx_compose_runtime_platform_SynchronizedObject$stableprop_getter(): kotlin/Int // androidx.compose.runtime.platform/androidx_compose_runtime_platform_SynchronizedObject$stableprop_getter|androidx_compose_runtime_platform_SynchronizedObject$stableprop_getter(){}[0]
@@ -1598,6 +1602,8 @@
 final inline fun (androidx.compose.runtime/MutableFloatState).androidx.compose.runtime/setValue(kotlin/Any?, kotlin.reflect/KProperty<*>, kotlin/Float) // androidx.compose.runtime/setValue|[email protected](kotlin.Any?;kotlin.reflect.KProperty<*>;kotlin.Float){}[0]
 final inline fun (androidx.compose.runtime/MutableIntState).androidx.compose.runtime/setValue(kotlin/Any?, kotlin.reflect/KProperty<*>, kotlin/Int) // androidx.compose.runtime/setValue|[email protected](kotlin.Any?;kotlin.reflect.KProperty<*>;kotlin.Int){}[0]
 final inline fun (androidx.compose.runtime/MutableLongState).androidx.compose.runtime/setValue(kotlin/Any?, kotlin.reflect/KProperty<*>, kotlin/Long) // androidx.compose.runtime/setValue|[email protected](kotlin.Any?;kotlin.reflect.KProperty<*>;kotlin.Long){}[0]
+final inline fun (kotlin/Long).androidx.compose.runtime.snapshots/toInt(): kotlin/Int // androidx.compose.runtime.snapshots/toInt|[email protected](){}[0]
+final inline fun (kotlin/Long).androidx.compose.runtime.snapshots/toLong(): kotlin/Long // androidx.compose.runtime.snapshots/toLong|[email protected](){}[0]
 final inline fun (kotlin/Long).androidx.compose.runtime/toLong(): kotlin/Long // androidx.compose.runtime/toLong|[email protected](){}[0]
 final inline fun (kotlin/Long).androidx.compose.runtime/toString(kotlin/Int): kotlin/String // androidx.compose.runtime/toString|[email protected](kotlin.Int){}[0]
 final inline fun <#A: androidx.compose.runtime.snapshots/StateRecord, #B: kotlin/Any?> (#A).androidx.compose.runtime.snapshots/withCurrent(kotlin/Function1<#A, #B>): #B // androidx.compose.runtime.snapshots/withCurrent|withCurrent@0:0(kotlin.Function1<0:0,0:1>){0§;1§}[0]
diff --git a/compose/runtime/runtime/build.gradle b/compose/runtime/runtime/build.gradle
index f648be3..fcf63ff 100644
--- a/compose/runtime/runtime/build.gradle
+++ b/compose/runtime/runtime/build.gradle
@@ -25,6 +25,8 @@
 import androidx.build.SoftwareType
 import androidx.build.PlatformIdentifier
 import com.android.build.api.dsl.KotlinMultiplatformAndroidHostTestCompilation
+import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
+import org.jetbrains.kotlin.konan.target.Family
 
 plugins {
     id("AndroidXPlugin")
@@ -42,8 +44,14 @@
             it.returnDefaultValues = true
         }
     }
-    jvmStubs(/* runTests */ true)
-    linuxX64Stubs()
+    desktop()
+    mingwX64()
+    linux()
+    mac()
+    ios()
+    tvos()
+    watchos()
+    js()
     wasmJs()
 
     defaultPlatform(PlatformIdentifier.ANDROID)
@@ -123,41 +131,122 @@
             dependsOn(commonMain)
         }
 
-        jvmStubsMain {
+        nonAndroidTest {
+            dependsOn(commonTest)
+        }
+
+        desktopMain {
             dependsOn(jvmMain)
             dependsOn(nonAndroidMain)
         }
 
-        jvmStubsTest {
+        desktopTest {
             dependsOn(jvmTest)
             dependsOn(nonEmulatorJvmTest)
+            dependsOn(nonAndroidTest)
         }
 
         nonJvmMain {
             dependsOn(nonAndroidMain)
+            dependencies {
+                implementation(libs.atomicFu)
+            }
         }
 
-        linuxx64StubsMain {
+        nonJvmTest {
+            dependsOn(nonAndroidTest)
+        }
+
+        nativeMain {
+            dependsOn(nonAndroidMain)
             dependsOn(nonJvmMain)
         }
 
+        nativeTest {
+            dependsOn(nonAndroidTest)
+            dependsOn(nonJvmTest)
+            dependsOn(nonEmulatorCommonTest)
+        }
+
         webMain {
             dependsOn(nonJvmMain)
         }
 
-        wasmJsMain {
+        webTest {
+            dependsOn(nonJvmTest)
+            dependsOn(nonEmulatorCommonTest)
+        }
+
+        jsMain {
             dependsOn(webMain)
             dependencies {
-                implementation("org.jetbrains.kotlinx:kotlinx-browser:0.1")
+                // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1):
+                // https://youtrack.jetbrains.com/issue/KT-71032
+                implementation(libs.kotlinStdlibJs)
             }
         }
 
-        webTest {
-            dependsOn(nonEmulatorCommonTest)
+        jsTest {
+            dependsOn(webTest)
+            dependencies {
+                implementation(libs.kotlinTestJs)
+            }
+        }
+
+        wasmJsMain {
+            dependsOn(webMain)
+            dependencies {
+                // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1):
+                // https://youtrack.jetbrains.com/issue/KT-71032
+                implementation(libs.kotlinStdlibWasm)
+                implementation(libs.kotlinXw3c)
+            }
         }
 
         wasmJsTest {
             dependsOn(webTest)
+            dependencies {
+                implementation(libs.kotlinTestWasm)
+            }
+        }
+
+        unixMain {
+            dependsOn(nativeMain)
+        }
+
+        unixTest {
+            dependsOn(nativeTest)
+        }
+
+        darwinMain {
+            dependsOn(unixMain)
+        }
+
+        darwinTest {
+            dependsOn(unixTest)
+        }
+
+        linuxMain {
+            dependsOn(unixMain)
+        }
+
+        linuxTest {
+            dependsOn(unixTest)
+        }
+
+        targets.configureEach { target ->
+            if (target.platformType == KotlinPlatformType.native) {
+                if (target.konanTarget.family.appleFamily) {
+                    target.compilations["main"].defaultSourceSet.dependsOn(darwinMain)
+                    target.compilations["test"].defaultSourceSet.dependsOn(darwinTest)
+                } else if (target.konanTarget.family == Family.LINUX) {
+                    target.compilations["main"].defaultSourceSet.dependsOn(linuxMain)
+                    target.compilations["test"].defaultSourceSet.dependsOn(linuxTest)
+                } else {
+                    target.compilations["main"].defaultSourceSet.dependsOn(nativeMain)
+                    target.compilations["test"].defaultSourceSet.dependsOn(nativeTest)
+                }
+            }
         }
     }
 }
diff --git a/compose/runtime/runtime/lint-baseline.xml b/compose/runtime/runtime/lint-baseline.xml
index 3532a6b..ede2caa 100644
--- a/compose/runtime/runtime/lint-baseline.xml
+++ b/compose/runtime/runtime/lint-baseline.xml
@@ -2,6 +2,15 @@
 
 
     
+        id="NullAnnotationGroup"
+        message="Could not find associated group for annotation androidx.compose.runtime.InternalComposeApi, which is used in androidx.compose.runtime."
+        errorLine1="@InternalComposeApi"
+        errorLine2="~~~~~~~~~~~~~~~~~~~">
+        
+            file="src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt"/>
+    
+
+    
         id="BanThreadSleep"
         message="Uses Thread.sleep()"
         errorLine1="                Thread.sleep(0)"
diff --git a/compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreTargets.android.kt
similarity index 100%
rename from compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
rename to compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreTargets.android.kt
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
index eef6899..8dc0226 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
@@ -485,14 +485,14 @@
 }
 
 private class ComposePausableCompositionException(
-    val instances: ObjectList,
-    val reused: ObjectList,
-    val operations: IntList,
-    val lastOperation: Int,
+    private val instances: ObjectList,
+    private val reused: ObjectList,
+    private val operations: IntList,
+    private val lastOperation: Int,
     cause: Throwable?,
 ) : Exception(cause) {
 
-    fun operations(): Sequence = sequence {
+    private fun operationsSequence(): Sequence = sequence {
         var currentOperation = 0
         var currentInstance = 0
         var currentReused = 0
@@ -557,7 +557,7 @@
         get() =
             """
             |Exception while applying pausable composition. Last 10 operations:
-            |${operations().toList().takeLast(10).joinToString("\n")}
+            |${operationsSequence().toList().takeLast(10).joinToString("\n")}
             """
                 .trimMargin()
 }
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt
index 584556d..b3a3b39 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt
@@ -83,3 +83,5 @@
 }
 
 internal expect fun Int.toSnapshotId(): SnapshotId
+
+internal expect fun Long.toSnapshotId(): SnapshotId
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt
index 92f7916..0e6c318 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt
@@ -42,14 +42,14 @@
 }
 
 internal abstract class ComposeStackTraceBuilder {
-    private val trace = mutableListOf()
+    private val _trace = mutableListOf()
 
-    fun trace(): List = trace
+    fun trace(): List = _trace
 
     private fun appendTraceFrame(groupSourceInformation: GroupSourceInformation, child: Any?) {
         val frame = extractTraceFrame(groupSourceInformation, child)
         if (frame != null) {
-            trace += frame
+            _trace += frame
         }
     }
 
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/TestUtils.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/TestUtils.kt
new file mode 100644
index 0000000..9833ae7
--- /dev/null
+++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/TestUtils.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.reflect.KClass
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.test.TestResult
+import kotlinx.coroutines.test.TestScope
+
+private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
+
+internal fun runTest(
+    context: CoroutineContext = EmptyCoroutineContext,
+    dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
+    timeoutMs: Long? = null,
+    expected: KClass? = null,
+    testBody: suspend TestScope.() -> Unit,
+): TestResult =
+    kotlinx.coroutines.test.runTest(context, timeout = dispatchTimeoutMs.milliseconds) {
+        val testScope = this
+        if (timeoutMs == null) {
+            runTestImpl(expected) { testBody() }
+        } else {
+            testWithTimeout(timeoutMs) {
+                testBody(testScope)
+                runTestImpl(expected) { testBody(testScope) }
+            }
+        }
+    }
+
+internal expect suspend fun testWithTimeout(
+    timeoutMs: Long,
+    block: suspend CoroutineScope.() -> Unit,
+)
+
+private inline fun runTestImpl(expected: KClass? = null, block: () -> Unit) {
+    if (expected != null) {
+        var exception: Throwable? = null
+        try {
+            block()
+        } catch (e: Throwable) {
+            exception = e
+        }
+        assertTrue(
+            exception != null && expected.isInstance(exception),
+            "Expected $expected to be thrown",
+        )
+    } else {
+        block()
+    }
+}
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
deleted file mode 100644
index 1a25a11..0000000
--- a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package kotlinx.test
-
-expect annotation class IgnoreAndroidUnitTestTarget()
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreTargets.kt
similarity index 66%
rename from compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt
rename to compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreTargets.kt
index 18bb059..b8c1e98 100644
--- a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt
+++ b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreTargets.kt
@@ -13,7 +13,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
+@file:OptIn(ExperimentalMultiplatform::class)
+
 package kotlinx.test
 
-// TODO(b/409723535): Differentiate between WASM and JS targets.
-expect annotation class IgnoreJsTarget()
+@OptionalExpectation expect annotation class IgnoreAndroidUnitTestTarget()
+
+@OptionalExpectation expect annotation class IgnoreNativeTarget()
+
+@OptionalExpectation expect annotation class IgnoreWasmTarget()
+
+@OptionalExpectation expect annotation class IgnoreJsTarget()
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt
similarity index 81%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt
index 3bf4637..68eab97 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt
@@ -14,6 +14,6 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.platform
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+internal actual val PTHREAD_MUTEX_ERRORCHECK: Int = platform.posix.PTHREAD_MUTEX_ERRORCHECK
diff --git a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.desktop.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.desktop.kt
new file mode 100644
index 0000000..b4bb612
--- /dev/null
+++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.desktop.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlinx.coroutines.delay
+
+@Deprecated(
+    "MonotonicFrameClocks are not globally applicable across platforms. " +
+        "Use an appropriate local clock."
+)
+actual val DefaultMonotonicFrameClock: MonotonicFrameClock
+    get() = SixtyFpsMonotonicFrameClock
+
+private object SixtyFpsMonotonicFrameClock : MonotonicFrameClock {
+    private const val fps = 60
+
+    override suspend fun  withFrameNanos(onFrame: (Long) -> R): R {
+        delay(1000L / fps)
+        return onFrame(System.nanoTime())
+    }
+}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt
similarity index 64%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt
index 3bf4637..c994dc2 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt
@@ -14,6 +14,15 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+@file:JvmName("ActualJvm_jvmKt")
+@file:JvmMultifileClass
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+package androidx.compose.runtime
+
+@InternalComposeApi
+@Deprecated(
+    level = DeprecationLevel.HIDDEN,
+    message = "Made internal. It wasn't supposed to be public",
+)
+fun identityHashCode(instance: Any?): Int =
+    androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldSynchronization.desktop.kt
similarity index 76%
copy from compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt
copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldSynchronization.desktop.kt
index 26722cb1..c615f66 100644
--- a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt
+++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldSynchronization.desktop.kt
@@ -14,19 +14,17 @@
  * limitations under the License.
  */
 
-@file:JvmName("ActualJvm_jvmKt")
-@file:JvmMultifileClass
+@file:JvmName("SynchronizationKt")
 
 package androidx.compose.runtime
 
-import androidx.compose.runtime.platform.SynchronizedObject
-import kotlin.DeprecationLevel.HIDDEN
+internal class SynchronizedObject
 
 @PublishedApi
 @JvmName("synchronized")
 @Deprecated(
-    level = HIDDEN,
+    level = DeprecationLevel.HIDDEN,
     message = "not expected to be referenced directly as the old version had to be inlined",
 )
-internal inline fun  oldSynchronized(lock: SynchronizedObject, block: () -> R): R =
+internal inline fun  oldSynchronized2(lock: SynchronizedObject, block: () -> R): R =
     androidx.compose.runtime.platform.synchronized(lock, block)
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/internal/Utils.desktop.kt
similarity index 77%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/internal/Utils.desktop.kt
index 3bf4637..934f60eb 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/internal/Utils.desktop.kt
@@ -14,6 +14,9 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.internal
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+internal actual fun logError(message: String, e: Throwable) {
+    System.err.println(message)
+    e.printStackTrace(System.err)
+}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt
similarity index 60%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt
index 3bf4637..4aef7e6 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt
@@ -14,6 +14,13 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.platform
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+internal actual typealias SynchronizedObject = androidx.compose.runtime.SynchronizedObject
+
+@Suppress("NOTHING_TO_INLINE")
+internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject()
+
+@PublishedApi
+internal actual inline fun  synchronized(lock: SynchronizedObject, block: () -> R): R =
+    kotlin.synchronized(lock, block)
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.js.kt
similarity index 100%
rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt
rename to compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.js.kt
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.js.kt
similarity index 63%
copy from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
copy to compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.js.kt
index 488cac6..78eadd1 100644
--- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
+++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.js.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright 2020 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,8 +14,12 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime
 
-annotation class NotIgnored
-
-actual typealias IgnoreAndroidUnitTestTarget = NotIgnored
+@InternalComposeApi
+@Deprecated(
+    level = DeprecationLevel.HIDDEN,
+    message = "Made internal. It wasn't supposed to be public",
+)
+fun identityHashCode(instance: Any?): Int =
+    androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.js.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.js.kt
new file mode 100644
index 0000000..92e412b
--- /dev/null
+++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.js.kt
@@ -0,0 +1,278 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(InternalComposeApi::class)
+
+package androidx.compose.runtime.internal
+
+import androidx.compose.runtime.ComposeCompilerApi
+import androidx.compose.runtime.Composer
+import androidx.compose.runtime.InternalComposeApi
+import androidx.compose.runtime.Stable
+
+@ComposeCompilerApi
+@Stable
+actual interface ComposableLambda {
+    actual operator fun invoke(p1: Composer, p2: Int): Any?
+
+    actual operator fun invoke(p1: Any?, p2: Composer, p3: Int): Any?
+
+    actual operator fun invoke(p1: Any?, p2: Any?, p3: Composer, p4: Int): Any?
+
+    actual operator fun invoke(p1: Any?, p2: Any?, p3: Any?, p4: Composer, p5: Int): Any?
+
+    actual operator fun invoke(p1: Any?, p2: Any?, p3: Any?, p4: Any?, p5: Composer, p6: Int): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Composer,
+        p7: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Composer,
+        p8: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Composer,
+        p9: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Composer,
+        p10: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Composer,
+        p11: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Composer,
+        p12: Int,
+        p13: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Composer,
+        p13: Int,
+        p14: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Any?,
+        p13: Composer,
+        p14: Int,
+        p15: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Any?,
+        p13: Any?,
+        p14: Composer,
+        p15: Int,
+        p16: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Any?,
+        p13: Any?,
+        p14: Any?,
+        p15: Composer,
+        p16: Int,
+        p17: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Any?,
+        p13: Any?,
+        p14: Any?,
+        p15: Any?,
+        p16: Composer,
+        p17: Int,
+        p18: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Any?,
+        p13: Any?,
+        p14: Any?,
+        p15: Any?,
+        p16: Any?,
+        p17: Composer,
+        p18: Int,
+        p19: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Any?,
+        p13: Any?,
+        p14: Any?,
+        p15: Any?,
+        p16: Any?,
+        p17: Any?,
+        p18: Composer,
+        p19: Int,
+        p20: Int,
+    ): Any?
+
+    actual operator fun invoke(
+        p1: Any?,
+        p2: Any?,
+        p3: Any?,
+        p4: Any?,
+        p5: Any?,
+        p6: Any?,
+        p7: Any?,
+        p8: Any?,
+        p9: Any?,
+        p10: Any?,
+        p11: Any?,
+        p12: Any?,
+        p13: Any?,
+        p14: Any?,
+        p15: Any?,
+        p16: Any?,
+        p17: Any?,
+        p18: Any?,
+        p19: Composer,
+        p20: Int,
+        p21: Int,
+    ): Any?
+}
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/System.js.kt
similarity index 73%
copy from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt
copy to compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/System.js.kt
index e6e5839..e3f0c98 100644
--- a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt
+++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/System.js.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2025 The Android Open Source Project
+ * Copyright 2024 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,18 +21,18 @@
 private var nextHash = 1
 
 private external interface WeakMap {
-    fun set(key: JsAny, value: Int)
+    fun set(key: Any, value: Int)
 
-    fun get(key: JsAny): Int?
+    fun get(key: Any): Int?
 }
 
 private val weakMap: WeakMap = js("new WeakMap()")
 
 @NoLiveLiterals
-private fun memoizeIdentityHashCode(instance: JsAny): Int {
+private fun memoizeIdentityHashCode(instance: Any): Int {
     val value = nextHash++
 
-    weakMap.set(instance.toJsReference(), value)
+    weakMap.set(instance, value)
 
     return value
 }
@@ -42,6 +42,5 @@
         return 0
     }
 
-    val jsRef = instance.toJsReference()
-    return weakMap.get(jsRef) ?: memoizeIdentityHashCode(jsRef)
+    return weakMap.get(instance) ?: memoizeIdentityHashCode(instance)
 }
diff --git a/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt
new file mode 100644
index 0000000..1a2b988
--- /dev/null
+++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER")
+
+package androidx.compose.runtime.snapshots
+
+import androidx.collection.mutableDoubleListOf
+
+actual typealias SnapshotId = Double
+
+internal actual const val SnapshotIdZero: SnapshotId = 0.0
+internal actual const val SnapshotIdMax: SnapshotId = Double.MAX_VALUE
+internal actual const val SnapshotIdSize: Int = Double.SIZE_BITS
+internal actual const val SnapshotIdInvalidValue: SnapshotId = -1.0
+
+internal actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int =
+    this.compareTo(other)
+
+internal actual inline operator fun SnapshotId.compareTo(other: Int): Int =
+    this.compareTo(other.toLong())
+
+internal actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong()
+
+internal actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other
+
+internal actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong()
+
+internal actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong()
+
+internal actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong()
+
+actual inline fun SnapshotId.toInt(): Int = this.toInt()
+
+actual inline fun SnapshotId.toLong(): Long = this.toLong()
+
+actual typealias SnapshotIdArray = DoubleArray
+
+internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray =
+    DoubleArray(capacity)
+
+internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index]
+
+internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) {
+    this[index] = value
+}
+
+internal actual inline val SnapshotIdArray.size: Int
+    get() = this.size
+
+internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) {
+    this.copyInto(other, 0)
+}
+
+internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0]
+
+internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int {
+    var low = 0
+    var high = size - 1
+
+    while (low <= high) {
+        val mid = (low + high).ushr(1)
+        val midVal = get(mid)
+        if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid
+    }
+    return -(low + 1)
+}
+
+internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) {
+    for (value in this) {
+        block(value)
+    }
+}
+
+internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray {
+    val newSize = size + 1
+    val newArray = DoubleArray(newSize)
+    this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index)
+    this.copyInto(
+        destination = newArray,
+        destinationOffset = index + 1,
+        startIndex = index,
+        endIndex = newSize - 1,
+    )
+    newArray[index] = id
+    return newArray
+}
+
+internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? {
+    val newSize = this.size - 1
+    if (newSize == 0) {
+        return null
+    }
+    val newArray = DoubleArray(newSize)
+    if (index > 0) {
+        this.copyInto(
+            destination = newArray,
+            destinationOffset = 0,
+            startIndex = 0,
+            endIndex = index,
+        )
+    }
+    if (index < newSize) {
+        this.copyInto(
+            destination = newArray,
+            destinationOffset = index,
+            startIndex = index + 1,
+            endIndex = newSize + 1,
+        )
+    }
+    return newArray
+}
+
+internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) {
+    private val list = array?.let { mutableDoubleListOf(*array) } ?: mutableDoubleListOf()
+
+    actual fun add(id: SnapshotId) {
+        list.add(id)
+    }
+
+    actual fun toArray(): SnapshotIdArray? {
+        val size = list.size
+        if (size == 0) return null
+        val result = DoubleArray(size)
+        list.forEachIndexed { index, element -> result[index] = element }
+        return result
+    }
+}
+
+internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = doubleArrayOf(id)
+
+internal actual fun Int.toSnapshotId(): SnapshotId = toDouble()
+
+internal actual fun Long.toSnapshotId(): SnapshotId = toDouble()
diff --git a/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/Actuals.js.kt b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/Actuals.js.kt
new file mode 100644
index 0000000..236fa91
--- /dev/null
+++ b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/Actuals.js.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.await
+import kotlinx.coroutines.promise
+import kotlinx.coroutines.test.TestResult
+
+internal actual suspend fun TestResult.awaitCompletion() {
+    await()
+}
+
+@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE")
+internal actual fun wrapTestWithCoroutine(block: suspend () -> Unit): TestResult {
+    return MainScope().promise { block() }
+}
diff --git a/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/TestUtils.js.kt b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/TestUtils.js.kt
new file mode 100644
index 0000000..4bddc2e
--- /dev/null
+++ b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/TestUtils.js.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlin.js.Promise
+import kotlinx.coroutines.*
+
+@OptIn(DelicateCoroutinesApi::class)
+actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) {
+    Promise { resolve, reject ->
+            GlobalScope.launch {
+                try {
+                    withTimeout(timeoutMs) { block() }
+                    resolve(Unit)
+                } catch (t: Throwable) {
+                    reject(t)
+                }
+            }
+        }
+        .await()
+}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/jsTest/kotlin/kotlinx/test/IgnoreTargets.js.kt
similarity index 92%
rename from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
rename to compose/runtime/runtime/src/jsTest/kotlin/kotlinx/test/IgnoreTargets.js.kt
index 3bf4637..4268c2f 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/jsTest/kotlin/kotlinx/test/IgnoreTargets.js.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2024 The Android Open Source Project
+ * Copyright 2021 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/OldSynchronization.jvm.kt
similarity index 94%
rename from compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt
rename to compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/OldSynchronization.jvm.kt
index 26722cb1..7844fbf 100644
--- a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt
+++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/OldSynchronization.jvm.kt
@@ -20,12 +20,11 @@
 package androidx.compose.runtime
 
 import androidx.compose.runtime.platform.SynchronizedObject
-import kotlin.DeprecationLevel.HIDDEN
 
 @PublishedApi
 @JvmName("synchronized")
 @Deprecated(
-    level = HIDDEN,
+    level = DeprecationLevel.HIDDEN,
     message = "not expected to be referenced directly as the old version had to be inlined",
 )
 internal inline fun  oldSynchronized(lock: SynchronizedObject, block: () -> R): R =
diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt
index 41858cb..c0aa19a 100644
--- a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt
+++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt
@@ -143,3 +143,5 @@
 internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id)
 
 internal actual fun Int.toSnapshotId(): SnapshotId = toLong()
+
+internal actual fun Long.toSnapshotId(): SnapshotId = this
diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/CompositionJvmTests.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/CompositionJvmTests.kt
new file mode 100644
index 0000000..3693128
--- /dev/null
+++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/CompositionJvmTests.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(InternalComposeApi::class)
+
+package androidx.compose.runtime
+
+import androidx.compose.runtime.mock.Text
+import androidx.compose.runtime.mock.compositionTest
+import androidx.compose.runtime.mock.validate
+import kotlin.reflect.KProperty
+import kotlin.test.Test
+
+@Stable
+@Suppress("unused")
+class CompositionJvmTests {
+    /* TODO: Move this test back to commonTest after updating to Kotlin 2.2
+       Due to a bug in Kotlin 2.1.2x https://youtrack.jetbrains.com/issue/KT-77508, compilation of
+       the tests for K/JS and K/Native fails with
+       "Wrong number of parameters in wrapper: expected: 0 bound and 2 unbound, but 0 found".
+       So ignoring doesn't really work for this case.
+    */
+    @Test
+    fun composableDelegates() = compositionTest {
+        val local = compositionLocalOf { "Default" }
+        val delegatedLocal by local
+        compose {
+            Text(delegatedLocal)
+
+            CompositionLocalProvider(local provides "Scoped") { Text(delegatedLocal) }
+        }
+        validate {
+            Text("Default")
+            Text("Scoped")
+        }
+    }
+}
+
+@Composable
+private operator fun  CompositionLocal.getValue(thisRef: Any?, property: KProperty<*>) =
+    current
diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/kotlinx/test/IgnoreJsTarget.jvm.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/TestUtils.jvm.kt
similarity index 61%
rename from compose/runtime/runtime/src/jvmTest/kotlin/kotlinx/test/IgnoreJsTarget.jvm.kt
rename to compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/TestUtils.jvm.kt
index bebc8a3..ca82c7d 100644
--- a/compose/runtime/runtime/src/jvmTest/kotlin/kotlinx/test/IgnoreJsTarget.jvm.kt
+++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/TestUtils.jvm.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 The Android Open Source Project
+ * Copyright 2025 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,8 +14,13 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime
 
-annotation class DoNothing
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
 
-actual typealias IgnoreJsTarget = DoNothing
+actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) =
+    runBlocking {
+        withTimeout(timeoutMs, block)
+    }
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt
similarity index 80%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt
index 3bf4637..3b75e07 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt
@@ -14,6 +14,6 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.platform
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+internal actual val PTHREAD_MUTEX_ERRORCHECK: Int = platform.posix.PTHREAD_MUTEX_ERRORCHECK.toInt()
diff --git a/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt b/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt
new file mode 100644
index 0000000..6db50ce
--- /dev/null
+++ b/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.compose.runtime.platform
+
+import androidx.compose.runtime.internal.currentThreadId
+import kotlinx.atomicfu.*
+
+@Suppress("NOTHING_TO_INLINE")
+internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject()
+
+@PublishedApi
+internal actual inline fun  synchronized(lock: SynchronizedObject, block: () -> R): R {
+    lock.run {
+        lock()
+        return try {
+            block()
+        } finally {
+            unlock()
+        }
+    }
+}
+
+/**
+ * Re-entrant spin lock implementation.
+ *
+ * `SynchronizedObject` from `kotlinx-atomicfu` library was used before. However, it is still
+ * [experimental](https://github.com/Kotlin/kotlinx-atomicfu?tab=readme-ov-file#locks) and has
+ * [a performance problem](https://github.com/Kotlin/kotlinx-atomicfu/issues/412) that seriously
+ * affects Compose.
+ *
+ * Using a posix mutex is
+ * [problematic for mingwX64](https://youtrack.jetbrains.com/issue/KT-70449/Posix-declarations-differ-much-for-mingwX64-and-LinuxDarwin-targets),
+ * so we just use a simple spin lock for mingwX64 (maybe reconsidered in case of problems).
+ */
+@PublishedApi
+internal actual class SynchronizedObject internal constructor() {
+
+    private companion object {
+        private const val NO_OWNER = -1L
+    }
+
+    private val owner: AtomicLong = atomic(NO_OWNER)
+    private var reEnterCount: Int = 0
+
+    @PublishedApi
+    internal fun lock() {
+        if (owner.value == currentThreadId()) {
+            reEnterCount += 1
+        } else {
+            // Busy wait
+            while (!owner.compareAndSet(NO_OWNER, currentThreadId())) {}
+        }
+    }
+
+    @PublishedApi
+    internal fun unlock() {
+        require(owner.value == currentThreadId())
+        if (reEnterCount > 0) {
+            reEnterCount -= 1
+        } else {
+            owner.value = NO_OWNER
+        }
+    }
+}
diff --git a/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.native.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.native.kt
new file mode 100644
index 0000000..2b88ffa
--- /dev/null
+++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.native.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlin.time.TimeSource
+import kotlinx.coroutines.yield
+
+/**
+ * The [MonotonicFrameClock] used by [withFrameNanos] and [withFrameMillis] if one is not present in
+ * the calling [kotlin.coroutines.CoroutineContext].
+ *
+ * This value is no longer used by compose runtime.
+ */
+@Deprecated(
+    "MonotonicFrameClocks are not globally applicable across platforms. " +
+        "Use an appropriate local clock."
+)
+actual val DefaultMonotonicFrameClock: MonotonicFrameClock =
+    object : MonotonicFrameClock {
+        override suspend fun  withFrameNanos(onFrame: (Long) -> R): R {
+            yield()
+            return onFrame(TimeSource.Monotonic.markNow().elapsedNow().inWholeNanoseconds)
+        }
+    }
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.native.kt
similarity index 63%
copy from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
copy to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.native.kt
index 488cac6..78eadd1 100644
--- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
+++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.native.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright 2020 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,8 +14,12 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime
 
-annotation class NotIgnored
-
-actual typealias IgnoreAndroidUnitTestTarget = NotIgnored
+@InternalComposeApi
+@Deprecated(
+    level = DeprecationLevel.HIDDEN,
+    message = "Made internal. It wasn't supposed to be public",
+)
+fun identityHashCode(instance: Any?): Int =
+    androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.native.kt
similarity index 98%
rename from compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt
rename to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.native.kt
index a17fe80..ab4f393 100644
--- a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt
+++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.native.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2020 The Android Open Source Project
+ * Copyright 2024 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.web.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/System.native.kt
similarity index 71%
rename from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.web.kt
rename to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/System.native.kt
index b07c105..695444b 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.web.kt
+++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/System.native.kt
@@ -14,8 +14,10 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.internal
 
-annotation class NotIgnored
+import kotlin.experimental.ExperimentalNativeApi
+import kotlin.native.identityHashCode
 
-actual typealias IgnoreAndroidUnitTestTarget = NotIgnored
+@OptIn(ExperimentalNativeApi::class)
+actual fun identityHashCode(instance: Any?): Int = instance.identityHashCode()
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/Thread.native.kt
similarity index 64%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/Thread.native.kt
index 3bf4637..1cee4ae 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/Thread.native.kt
@@ -14,6 +14,14 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.internal
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+import kotlinx.atomicfu.atomic
+
+private val threadCounter = atomic(0L)
+
[email protected] private var threadId: Long = threadCounter.addAndGet(1)
+
+internal actual fun currentThreadId(): Long = threadId
+
+internal actual fun currentThreadName(): String = "thread@$threadId"
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/WeakReference.native.kt
similarity index 63%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/WeakReference.native.kt
index 3bf4637..631ff88 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/WeakReference.native.kt
@@ -14,6 +14,13 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime.internal
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+import kotlin.experimental.ExperimentalNativeApi
+
+@OptIn(ExperimentalNativeApi::class)
+internal actual class WeakReference actual constructor(reference: T) {
+    private val kotlinNativeReference = kotlin.native.ref.WeakReference(reference)
+
+    actual fun get(): T? = kotlinNativeReference.get()
+}
diff --git a/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt
new file mode 100644
index 0000000..c0aa19a
--- /dev/null
+++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER")
+
+package androidx.compose.runtime.snapshots
+
+import androidx.collection.mutableLongListOf
+
+actual typealias SnapshotId = Long
+
+internal actual const val SnapshotIdZero: SnapshotId = 0L
+internal actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE
+internal actual const val SnapshotIdSize: Int = Long.SIZE_BITS
+internal actual const val SnapshotIdInvalidValue: SnapshotId = -1
+
+internal actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int =
+    this.compareTo(other)
+
+internal actual inline operator fun SnapshotId.compareTo(other: Int): Int =
+    this.compareTo(other.toLong())
+
+internal actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong()
+
+internal actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other
+
+internal actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong()
+
+internal actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong()
+
+internal actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong()
+
+actual inline fun SnapshotId.toInt(): Int = this.toInt()
+
+actual inline fun SnapshotId.toLong(): Long = this
+
+actual typealias SnapshotIdArray = LongArray
+
+internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray =
+    LongArray(capacity)
+
+internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index]
+
+internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) {
+    this[index] = value
+}
+
+internal actual inline val SnapshotIdArray.size: Int
+    get() = this.size
+
+internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) {
+    this.copyInto(other, 0)
+}
+
+internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0]
+
+internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int {
+    var low = 0
+    var high = size - 1
+
+    while (low <= high) {
+        val mid = (low + high).ushr(1)
+        val midVal = get(mid)
+        if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid
+    }
+    return -(low + 1)
+}
+
+internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) {
+    for (value in this) {
+        block(value)
+    }
+}
+
+internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray {
+    val newSize = size + 1
+    val newArray = LongArray(newSize)
+    this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index)
+    this.copyInto(
+        destination = newArray,
+        destinationOffset = index + 1,
+        startIndex = index,
+        endIndex = newSize - 1,
+    )
+    newArray[index] = id
+    return newArray
+}
+
+internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? {
+    val newSize = this.size - 1
+    if (newSize == 0) {
+        return null
+    }
+    val newArray = LongArray(newSize)
+    if (index > 0) {
+        this.copyInto(
+            destination = newArray,
+            destinationOffset = 0,
+            startIndex = 0,
+            endIndex = index,
+        )
+    }
+    if (index < newSize) {
+        this.copyInto(
+            destination = newArray,
+            destinationOffset = index,
+            startIndex = index + 1,
+            endIndex = newSize + 1,
+        )
+    }
+    return newArray
+}
+
+internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) {
+    private val list = array?.let { mutableLongListOf(*array) } ?: mutableLongListOf()
+
+    actual fun add(id: SnapshotId) {
+        list.add(id)
+    }
+
+    actual fun toArray(): SnapshotIdArray? {
+        val size = list.size
+        if (size == 0) return null
+        val result = LongArray(size)
+        list.forEachIndexed { index, element -> result[index] = element }
+        return result
+    }
+}
+
+internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id)
+
+internal actual fun Int.toSnapshotId(): SnapshotId = toLong()
+
+internal actual fun Long.toSnapshotId(): SnapshotId = this
diff --git a/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/TestUtils.native.kt b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/TestUtils.native.kt
new file mode 100644
index 0000000..971b4ee
--- /dev/null
+++ b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/TestUtils.native.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+
+actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) =
+    runBlocking {
+        // TODO: k/native tests run in debug mode and much-much slower than jvm,
+        // so we adjust for it here by multiplying by 10 :(
+        withTimeout(timeoutMs * 10, block)
+    }
diff --git a/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt
new file mode 100644
index 0000000..6d27038
--- /dev/null
+++ b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.platform
+
+import kotlin.concurrent.AtomicInt
+import kotlin.native.concurrent.*
+import kotlin.test.*
+
+private const val iterations = 100
+private const val nWorkers = 4
+private const val increments = 500
+private const val nLocks = 5
+
+private fun nest(lock: SynchronizedObject, nestedLocks: Int, count: AtomicInt) {
+    synchronized(lock) {
+        if (nestedLocks == 1) {
+            val oldValue = count.value
+            count.value = oldValue + 1
+        } else {
+            nest(lock, nestedLocks - 1, count)
+        }
+    }
+}
+
+/**
+ * Test is taken from [kotlinx-atomicfu](https://github.com/Kotlin/kotlinx-atomicfu) with a few
+ * modifications.
+ */
+@OptIn(ObsoleteWorkersApi::class)
+class SynchronizedTest {
+    @Test
+    fun stressCounterTest() {
+        repeat(iterations) {
+            val workers = Array(nWorkers) { Worker.start() }
+            val counter = AtomicInt(0)
+            val so = makeSynchronizedObject()
+            workers.forEach { worker ->
+                worker.execute(TransferMode.SAFE, { counter to so }) { (count, lock) ->
+                    repeat(increments) {
+                        val nestedLocks = (1..3).random()
+                        nest(lock, nestedLocks, count)
+                    }
+                }
+            }
+            workers.forEach { it.requestTermination().result }
+            assertEquals(nWorkers * increments, counter.value)
+        }
+    }
+
+    @Test
+    fun manyLocksTest() {
+        repeat(iterations) {
+            val workers = Array(nWorkers) { Worker.start() }
+            val counters = Array(nLocks) { AtomicInt(0) }
+            val locks = Array(nLocks) { makeSynchronizedObject() }
+            workers.forEach { worker ->
+                worker.execute(TransferMode.SAFE, { counters to locks }) { (counters, locks) ->
+                    locks.forEachIndexed { i, lock ->
+                        repeat(increments) {
+                            synchronized(lock) {
+                                val oldValue = counters[i].value
+                                counters[i].value = oldValue + 1
+                            }
+                        }
+                    }
+                }
+            }
+            workers.forEach { it.requestTermination().result }
+            assertEquals(nWorkers * nLocks * increments, counters.sumOf { it.value })
+        }
+    }
+}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/nativeTest/kotlin/kotlinx/test/IgnoreTargets.native.kt
similarity index 84%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/nativeTest/kotlin/kotlinx/test/IgnoreTargets.native.kt
index 3bf4637..bf10881 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/nativeTest/kotlin/kotlinx/test/IgnoreTargets.native.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2024 The Android Open Source Project
+ * Copyright 2025 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,4 +16,4 @@
 
 package kotlinx.test
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+actual typealias IgnoreNativeTarget = kotlin.test.Ignore
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt
index fac0850..3d3346b 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt
@@ -36,6 +36,7 @@
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.yield
 import kotlinx.test.IgnoreJsTarget
+import kotlinx.test.IgnoreWasmTarget
 
 @ExperimentalCoroutinesApi
 class BroadcastFrameClockTest {
@@ -91,6 +92,7 @@
     //
     // Remove this annotation if JS adds proper multithreading support, then flee the area quickly.
     @IgnoreJsTarget
+    @IgnoreWasmTarget
     @OptIn(InternalCoroutinesApi::class)
     @Test
     fun locklessCancellation() =
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt
index adea6dd..43473a5 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt
@@ -28,6 +28,7 @@
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 import kotlinx.test.IgnoreJsTarget
+import kotlinx.test.IgnoreWasmTarget
 
 @Stable
 @OptIn(ExperimentalComposeRuntimeApi::class)
@@ -66,7 +67,10 @@
     }
 
     @Test
-    @IgnoreJsTarget // b/409727436
+    // TODO: b/409727436
+    // TODO: https://youtrack.jetbrains.com/issue/CMP-797
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
     fun observeScope() {
         val observer = SingleScopeObserver()
         compositionTest {
@@ -94,7 +98,10 @@
     }
 
     @Test
-    @IgnoreJsTarget // b/409727436
+    // TODO: b/409727436
+    // TODO: https://youtrack.jetbrains.com/issue/CMP-797
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
     fun observeScope_dispose() {
         val observer = SingleScopeObserver()
         compositionTest {
@@ -129,7 +136,10 @@
     }
 
     @Test
-    @IgnoreJsTarget // b/409727436
+    // TODO: b/409727436
+    // TODO: https://youtrack.jetbrains.com/issue/CMP-797
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
     fun observeScope_scopeRemoved() {
         val observer = SingleScopeObserver()
         compositionTest {
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt
index 2bd40cd..31fc54f 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt
@@ -73,6 +73,7 @@
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.withContext
 import kotlinx.test.IgnoreJsTarget
+import kotlinx.test.IgnoreWasmTarget
 
 @Composable fun Container(content: @Composable () -> Unit) = content()
 
@@ -2566,7 +2567,9 @@
     }
 
     @Test
-    @IgnoreJsTarget // b/409728274
+    // The test for web is properly implemented in CompositionTests.web.kt
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
     fun testRememberObserver_Abandon_Recompose() {
         val abandonedObjects = mutableListOf()
         val observed =
@@ -4390,6 +4393,12 @@
         revalidate()
     }
 
+    /* TODO: Restore after updating to Kotlin 2.2
+        Due to a bug in Kotlin 2.1.2x https://youtrack.jetbrains.com/issue/KT-77508, compilation of
+        the tests for K/JS and K/Native fails with
+        "Wrong number of parameters in wrapper: expected: 0 bound and 2 unbound, but 0 found".
+        So ignoring doesn't really work for this case. For now the test is moved to CompositionJvmTests
+
     @Test
     fun composableDelegates() = compositionTest {
         val local = compositionLocalOf { "Default" }
@@ -4404,6 +4413,7 @@
             Text("Scoped")
         }
     }
+    */
 
     @Test
     fun testCompositionAndRecomposerDeadlock() {
@@ -5104,7 +5114,8 @@
     }
 
 @Composable
-operator fun  CompositionLocal.getValue(thisRef: Any?, property: KProperty<*>) = current
+private operator fun  CompositionLocal.getValue(thisRef: Any?, property: KProperty<*>) =
+    current
 
 // for 274185312
 
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt
index 4a87a12..24c0316e 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt
@@ -21,30 +21,34 @@
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.withTimeout
 
 @ExperimentalCoroutinesApi
 class LatchTest {
     @Test
-    fun openDoesntSuspend() = runTest {
-        val latch = Latch()
-        assertTrue(latch.isOpen, "latch open after construction")
+    fun openDoesntSuspend() =
+        runTest(UnconfinedTestDispatcher()) {
+            val latch = Latch()
+            assertTrue(latch.isOpen, "latch open after construction")
 
-        val awaiter = launch(start = CoroutineStart.UNDISPATCHED) { latch.await() }
-        assertTrue(awaiter.isCompleted, "await did not suspend")
-    }
+            val awaiter = launch(start = CoroutineStart.UNDISPATCHED) { latch.await() }
+            assertTrue(awaiter.isCompleted, "await did not suspend")
+        }
 
     @Test
-    fun closedSuspendsReleasesAll() = runTest {
-        val latch = Latch()
-        latch.closeLatch()
-        assertTrue(!latch.isOpen, "latch.isOpen after close")
+    fun closedSuspendsReleasesAll() =
+        runTest(UnconfinedTestDispatcher()) {
+            val latch = Latch()
+            latch.closeLatch()
+            assertTrue(!latch.isOpen, "latch.isOpen after close")
 
-        val awaiters = (1..5).map { launch(start = CoroutineStart.UNDISPATCHED) { latch.await() } }
-        assertTrue("all awaiters still active") { awaiters.all { it.isActive } }
+            val awaiters =
+                (1..5).map { launch(start = CoroutineStart.UNDISPATCHED) { latch.await() } }
+            assertTrue("all awaiters still active") { awaiters.all { it.isActive } }
 
-        latch.openLatch()
-        withTimeout(500) { awaiters.map { it.join() } }
-    }
+            latch.openLatch()
+            withTimeout(500) { awaiters.map { it.join() } }
+        }
 }
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt
index ef91012..56bbf88 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt
@@ -29,13 +29,13 @@
 import kotlin.test.Ignore
 import kotlin.test.Test
 import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
 import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.test.runTest
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @Stable
 class PausableCompositionTests {
     @Test
@@ -426,8 +426,8 @@
     }
 
     @Test
-    fun pausableComposition_throwInResume() = runTest {
-        assertFailsWith {
+    fun pausableComposition_throwInResume() =
+        runTest(expected = IllegalStateException::class) {
             val recomposer = Recomposer(coroutineContext)
             val pausableComposition = PausableComposition(EmptyApplier(), recomposer)
 
@@ -440,11 +440,10 @@
                 recomposer.close()
             }
         }
-    }
 
     @Test
-    fun pausableComposition_throwInApply() = runTest {
-        assertFailsWith {
+    fun pausableComposition_throwInApply() =
+        runTest(expected = IllegalStateException::class) {
             val recomposer = Recomposer(coroutineContext)
             val pausableComposition = PausableComposition(EmptyApplier(), recomposer)
 
@@ -460,7 +459,6 @@
                 recomposer.close()
             }
         }
-    }
 
     @Test
     fun pausableComposition_isAppliedReturnsCorrectValue() = runTest {
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt
index cdca0cf..048285d 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt
@@ -43,6 +43,8 @@
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeoutOrNull
 import kotlinx.test.IgnoreJsTarget
+import kotlinx.test.IgnoreNativeTarget
+import kotlinx.test.IgnoreWasmTarget
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class RecomposerTests {
@@ -354,8 +356,12 @@
         }
     }
 
+    // TODO: b/409727145
+    // TODO: https://youtrack.jetbrains.com/issue/CMP-7455
     @Test
-    @IgnoreJsTarget // b/409727145
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
+    @IgnoreNativeTarget
     fun stateChangesDuringApplyChangesAreNotifiedBeforeFrameFinished() = compositionTest {
         val count = mutableStateOf(0)
         val countFromEffect = mutableStateOf(0)
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt
index 1c64504..5c78c83 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt
@@ -25,11 +25,16 @@
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.withContext
 import kotlinx.test.IgnoreJsTarget
+import kotlinx.test.IgnoreNativeTarget
+import kotlinx.test.IgnoreWasmTarget
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class SnapshotContextElementTests {
+    // TODO: b/409725929
     @Test
-    @IgnoreJsTarget // b/409725929
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
+    @IgnoreNativeTarget
     fun coroutineEntersExpectedSnapshot() =
         runTest(UnconfinedTestDispatcher()) {
             val snapshot = Snapshot.takeSnapshot()
@@ -43,6 +48,8 @@
         }
 
     @Test
+    @IgnoreJsTarget
+    @IgnoreNativeTarget
     fun snapshotRestoredAfterResume() {
         val snapshotOne = Snapshot.takeSnapshot()
         val snapshotTwo = Snapshot.takeSnapshot()
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt
index b645d39..03d47d2 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt
@@ -35,9 +35,9 @@
     fun canAddAndRemoveNumbersInSequence() {
         val heap = SnapshotDoubleIndexHeap()
         val handles = IntArray(100)
-        repeat(100) { handles[it] = heap.add(it.toLong()) }
+        repeat(100) { handles[it] = heap.add(it.toSnapshotId()) }
         repeat(100) {
-            assertEquals(it.toLong(), heap.lowestOrDefault(-1))
+            assertEquals(it.toSnapshotId(), heap.lowestOrDefault(SnapshotIdInvalidValue))
             heap.remove(handles[it])
         }
         assertEquals(0, heap.size)
@@ -55,24 +55,24 @@
             if (shouldAdd) {
                 val indexToAdd = random.nextInt(toAdd.size)
                 val value = toAdd[indexToAdd]
-                val handle = heap.add(value.toLong())
+                val handle = heap.add(value.toSnapshotId())
                 toRemove.add(value to handle)
                 toAdd.removeAt(indexToAdd)
             } else {
                 val indexToRemove = random.nextInt(toRemove.size)
                 val (value, handle) = toRemove[indexToRemove]
-                assertTrue(heap.lowestOrDefault(-1) <= value)
+                assertTrue(heap.lowestOrDefault(SnapshotIdInvalidValue) <= value)
                 heap.remove(handle)
                 toRemove.removeAt(indexToRemove)
             }
 
             heap.validate()
             for ((value, handle) in toRemove) {
-                heap.validateHandle(handle, value.toLong())
+                heap.validateHandle(handle, value.toSnapshotId())
             }
             val lowestAdded =
                 toRemove.fold(400) { lowest, (value, _) -> if (value < lowest) value else lowest }
-            assertEquals(lowestAdded, heap.lowestOrDefault(400).toInt())
+            assertEquals(lowestAdded, heap.lowestOrDefault(400.toSnapshotId()).toInt())
         }
     }
 }
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt
index ee92d0c..0104afb 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt
@@ -35,7 +35,8 @@
     @Test
     fun shouldBeAbleToSetItems() {
         val times = 10000L
-        val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) }
+        val set =
+            (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index.toSnapshotId()) }
 
         repeat(times) { set.shouldBe(it, true) }
     }
@@ -45,7 +46,7 @@
         val times = 10000L
         val set =
             (0..times).fold(SnapshotIdSet.EMPTY) { prev, index ->
-                if (index % 2L == 0L) prev.set(index) else prev
+                if (index % 2L == 0L) prev.set(index.toSnapshotId()) else prev
             }
 
         repeat(times) { set.shouldBe(it, it % 2L == 0L) }
@@ -56,7 +57,7 @@
         val times = 10000L
         val set =
             (0..times).fold(SnapshotIdSet.EMPTY) { prev, index ->
-                if (index % 2L == 1L) prev.set(index) else prev
+                if (index % 2L == 1L) prev.set(index.toSnapshotId()) else prev
             }
 
         repeat(times) { set.shouldBe(it, it % 2L == 1L) }
@@ -65,11 +66,12 @@
     @Test
     fun shouldBeAbleToClearEvens() {
         val times = 10000L
-        val allSet = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) }
+        val allSet =
+            (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index.toSnapshotId()) }
 
         val set =
             (0..times).fold(allSet) { prev, index ->
-                if (index % 2L == 0L) prev.clear(index) else prev
+                if (index % 2L == 0L) prev.clear(index.toSnapshotId()) else prev
             }
 
         repeat(times - 1) { set.shouldBe(it, it % 2L == 1L) }
@@ -79,7 +81,9 @@
     fun shouldBeAbleToCrawlSet() {
         val times = 10000L
         val set =
-            (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.clear(index - 1).set(index) }
+            (0..times).fold(SnapshotIdSet.EMPTY) { prev, index ->
+                prev.clear(index.toSnapshotId() - 1).set(index.toSnapshotId())
+            }
 
         set.shouldBe(times, true)
         repeat(times - 1) { set.shouldBe(it, false) }
@@ -90,7 +94,11 @@
         val times = 10000L
         val set =
             (0..times).fold(SnapshotIdSet.EMPTY) { prev, index ->
-                prev.let { if ((index - 1L) % 33L != 0L) it.clear(index - 1) else it }.set(index)
+                prev
+                    .let {
+                        if ((index - 1L) % 33L != 0L) it.clear(index.toSnapshotId() - 1) else it
+                    }
+                    .set(index.toSnapshotId())
             }
 
         set.shouldBe(times, true)
@@ -98,7 +106,7 @@
         // The multiples of 33 items should now be set
         repeat(times - 1) { set.shouldBe(it, it % 33L == 0L) }
 
-        val newSet = (0 until times).fold(set) { prev, index -> prev.clear(index) }
+        val newSet = (0 until times).fold(set) { prev, index -> prev.clear(index.toSnapshotId()) }
 
         newSet.shouldBe(times, true)
 
@@ -107,19 +115,19 @@
 
     @Test
     fun shouldBeAbleToInsertAndRemoveOutOfOptimalRange() {
-        SnapshotIdSet.EMPTY.set(1000L)
-            .set(1L)
+        SnapshotIdSet.EMPTY.set(1000L.toSnapshotId())
+            .set(1L.toSnapshotId())
             .shouldBe(1000L, true)
             .shouldBe(1L, true)
-            .set(10L)
+            .set(10L.toSnapshotId())
             .shouldBe(10L, true)
-            .set(4L)
+            .set(4L.toSnapshotId())
             .shouldBe(4L, true)
-            .clear(1L)
+            .clear(1L.toSnapshotId())
             .shouldBe(1L, false)
-            .clear(4L)
+            .clear(4L.toSnapshotId())
             .shouldBe(4L, false)
-            .clear(10L)
+            .clear(10L.toSnapshotId())
             .shouldBe(1L, false)
             .shouldBe(4L, false)
             .shouldBe(10L, false)
@@ -134,14 +142,14 @@
             (0..100L).fold(SnapshotIdSet.EMPTY) { prev, _ ->
                 val value = random.nextInt(0, 1000)
                 booleans[value] = true
-                prev.set(value.toLong())
+                prev.set(value.toSnapshotId())
             }
 
         val clear =
             (0..100).fold(set) { prev, _ ->
                 val value = random.nextInt(0, 1000)
                 booleans[value] = false
-                prev.clear(value.toLong())
+                prev.clear(value.toSnapshotId())
             }
 
         repeat(1000L) { clear.shouldBe(it, booleans[it.toInt()]) }
@@ -155,14 +163,14 @@
             (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ ->
                 val value = random.nextInt(0, 1000)
                 booleans[value] = true
-                prev.set(value.toLong())
+                prev.set(value.toSnapshotId())
             }
 
         val setB =
             (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ ->
                 val value = random.nextInt(0, 1000)
                 booleans[value] = false
-                prev.set(value.toLong())
+                prev.set(value.toSnapshotId())
             }
 
         val set = setA.andNot(setB)
@@ -178,14 +186,14 @@
                 (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index ->
                     if (random.nextInt(0, 1000) > 500) {
                         booleans[index] = true
-                        prev.set(index.toLong())
+                        prev.set(index.toSnapshotId())
                     } else prev
                 }
             val setB =
                 (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index ->
                     if (random.nextInt(0, 1000) > 500) {
                         booleans[index] = false
-                        prev.set(index.toLong())
+                        prev.set(index.toSnapshotId())
                     } else prev
                 }
             val set = setA.andNot(setB)
@@ -207,14 +215,14 @@
                 (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index ->
                     if (random.nextInt(0, 1000) > 500) {
                         booleans[index] = true
-                        prev.set(index.toLong())
+                        prev.set(index.toSnapshotId())
                     } else prev
                 }
             val setB =
                 (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index ->
                     if (random.nextInt(0, 1000) > 500) {
                         booleans[index] = true
-                        prev.set(index.toLong())
+                        prev.set(index.toSnapshotId())
                     } else prev
                 }
             val set = setA.or(setB)
@@ -236,10 +244,10 @@
                 (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index ->
                     if (random.nextInt(0, 1000) > 500) {
                         values.add(index.toLong())
-                        prev.set(index.toLong())
+                        prev.set(index.toSnapshotId())
                     } else prev
                 }
-            values.zip(set).forEach { assertEquals(it.first, it.second) }
+            values.zip(set).forEach { assertEquals(it.first.toSnapshotId(), it.second) }
             assertEquals(values.size, set.count())
         }
 
@@ -251,20 +259,20 @@
 
     @Test // Regression b/182822837
     fun shouldReportTheCorrectLowest() {
-        fun test(number: Long) {
+        fun test(number: SnapshotId) {
             val set = SnapshotIdSet.EMPTY.set(number)
-            assertEquals(number, set.lowest(-1))
+            assertEquals(number, set.lowest(SnapshotIdInvalidValue))
         }
 
-        repeat(64) { test(it) }
+        repeat(64) { test(it.toSnapshotId()) }
     }
 
     @Test
     fun shouldOverflowGracefully() {
-        val s = SnapshotIdSet.EMPTY.set(0).set(Long.MAX_VALUE)
-        assertTrue(s.get(0))
-        assertTrue(s.get(Long.MAX_VALUE))
-        assertFalse(s.get(1))
+        val s = SnapshotIdSet.EMPTY.set(0.toSnapshotId()).set(Long.MAX_VALUE.toSnapshotId())
+        assertTrue(s.get(0.toSnapshotId()))
+        assertTrue(s.get(Long.MAX_VALUE.toSnapshotId()))
+        assertFalse(s.get(1.toSnapshotId()))
     }
 
     @Test // Regression: b/147836978
@@ -5627,17 +5635,18 @@
                 .map { it.split(":").let { it[0].toInt() to it[1].toBoolean() } }
         operations.fold(SnapshotIdSet.EMPTY) { prev, (value, op) ->
             assertTrue(
-                prev.get(value.toLong()) != op,
+                prev.get(value.toSnapshotId()) != op,
                 "Error on bit $value, expected ${!op}, received $op",
             )
-            val result = if (op) prev.set(value.toLong()) else prev.clear(value.toLong())
+            val result =
+                if (op) prev.set(value.toSnapshotId()) else prev.clear(value.toSnapshotId())
             result
         }
     }
 }
 
 private fun SnapshotIdSet.shouldBe(index: Long, value: Boolean): SnapshotIdSet {
-    assertEquals(value, get(index), "Bit $index should be $value")
+    assertEquals(value, get(index.toSnapshotId()), "Bit $index should be $value")
     return this
 }
 
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt
index 20c1d86..3dca762 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt
@@ -19,6 +19,7 @@
 package androidx.compose.runtime.snapshots
 
 import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.runTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
@@ -33,6 +34,8 @@
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.runTest
 import kotlinx.test.IgnoreJsTarget
+import kotlinx.test.IgnoreNativeTarget
+import kotlinx.test.IgnoreWasmTarget
 
 class SnapshotStateMapTests {
     @Test
@@ -132,8 +135,12 @@
         validateWrite { map -> map.entries.clear() }
     }
 
+    // TODO: b/409729875
+    //  test passes if the order is changed to assertEquals(entries.first, entries.second)
     @Test
-    @IgnoreJsTarget // b/409729875
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
+    @IgnoreNativeTarget
     fun validateEntriesIterator() {
         validateRead { map, normalMap ->
             for (entries in map.entries.zip(normalMap.entries)) {
@@ -213,14 +220,22 @@
         validateWrite { map -> map.entries.remove(map.entries.first()) }
     }
 
+    // TODO: b/409727470
+    // TODO: https://youtrack.jetbrains.com/issue/CMP-7397
     @Test
-    @IgnoreJsTarget // b/409727470
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
+    @IgnoreNativeTarget
     fun validateEntriesRemoveAll() {
         validateWrite { map -> map.entries.removeAll(map.entries.filter { it.key % 2 == 0 }) }
     }
 
+    // TODO: b/409727470
+    // TODO: https://youtrack.jetbrains.com/issue/CMP-7397
     @Test
-    @IgnoreJsTarget // b/409727470
+    @IgnoreJsTarget
+    @IgnoreWasmTarget
+    @IgnoreNativeTarget
     fun validateEntriesRetainAll() {
         validateWrite { map -> map.entries.retainAll(map.entries.filter { it.key % 2 == 0 }) }
     }
@@ -383,6 +398,12 @@
 
     @Test
     @IgnoreJsTarget
+    @IgnoreWasmTarget
+    @IgnoreNativeTarget
+    // Ignored for js, wasm and native:
+    // SnapshotStateMap removes a correct element - entry(key=1,value=1f)
+    // The test fails because MutableMap (normalMap) removes entry(key=1, value=5f)
+    // due to an entry search by value starting from the end of an array (in native HashMap impl).
     fun validateValuesRemove() {
         validateWrite { map ->
             map.values.remove(1f)
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt
index eaa2cbd..59eb900 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt
@@ -30,7 +30,7 @@
 
     @Test
     fun stateChangeTriggersCallback() {
-        val data = "Hello World"
+        val data = ValueWrapper("Hello World")
         var changes = 0
 
         val state = mutableStateOf(0)
@@ -38,7 +38,7 @@
         try {
             stateObserver.start()
 
-            val onChangeListener: (String) -> Unit = { affected ->
+            val onChangeListener: (ValueWrapper) -> Unit = { affected ->
                 assertEquals(data, affected)
                 assertEquals(0, changes)
                 changes++
@@ -61,9 +61,9 @@
 
     @Test
     fun multipleStagesWorksTogether() {
-        val strStage1 = "Stage1"
-        val strStage2 = "Stage2"
-        val strStage3 = "Stage3"
+        val strStage1 = ValueWrapper("Stage1")
+        val strStage2 = ValueWrapper("Stage2")
+        val strStage3 = ValueWrapper("Stage3")
         var stage1Changes = 0
         var stage2Changes = 0
         var stage3Changes = 0
@@ -71,17 +71,17 @@
         val stage2Model = mutableStateOf(0)
         val stage3Model = mutableStateOf(0)
 
-        val onChangeStage1: (String) -> Unit = { affectedData ->
+        val onChangeStage1: (ValueWrapper) -> Unit = { affectedData ->
             assertEquals(strStage1, affectedData)
             assertEquals(0, stage1Changes)
             stage1Changes++
         }
-        val onChangeStage2: (String) -> Unit = { affectedData ->
+        val onChangeStage2: (ValueWrapper) -> Unit = { affectedData ->
             assertEquals(strStage2, affectedData)
             assertEquals(0, stage2Changes)
             stage2Changes++
         }
-        val onChangeStage3: (String) -> Unit = { affectedData ->
+        val onChangeStage3: (ValueWrapper) -> Unit = { affectedData ->
             assertEquals(strStage3, affectedData)
             assertEquals(0, stage3Changes)
             stage3Changes++
@@ -114,9 +114,9 @@
 
     @Test
     fun enclosedStagesCorrectlyObserveChanges() {
-        val stage1Info = "stage 1"
-        val stage2Info1 = "stage 1 - value 1"
-        val stage2Info2 = "stage 2 - value 2"
+        val stage1Info = ValueWrapper("stage 1")
+        val stage2Info1 = ValueWrapper("stage 1 - value 1")
+        val stage2Info2 = ValueWrapper("stage 2 - value 2")
         var stage1Changes = 0
         var stage2Changes1 = 0
         var stage2Changes2 = 0
@@ -124,12 +124,12 @@
         val stage2Data1 = mutableStateOf(0)
         val stage2Data2 = mutableStateOf(0)
 
-        val onChangeStage1Listener: (String) -> Unit = { affected ->
+        val onChangeStage1Listener: (ValueWrapper) -> Unit = { affected ->
             assertEquals(affected, stage1Info)
             assertEquals(stage1Changes, 0)
             stage1Changes++
         }
-        val onChangeState2Listener: (String) -> Unit = { affected ->
+        val onChangeState2Listener: (ValueWrapper) -> Unit = { affected ->
             when (affected) {
                 stage2Info1 -> {
                     assertEquals(0, stage2Changes1)
@@ -177,11 +177,11 @@
 
     @Test
     fun stateReadTriggersCallbackAfterSwitchingAdvancingGlobalWithinObserveReads() {
-        val info = "Hello"
+        val info = ValueWrapper("Hello")
         var changes = 0
 
         val state = mutableStateOf(0)
-        val onChangeListener: (String) -> Unit = { _ ->
+        val onChangeListener: (ValueWrapper) -> Unit = { _ ->
             assertEquals(0, changes)
             changes++
         }
@@ -216,7 +216,7 @@
     @Suppress("DEPRECATION")
     @Test
     fun pauseStopsObserving() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes = 0
 
         runSimpleTest { stateObserver, state ->
@@ -230,7 +230,7 @@
 
     @Test
     fun withoutReadObservationStopsObserving() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes = 0
 
         runSimpleTest { stateObserver, state ->
@@ -244,7 +244,7 @@
 
     @Test
     fun changeAfterWithoutReadObservationIsObserving() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes = 0
 
         runSimpleTest { stateObserver, state ->
@@ -260,7 +260,7 @@
     @Suppress("DEPRECATION")
     @Test
     fun nestedPauseStopsObserving() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes = 0
 
         runSimpleTest { stateObserver, state ->
@@ -277,7 +277,7 @@
 
     @Test
     fun nestedWithoutReadObservation() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes = 0
 
         runSimpleTest { stateObserver, state ->
@@ -294,7 +294,7 @@
 
     @Test
     fun simpleObserving() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes = 0
 
         runSimpleTest { stateObserver, state ->
@@ -307,7 +307,7 @@
     @Suppress("DEPRECATION")
     @Test
     fun observeWithinPause() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes1 = 0
         var changes2 = 0
 
@@ -324,7 +324,7 @@
 
     @Test
     fun observeWithinWithoutReadObservation() {
-        val data = "data"
+        val data = ValueWrapper("data")
         var changes1 = 0
         var changes2 = 0
 
@@ -345,8 +345,8 @@
         var changes2 = 0
 
         runSimpleTest { stateObserver, state ->
-            stateObserver.observeReads("scope1", { changes1++ }) {
-                stateObserver.observeReads("scope2", { changes2++ }) {
+            stateObserver.observeReads(ValueWrapper("scope1"), { changes1++ }) {
+                stateObserver.observeReads(ValueWrapper("scope2"), { changes2++ }) {
                     Snapshot.withoutReadObservation { state.value }
                 }
             }
@@ -361,8 +361,8 @@
         var changes2 = 0
 
         runSimpleTest { stateObserver, state ->
-            stateObserver.observeReads("scope1", { changes1++ }) {
-                stateObserver.observeReads("scope2", { changes2++ }) {
+            stateObserver.observeReads(ValueWrapper("scope1"), { changes1++ }) {
+                stateObserver.observeReads(ValueWrapper("scope2"), { changes2++ }) {
                     Snapshot.withoutReadObservation {
                         val newSnapshot = Snapshot.takeMutableSnapshot()
                         newSnapshot.enter { state.value }
@@ -382,8 +382,8 @@
         var changes2 = 0
 
         runSimpleTest { stateObserver, state ->
-            stateObserver.observeReads("scope1", { changes1++ }) {
-                stateObserver.observeReads("scope2", { changes2++ }) {
+            stateObserver.observeReads(ValueWrapper("scope1"), { changes1++ }) {
+                stateObserver.observeReads(ValueWrapper("scope2"), { changes2++ }) {
                     Snapshot.withoutReadObservation {
                         val newSnapshot = Snapshot.takeSnapshot()
                         newSnapshot.enter { state.value }
@@ -401,7 +401,7 @@
         var changes = 0
 
         runSimpleTest { stateObserver, state ->
-            stateObserver.observeReads("scope", { changes++ }) {
+            stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) {
                 val newSnapshot = Snapshot.takeSnapshot()
                 newSnapshot.enter { Snapshot.withoutReadObservation { state.value } }
                 newSnapshot.dispose()
@@ -417,7 +417,7 @@
         runSimpleTest { stateObserver, state ->
             val derivedState = derivedStateOf { state.value }
 
-            stateObserver.observeReads("scope", { changes++ }) {
+            stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) {
                 // read
                 derivedState.value
             }
@@ -433,7 +433,7 @@
             val state = mutableStateOf(mutableListOf(42), referentialEqualityPolicy())
             val derivedState = derivedStateOf { state.value }
 
-            stateObserver.observeReads("scope", { changes++ }) {
+            stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) {
                 // read
                 derivedState.value
             }
@@ -451,7 +451,7 @@
             val derivedState = derivedStateOf { state.value }
             val derivedState2 = derivedStateOf { derivedState.value }
 
-            stateObserver.observeReads("scope", { changes++ }) {
+            stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) {
                 // read
                 derivedState2.value
             }
@@ -467,7 +467,7 @@
             val state = mutableStateOf(mutableListOf(1), referentialEqualityPolicy())
             val derivedState = derivedStateOf(referentialEqualityPolicy()) { state.value }
 
-            stateObserver.observeReads("scope", { changes++ }) {
+            stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) {
                 // read
                 derivedState.value
             }
@@ -485,7 +485,7 @@
             val state = mutableStateOf(mutableListOf(1), referentialEqualityPolicy())
             val derivedState = derivedStateOf(structuralEqualityPolicy()) { state.value }
 
-            stateObserver.observeReads("scope", { changes++ }) {
+            stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) {
                 // read
                 derivedState.value
             }
@@ -502,7 +502,7 @@
         runSimpleTest { stateObserver, state ->
             val derivedState = derivedStateOf { state.value >= 0 }
 
-            stateObserver.observeReads("scope", { changes++ }) {
+            stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) {
                 // read derived state
                 derivedState.value
                 // read dependency
@@ -525,9 +525,10 @@
                     null
                 }
             }
-            val onChange: (String) -> Unit = { changes++ }
+            val onChange: (ValueWrapper) -> Unit = { changes++ }
 
-            stateObserver.observeReads("scope", onChange) {
+            val scope = ValueWrapper("scope")
+            stateObserver.observeReads(scope, onChange) {
                 // read derived state
                 derivedState.value
             }
@@ -537,7 +538,7 @@
             Snapshot.sendApplyNotifications()
             Snapshot.notifyObjectsInitialized()
 
-            stateObserver.observeReads("scope", onChange) {
+            stateObserver.observeReads(scope, onChange) {
                 // read derived state
                 derivedState.value
             }
@@ -552,20 +553,21 @@
         runSimpleTest { stateObserver, state ->
             val derivedState = derivedStateOf { state.value }
 
-            val onChange: (String) -> Unit = { changes++ }
-            stateObserver.observeReads("scope", onChange) {
+            val onChange: (ValueWrapper) -> Unit = { changes++ }
+            stateObserver.observeReads(ValueWrapper("scope"), onChange) {
                 // read derived state
                 derivedState.value
             }
 
+            val scope2 = ValueWrapper("other scope")
             // read the same state in other scope
-            stateObserver.observeReads("other scope", onChange) { derivedState.value }
+            stateObserver.observeReads(scope2, onChange) { derivedState.value }
 
             // advance snapshot to invalidate reads
             Snapshot.notifyObjectsInitialized()
 
             // stop observing state in other scope
-            stateObserver.observeReads("other scope", onChange) {
+            stateObserver.observeReads(scope2, onChange) {
                 /* no-op */
             }
         }
@@ -581,20 +583,20 @@
             stateObserver.start()
             Snapshot.notifyObjectsInitialized()
 
-            val onChange: (String) -> Unit = { scope ->
-                if (scope == "scope" && state1.value < 2) {
+            val onChange: (ValueWrapper) -> Unit = { scope ->
+                if (scope.s == "scope" && state1.value < 2) {
                     state1.value++
                     Snapshot.sendApplyNotifications()
                 }
             }
 
-            stateObserver.observeReads("scope", onChange) {
+            stateObserver.observeReads(ValueWrapper("scope"), onChange) {
                 state1.value
                 state2.value
             }
 
             repeat(10) {
-                stateObserver.observeReads("scope $it", onChange) {
+                stateObserver.observeReads(ValueWrapper("scope $it"), onChange) {
                     state1.value
                     state2.value
                 }
@@ -620,8 +622,8 @@
             stateObserver.start()
             Snapshot.notifyObjectsInitialized()
 
-            val onChange: (String) -> Unit = { scope ->
-                if (scope == "scope" && state1.value < 2) {
+            val onChange: (ValueWrapper) -> Unit = { scope ->
+                if (scope.s == "scope" && state1.value < 2) {
                     state1.value++
                     Snapshot.sendApplyNotifications()
                     state2.value++
@@ -633,7 +635,7 @@
                 }
             }
 
-            stateObserver.observeReads("scope", onChange) {
+            stateObserver.observeReads(ValueWrapper("scope"), onChange) {
                 state1.value
                 state2.value
                 state3.value
@@ -641,7 +643,7 @@
             }
 
             repeat(10) {
-                stateObserver.observeReads("scope $it", onChange) {
+                stateObserver.observeReads(ValueWrapper("scope $it"), onChange) {
                     state1.value
                     state2.value
                     state3.value
@@ -667,16 +669,17 @@
         runSimpleTest { stateObserver, state ->
             val changeBlock: (Any) -> Unit = { changes++ }
             // record observation
-            stateObserver.observeReads("scope", changeBlock) {
+            val s = ValueWrapper("scope")
+            stateObserver.observeReads(s, changeBlock) {
                 // read state
                 state.value
             }
 
             // clear scope
-            stateObserver.clear("scope")
+            stateObserver.clear(s)
 
             // record again
-            stateObserver.observeReads("scope", changeBlock) {
+            stateObserver.observeReads(s, changeBlock) {
                 // read state
                 state.value
             }
@@ -765,3 +768,6 @@
         }
     }
 }
+
+// In k/js string is a primitive type and it doesn't have identityHashCode
+private class ValueWrapper(val s: String)
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt
index 9614994..b412d06 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt
@@ -27,6 +27,7 @@
 import kotlin.test.AfterTest
 import kotlin.test.BeforeTest
 import kotlin.test.Test
+import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 import kotlinx.test.IgnoreJsTarget
@@ -318,7 +319,7 @@
                 } finally {
                     ms1.dispose()
                 }
-                assertEquals(
+                assertContentEquals(
                     listOf(
                         null to "Outer: creating, readonly = true",
                         null to "Inner: creating, readonly = true",
diff --git a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Atomic.nonJvm.kt b/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Atomic.nonJvm.kt
new file mode 100644
index 0000000..06591ec
--- /dev/null
+++ b/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Atomic.nonJvm.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.internal
+
+import kotlinx.atomicfu.atomic
+
+internal actual class AtomicReference actual constructor(value: V) {
+    private val delegate = atomic(value)
+
+    actual fun get() = delegate.value
+
+    actual fun set(value: V) {
+        delegate.value = value
+    }
+
+    actual fun getAndSet(value: V) = delegate.getAndSet(value)
+
+    actual fun compareAndSet(expect: V, newValue: V) = delegate.compareAndSet(expect, newValue)
+}
+
+internal actual class AtomicInt actual constructor(value: Int) {
+    private val delegate = atomic(value)
+
+    actual fun get(): Int = delegate.value
+
+    actual fun set(value: Int) {
+        delegate.value = value
+    }
+
+    actual fun add(amount: Int): Int = delegate.addAndGet(amount)
+
+    actual fun compareAndSet(expect: Int, newValue: Int) = delegate.compareAndSet(expect, newValue)
+}
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Utils.web.kt b/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Utils.nonJvm.kt
similarity index 100%
rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Utils.web.kt
rename to compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Utils.nonJvm.kt
diff --git a/compose/runtime/runtime/src/unixMain/kotlin/androidx/compose/runtime/platform/Synchronization.unix.kt b/compose/runtime/runtime/src/unixMain/kotlin/androidx/compose/runtime/platform/Synchronization.unix.kt
new file mode 100644
index 0000000..cdb5f14
--- /dev/null
+++ b/compose/runtime/runtime/src/unixMain/kotlin/androidx/compose/runtime/platform/Synchronization.unix.kt
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.platform
+
+import androidx.compose.runtime.internal.currentThreadId
+import kotlin.experimental.ExperimentalNativeApi
+import kotlin.native.ref.createCleaner
+import kotlinx.atomicfu.AtomicInt
+import kotlinx.atomicfu.AtomicLong
+import kotlinx.atomicfu.atomic
+import kotlinx.cinterop.Arena
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.ptr
+import platform.posix.pthread_cond_destroy
+import platform.posix.pthread_cond_init
+import platform.posix.pthread_cond_signal
+import platform.posix.pthread_cond_t
+import platform.posix.pthread_cond_wait
+import platform.posix.pthread_mutex_destroy
+import platform.posix.pthread_mutex_init
+import platform.posix.pthread_mutex_lock
+import platform.posix.pthread_mutex_t
+import platform.posix.pthread_mutex_unlock
+import platform.posix.pthread_mutexattr_destroy
+import platform.posix.pthread_mutexattr_init
+import platform.posix.pthread_mutexattr_settype
+import platform.posix.pthread_mutexattr_t
+
+/**
+ * Wrapper for `platform.posix.PTHREAD_MUTEX_ERRORCHECK` which is represented as `kotlin.Int` on
+ * darwin platforms and `kotlin.UInt` on linuxX64.
+ *
+ * See: [KT-41509](https://youtrack.jetbrains.com/issue/KT-41509)
+ */
+internal expect val PTHREAD_MUTEX_ERRORCHECK: Int
+
+/**
+ * A synchronized object that provides thread-safe locking and unlocking operations.
+ *
+ * `SynchronizedObject` from `kotlinx-atomicfu` library was used before. However, it is still
+ * [experimental](https://github.com/Kotlin/kotlinx-atomicfu?tab=readme-ov-file#locks) and has
+ * [a performance problem](https://github.com/Kotlin/kotlinx-atomicfu/issues/412) that seriously
+ * affects Compose.
+ *
+ * This implementation is optimized for a non-contention case (that is the case for the current
+ * state of Compose for iOS), so it does not create a posix mutex when there is no contention: using
+ * a posix mutex has its own performance overheads. On the other hand, it does not just spin lock in
+ * case of contention, protecting from an occasional battery drain.
+ */
+@PublishedApi
+internal actual class SynchronizedObject internal constructor() {
+
+    private companion object {
+        private const val NO_OWNER = -1L
+    }
+
+    private val owner: AtomicLong = atomic(NO_OWNER)
+    private var reEnterCount: Int = 0
+    private val waiters: AtomicInt = atomic(0)
+
+    private val monitorWrapper: MonitorWrapper by lazy { MonitorWrapper() }
+    private val monitor: NativeMonitor
+        get() = monitorWrapper.monitor
+
+    @PublishedApi
+    internal fun lock() {
+        if (owner.value == currentThreadId()) {
+            reEnterCount += 1
+        } else if (waiters.incrementAndGet() > 1) {
+            waitForUnlockAndLock()
+        } else {
+            if (!owner.compareAndSet(NO_OWNER, currentThreadId())) {
+                waitForUnlockAndLock()
+            }
+        }
+    }
+
+    private fun waitForUnlockAndLock() {
+        withMonitor(monitor) {
+            while (!owner.compareAndSet(NO_OWNER, currentThreadId())) {
+                wait()
+            }
+        }
+    }
+
+    @PublishedApi
+    internal fun unlock() {
+        require(owner.value == currentThreadId())
+        if (reEnterCount > 0) {
+            reEnterCount -= 1
+        } else {
+            owner.value = NO_OWNER
+            if (waiters.decrementAndGet() > 0) {
+                withMonitor(monitor) { notify() }
+            }
+        }
+    }
+
+    private inline fun withMonitor(monitor: NativeMonitor, block: NativeMonitor.() -> Unit) {
+        monitor.run {
+            enter()
+            return try {
+                block()
+            } finally {
+                exit()
+            }
+        }
+    }
+
+    private class MonitorWrapper {
+        val monitor: NativeMonitor = NativeMonitor()
+        @OptIn(ExperimentalNativeApi::class)
+        val cleaner = createCleaner(monitor, NativeMonitor::dispose)
+    }
+
+    @OptIn(ExperimentalForeignApi::class)
+    private class NativeMonitor {
+        private val arena: Arena = Arena()
+        private val cond: pthread_cond_t = arena.alloc()
+        private val mutex: pthread_mutex_t = arena.alloc()
+        private val attr: pthread_mutexattr_t = arena.alloc()
+
+        init {
+            require(pthread_cond_init(cond.ptr, null) == 0)
+            require(pthread_mutexattr_init(attr.ptr) == 0)
+            require(pthread_mutexattr_settype(attr.ptr, PTHREAD_MUTEX_ERRORCHECK) == 0)
+            require(pthread_mutex_init(mutex.ptr, attr.ptr) == 0)
+        }
+
+        fun enter() = require(pthread_mutex_lock(mutex.ptr) == 0)
+
+        fun exit() = require(pthread_mutex_unlock(mutex.ptr) == 0)
+
+        fun wait() = require(pthread_cond_wait(cond.ptr, mutex.ptr) == 0)
+
+        fun notify() = require(pthread_cond_signal(cond.ptr) == 0)
+
+        fun dispose() {
+            pthread_cond_destroy(cond.ptr)
+            pthread_mutex_destroy(mutex.ptr)
+            pthread_mutexattr_destroy(attr.ptr)
+            arena.clear()
+        }
+    }
+}
+
+@Suppress("NOTHING_TO_INLINE")
+internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject()
+
+@PublishedApi
+internal actual inline fun  synchronized(lock: SynchronizedObject, block: () -> R): R {
+    lock.run {
+        lock()
+        return try {
+            block()
+        } finally {
+            unlock()
+        }
+    }
+}
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasmJs.kt
similarity index 73%
rename from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
rename to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasmJs.kt
index 488cac6..46c4e2d 100644
--- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
+++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasmJs.kt
@@ -14,8 +14,9 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime
 
-annotation class NotIgnored
+@JsFun("(obj, index) => obj[index]")
+private external fun dynamicGetInt(obj: JsAny, index: String): Int?
 
-actual typealias IgnoreAndroidUnitTestTarget = NotIgnored
+@JsFun("(obj) => typeof obj") private external fun jsTypeOf(a: JsAny?): String
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.wasmJs.kt
similarity index 100%
copy from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt
copy to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.wasmJs.kt
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.wasmJs.kt
similarity index 63%
copy from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
copy to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.wasmJs.kt
index 488cac6..4eee10f 100644
--- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt
+++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.wasmJs.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright 2025 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,8 +14,12 @@
  * limitations under the License.
  */
 
-package kotlinx.test
+package androidx.compose.runtime
 
-annotation class NotIgnored
-
-actual typealias IgnoreAndroidUnitTestTarget = NotIgnored
+@InternalComposeApi
+@Deprecated(
+    level = DeprecationLevel.HIDDEN,
+    message = "Made internal. It wasn't supposed to be public",
+)
+fun identityHashCode(instance: Any?): Int =
+    androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.wasmJs.kt
similarity index 89%
copy from compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt
copy to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.wasmJs.kt
index a17fe80..d111419 100644
--- a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt
+++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.wasmJs.kt
@@ -14,12 +14,22 @@
  * limitations under the License.
  */
 
+@file:OptIn(InternalComposeApi::class)
+
 package androidx.compose.runtime.internal
 
 import androidx.compose.runtime.ComposeCompilerApi
 import androidx.compose.runtime.Composer
+import androidx.compose.runtime.InternalComposeApi
 import androidx.compose.runtime.Stable
 
+/**
+ * A Restart is created to hold composable lambdas to track when they are invoked allowing the
+ * invocations to be invalidated when a new composable lambda is created during composition.
+ *
+ * This allows much of the call-graph to be skipped when a composable function is passed through
+ * multiple levels of composable functions.
+ */
 @ComposeCompilerApi
 @Stable
 actual interface ComposableLambda :
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/Utils.wasmJs.kt
similarity index 95%
rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt
rename to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/Utils.wasmJs.kt
index e6e5839..be49779 100644
--- a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt
+++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/Utils.wasmJs.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2025 The Android Open Source Project
+ * Copyright 2024 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt
index 9e8bda2..c0aa19a 100644
--- a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt
+++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt
@@ -22,27 +22,31 @@
 
 actual typealias SnapshotId = Long
 
-actual const val SnapshotIdZero: SnapshotId = 0L
-actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE
-actual const val SnapshotIdSize: Int = Long.SIZE_BITS
-actual const val SnapshotIdInvalidValue: SnapshotId = -1
+internal actual const val SnapshotIdZero: SnapshotId = 0L
+internal actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE
+internal actual const val SnapshotIdSize: Int = Long.SIZE_BITS
+internal actual const val SnapshotIdInvalidValue: SnapshotId = -1
 
-actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = this.compareTo(other)
+internal actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int =
+    this.compareTo(other)
 
-actual inline operator fun SnapshotId.compareTo(other: Int): Int = this.compareTo(other.toLong())
+internal actual inline operator fun SnapshotId.compareTo(other: Int): Int =
+    this.compareTo(other.toLong())
 
-actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong()
+internal actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong()
 
-actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other
+internal actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other
 
-actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong()
+internal actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong()
 
-actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong()
+internal actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong()
 
-actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong()
+internal actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong()
 
 actual inline fun SnapshotId.toInt(): Int = this.toInt()
 
+actual inline fun SnapshotId.toLong(): Long = this
+
 actual typealias SnapshotIdArray = LongArray
 
 internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray =
@@ -140,4 +144,4 @@
 
 internal actual fun Int.toSnapshotId(): SnapshotId = toLong()
 
-actual fun SnapshotId.toLong(): Long = this
+internal actual fun Long.toSnapshotId(): SnapshotId = this
diff --git a/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/Actuals.wasmJs.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/Actuals.wasmJs.kt
new file mode 100644
index 0000000..4c0f417
--- /dev/null
+++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/Actuals.wasmJs.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.await
+import kotlinx.coroutines.promise
+import kotlinx.coroutines.test.TestResult
+
+internal actual suspend fun TestResult.awaitCompletion() {
+    this.await()
+}
+
+internal actual fun wrapTestWithCoroutine(block: suspend () -> Unit): TestResult {
+    return MainScope().promise { block() }
+}
diff --git a/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/TestUtils.wasm.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/TestUtils.wasm.kt
new file mode 100644
index 0000000..ee8d629
--- /dev/null
+++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/TestUtils.wasm.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import kotlinx.coroutines.*
+
+@OptIn(DelicateCoroutinesApi::class)
+// TODO: Make a proper implementation with timeoutMs
+//  https://youtrack.jetbrains.com/issue/CMP-662
+actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) {
+    GlobalScope.launch { block() }
+}
diff --git a/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/internal/IdentityHashCodeTest.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/internal/IdentityHashCodeTest.kt
new file mode 100644
index 0000000..7218bb9
--- /dev/null
+++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/internal/IdentityHashCodeTest.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.internal
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class IdentityHashCodeTest {
+
+    @Test
+    fun smokeTest() {
+        val a = DefaultImpl()
+        val b = DefaultImpl()
+
+        assertEquals(a, a)
+        assertNotEquals(a, b)
+        assertNotEquals(b, a)
+
+        val set = mutableSetOf()
+        set.add(a)
+        set.add(a)
+        set.add(b)
+
+        assertEquals(set.size, 2)
+    }
+}
+
+private class DefaultImpl {
+    override fun equals(other: Any?): Boolean {
+        return this === other
+    }
+
+    override fun hashCode(): Int {
+        return identityHashCode(this)
+    }
+}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/kotlinx/test/IngoreJsTarget.wasmJs.kt
similarity index 84%
copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
copy to compose/runtime/runtime/src/wasmJsTest/kotlin/kotlinx/test/IngoreJsTarget.wasmJs.kt
index 3bf4637..c1ff5e8 100644
--- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt
+++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/kotlinx/test/IngoreJsTarget.wasmJs.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2024 The Android Open Source Project
+ * Copyright 2025 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,4 +16,4 @@
 
 package kotlinx.test
 
-actual typealias IgnoreJsTarget = kotlin.test.Ignore
+actual typealias IgnoreWasmTarget = kotlin.test.Ignore
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Atomic.web.kt b/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Atomic.web.kt
deleted file mode 100644
index f3705bc..0000000
--- a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Atomic.web.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.compose.runtime.internal
-
-internal actual class AtomicReference actual constructor(value: V) {
-    private var value: V = value
-
-    actual fun get() = value
-
-    actual fun set(value: V) {
-        this.value = value
-    }
-
-    actual fun getAndSet(value: V): V {
-        val oldValue = this.value
-        this.value = value
-        return oldValue
-    }
-
-    actual fun compareAndSet(expect: V, newValue: V): Boolean {
-        return if (this.value == expect) {
-            this.value = newValue
-            true
-        } else {
-            false
-        }
-    }
-}
-
-internal actual class AtomicInt actual constructor(value: Int) {
-    private var value: Int = value
-
-    actual fun get(): Int = value
-
-    actual fun set(value: Int) {
-        this.value = value
-    }
-
-    actual fun add(amount: Int): Int {
-        value += amount
-        return value
-    }
-
-    actual fun compareAndSet(expect: Int, newValue: Int): Boolean {
-        return if (value == expect) {
-            value = newValue
-            true
-        } else {
-            false
-        }
-    }
-}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/androidx/compose/runtime/CompositionTests.web.kt b/compose/runtime/runtime/src/webTest/kotlin/androidx/compose/runtime/CompositionTests.web.kt
new file mode 100644
index 0000000..a4f8dc0
--- /dev/null
+++ b/compose/runtime/runtime/src/webTest/kotlin/androidx/compose/runtime/CompositionTests.web.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import androidx.compose.runtime.mock.compositionTest
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlinx.coroutines.test.TestResult
+
+// This test duplicates the common CompositionTest, but makes it run properly on web.
+// This test requires that compositionTest is called in the test body.
+// On Web, compositionTest calls returns a Promise, and it's ignored when not returned from the Test
+// method.
+// Here we make sure it runs (not ignored) by wrapping it in another Promise returned from the Test
+// method.
+class CompositionTestWeb {
+
+    @Test // https://youtrack.jetbrains.com/issue/CMP-7453
+    fun testRememberObserver_Abandon_Recompose() = wrapTestWithCoroutine {
+        val abandonedObjects = mutableListOf()
+        val observed =
+            object : RememberObserver {
+                override fun onAbandoned() {
+                    abandonedObjects.add(this)
+                }
+
+                override fun onForgotten() {
+                    error("Unexpected call to onForgotten")
+                }
+
+                override fun onRemembered() {
+                    error("Unexpected call to onRemembered")
+                }
+            }
+
+        var promiseStarted = false
+        var promiseCompleted = false
+
+        assertFailsWith(IllegalStateException::class, message = "Throw") {
+            promiseStarted = true
+            compositionTest {
+                    val rememberObject = mutableStateOf(false)
+
+                    compose {
+                        if (rememberObject.value) {
+                            @Suppress("UNUSED_EXPRESSION") remember { observed }
+                            error("Throw")
+                        }
+                    }
+
+                    assertTrue(abandonedObjects.isEmpty())
+
+                    rememberObject.value = true
+
+                    advance(ignorePendingWork = true)
+                }
+                .awaitCompletion()
+
+            promiseCompleted = true
+        }
+
+        assertTrue(promiseStarted)
+        assertFalse(promiseCompleted)
+        assertArrayEquals(listOf(observed), abandonedObjects)
+    }
+}
+
+internal expect suspend fun TestResult.awaitCompletion()
+
+internal expect fun wrapTestWithCoroutine(block: suspend () -> Unit): TestResult
+
+class TestWrapTest {
+
+    @Test
+    fun t() = wrapTestWithCoroutine {
+        var result = false
+
+        kotlinx.coroutines.test.runTest { result = true }.awaitCompletion()
+
+        assertTrue(result)
+        println("Completed\n")
+    }
+}
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index 5e63f7b..716b5dc 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -367,6 +367,7 @@
 \[log\].*
 # b/382336155 Capture more logging for Kotlin JS Tests
 .*STANDARD_OUT
+Completed
 # Warning emitted on empty Java files generated from AIDL:
 # https://github.com/kythe/kythe/issues/6145
 .*com.google\.devtools\.kythe\.extractors\.java\.JavaCompilationUnitExtractor.*
@@ -402,4 +403,4 @@
 # > Task :core:core:compileReleaseAndroidTestKotlin
 w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var statusBarColor: Int' is deprecated\. Deprecated in Java\.
 w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var navigationBarColor: Int' is deprecated\. Deprecated in Java\.
-w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var isStatusBarContrastEnforced: Boolean' is deprecated\. Deprecated in Java\.
+w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var isStatusBarContrastEnforced: Boolean' is deprecated\. Deprecated in Java\.
\ No newline at end of file
diff --git a/fragment/fragment-lint/build.gradle b/fragment/fragment-lint/build.gradle
index 3c4b545..bc34169 100644
--- a/fragment/fragment-lint/build.gradle
+++ b/fragment/fragment-lint/build.gradle
@@ -40,7 +40,6 @@
 
     testImplementation(libs.kotlinStdlib)
     testRuntimeOnly(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLintApi)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 715f5f4..daef513 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -223,7 +223,6 @@
 kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
 kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib" }
 kotlinStdlibCommon = { module = "org.jetbrains.kotlin:kotlin-stdlib-common" }
-kotlinStdlibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" }
 # The Kotlin/JS and Kotlin/Wasm standard library must match the compiler
 kotlinStdlibJs = { module = "org.jetbrains.kotlin:kotlin-stdlib-js", version.ref = "kotlin" }
 kotlinStdlibWasm = { module = "org.jetbrains.kotlin:kotlin-stdlib-wasm-js", version.ref = "kotlin" }
@@ -233,9 +232,11 @@
 kotlinTestCommon = { module = "org.jetbrains.kotlin:kotlin-test-common" }
 kotlinTestJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit" }
 kotlinTestJs = { module = "org.jetbrains.kotlin:kotlin-test-js" }
+kotlinTestWasm = { module = "org.jetbrains.kotlin:kotlin-test-wasm-js" }
 kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
 kotlinPoet = { module = "com.squareup:kotlinpoet", version = "2.1.0" }
 kotlinPoetJavaPoet = { module = "com.squareup:kotlinpoet-javapoet", version = "2.1.0" }
+kotlinXw3c = { module = "org.jetbrains.kotlinx:kotlinx-browser", version = "0.3" }
 ksp = { module = "com.google.devtools.ksp:symbol-processing", version.ref = "ksp" }
 kspApi = { module = "com.google.devtools.ksp:symbol-processing-api", version = "2.0.10-1.0.24" }
 kspCommon = { module = "com.google.devtools.ksp:symbol-processing-common-deps", version.ref = "ksp" }
diff --git a/lifecycle/lifecycle-runtime-compose-lint/build.gradle b/lifecycle/lifecycle-runtime-compose-lint/build.gradle
index 4c69a20..e507a4c 100644
--- a/lifecycle/lifecycle-runtime-compose-lint/build.gradle
+++ b/lifecycle/lifecycle-runtime-compose-lint/build.gradle
@@ -40,7 +40,6 @@
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLint)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/lifecycle/lifecycle-runtime-lint/build.gradle b/lifecycle/lifecycle-runtime-lint/build.gradle
index bc3c278..6739d10 100644
--- a/lifecycle/lifecycle-runtime-lint/build.gradle
+++ b/lifecycle/lifecycle-runtime-lint/build.gradle
@@ -34,7 +34,6 @@
 
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLint)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/lifecycle/lifecycle-runtime-testing-lint/build.gradle b/lifecycle/lifecycle-runtime-testing-lint/build.gradle
index 5483ba3..8d95b36 100644
--- a/lifecycle/lifecycle-runtime-testing-lint/build.gradle
+++ b/lifecycle/lifecycle-runtime-testing-lint/build.gradle
@@ -40,7 +40,6 @@
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLint)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle b/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle
index a116be3..785bd67 100644
--- a/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle
@@ -40,7 +40,6 @@
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLint)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/navigation/navigation-compose-lint/build.gradle b/navigation/navigation-compose-lint/build.gradle
index bed2ddf..4cfa553 100644
--- a/navigation/navigation-compose-lint/build.gradle
+++ b/navigation/navigation-compose-lint/build.gradle
@@ -44,7 +44,6 @@
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(project(":navigation:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLintApiStableAnalysis)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt b/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt
index 35f4579..30b5b3e 100644
--- a/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt
+++ b/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt
@@ -149,6 +149,65 @@
     }
 
     @Test
+    fun decoratorsOnPopImmutableBackStack() {
+        val poppedEntries = mutableListOf()
+        lateinit var backStackState: MutableState
+        lateinit var backStack: List
+
+        val decorator =
+            createTestNavEntryDecorator(onPop = { key -> poppedEntries.add(key as Int) }) {
+                entry ->
+                entry.Content()
+            }
+        composeTestRule.setContent {
+            backStackState = remember { mutableStateOf(2) }
+            backStack =
+                when (backStackState.value) {
+                    1 -> {
+                        listOf(1)
+                    }
+                    2 -> {
+                        listOf(1, 2)
+                    }
+                    else -> listOf(1, 3)
+                }
+            DecoratedNavEntryProvider(
+                backStack = backStack,
+                entryDecorators = listOf(decorator),
+                entryProvider = { key ->
+                    when (key) {
+                        1 -> NavEntry(1, 1) {}
+                        2 -> NavEntry(2, 2) {}
+                        3 -> NavEntry(3, 3) {}
+                        else -> error("Invalid Key")
+                    }
+                },
+            ) { entries ->
+                entries.lastOrNull()?.Content()
+            }
+        }
+        composeTestRule.waitForIdle()
+        assertThat(backStack).containsExactly(1, 2).inOrder()
+
+        backStackState.value = 1 // pop 2
+
+        composeTestRule.waitForIdle()
+        assertThat(backStack).containsExactly(1).inOrder()
+        assertThat(poppedEntries).containsExactly(2)
+
+        backStackState.value = 3 // add 3
+
+        composeTestRule.waitForIdle()
+        assertThat(backStack).containsExactly(1, 3).inOrder()
+
+        backStackState.value = 1 // pop 3
+
+        composeTestRule.waitForIdle()
+        assertThat(backStack).containsExactly(1).inOrder()
+        assertThat(poppedEntries).containsExactly(2, 3).inOrder()
+    }
+
+    @Test
     fun decoratorsOnPopOrder() {
         var count = -1
         var outerPop = -1
@@ -508,8 +567,55 @@
         lateinit var backStackState: MutableState
 
         composeTestRule.setContent {
+            val backStack1 = mutableStateListOf(key1)
+            val backStack2 = mutableStateListOf(key2)
+            backStackState = remember { mutableStateOf(1) }
+            val backStack =
+                when (backStackState.value) {
+                    1 -> backStack1
+                    else -> backStack2
+                }
+            DecoratedNavEntryProvider(
+                backStack = backStack,
+                entryDecorators = listOf(decorator),
+                entryProvider = entryProvider { entry({ it.arg }) {} },
+            ) { entries ->
+                entries.lastOrNull()?.Content()
+            }
+        }
+
+        composeTestRule.runOnIdle { assertThat(stateMap).containsExactly(1 to "state") }
+
+        backStackState.value = 2
+
+        composeTestRule.waitForIdle()
+        assertThat(stateMap).containsExactly(1 to "state", 2 to "state")
+
+        backStackState.value = 1
+
+        composeTestRule.waitForIdle()
+        assertThat(stateMap).containsExactly(1 to "state", 2 to "state")
+    }
+
+    @Test
+    fun popDuplicateWithImmutableBackStackPreservesState() {
+        val key1 = DataClass(1)
+        val key2 = DataClass(2)
+
+        val stateMap = mutableMapOf()
+        val decorator =
+            createTestNavEntryDecorator(
+                onPop = { contentKey -> stateMap.remove(contentKey) }
+            ) { entry ->
+                stateMap.put(entry.contentKey as Int, "state")
+                entry.Content()
+            }
+        lateinit var backStackState: MutableState
+
+        composeTestRule.setContent {
             val backStack1 = mutableStateListOf(key1, key2)
-            val backStack2 = mutableStateListOf(key1)
+            val backStack2 = mutableStateListOf(key1, key2, key2)
+
             backStackState = remember { mutableStateOf(1) }
             val backStack =
                 when (backStackState.value) {
@@ -526,16 +632,63 @@
         }
 
         composeTestRule.runOnIdle { assertThat(stateMap).containsExactly(2 to "state") }
-
-        backStackState.value = 2
+        backStackState.value = 2 // add 2 again
 
         composeTestRule.waitForIdle()
-        assertThat(stateMap).containsExactly(2 to "state", 1 to "state")
+        assertThat(stateMap).containsExactly(2 to "state")
 
-        backStackState.value = 1
+        backStackState.value = 1 // pop duplicate 2
 
         composeTestRule.waitForIdle()
-        assertThat(stateMap).containsExactly(2 to "state", 1 to "state")
+        assertThat(stateMap).containsExactly(2 to "state")
+    }
+
+    @Test
+    fun popAllDuplicatesWithImmutableBackStackClearsState() {
+        val key1 = DataClass(1)
+        val key2 = DataClass(2)
+
+        val stateMap = mutableMapOf()
+        val decorator =
+            createTestNavEntryDecorator(
+                onPop = { contentKey -> stateMap.remove(contentKey) }
+            ) { entry ->
+                stateMap.put(entry.contentKey as Int, "state")
+                entry.Content()
+            }
+        lateinit var backStackState: MutableState
+
+        composeTestRule.setContent {
+            val backStack1 = mutableStateListOf(key1, key2)
+            val backStack2 = mutableStateListOf(key1, key2, key2)
+            val backStack3 = mutableStateListOf(key1)
+
+            backStackState = remember { mutableStateOf(1) }
+            val backStack =
+                when (backStackState.value) {
+                    1 -> backStack1
+                    2 -> backStack2
+                    else -> backStack3
+                }
+            DecoratedNavEntryProvider(
+                backStack = backStack,
+                entryDecorators = listOf(decorator),
+                entryProvider = entryProvider { entry({ it.arg }) {} },
+            ) { entries ->
+                entries.lastOrNull()?.Content()
+            }
+        }
+
+        composeTestRule.runOnIdle { assertThat(stateMap).containsExactly(2 to "state") }
+        backStackState.value = 2 // add 2 again
+
+        composeTestRule.waitForIdle()
+        assertThat(stateMap).containsExactly(2 to "state")
+
+        backStackState.value = 3 // pop both 2's
+
+        composeTestRule.waitForIdle()
+        assertThat(stateMap).containsExactly(1 to "state")
     }
 
     private data class DataClass(val arg: Int)
diff --git a/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt b/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt
index bd30e87..8a2f4e9 100644
--- a/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt
+++ b/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt
@@ -22,7 +22,6 @@
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.staticCompositionLocalOf
@@ -52,27 +51,16 @@
     // Kotlin does not know these things are compatible so we need this explicit cast
     // to ensure our lambda below takes the correct type
     entryProvider as (T) -> NavEntry
+    val entries =
+        backStack.mapIndexed { index, key ->
+            val entry = entryProvider.invoke(key)
+            decorateEntry(entry, entryDecorators as List>)
+        }
 
-    /**
-     * We scope the block to a backStack to support multiple backStack / backStack swapping with
-     * hoisted states. The DisposableEffects/onDispose for a particular backStack will only trigger
-     * for operations on the same backStack i.e. navigate / pop. This is so that when we swap
-     * between two lists, i.e. from listOf[A, B] to listOf[C, D], A and B will not be considered as
-     * pop, meaning their state will be preserved and recovered when returning to the first list.
-     */
-    key(backStack) {
-        // Generates a list of entries that are wrapped with the given providers
-        val entries =
-            backStack.mapIndexed { index, key ->
-                val entry = entryProvider.invoke(key)
-                decorateEntry(entry, entryDecorators as List>)
-            }
+    // Provides the entire backstack to the previously wrapped entries
+    val initial: @Composable () -> Unit = remember(entries) { { content(entries) } }
 
-        // Provides the entire backstack to the previously wrapped entries
-        val initial: @Composable () -> Unit = remember(entries) { { content(entries) } }
-
-        PrepareBackStack(entries, entryDecorators, initial)
-    }
+    PrepareBackStack(entries, entryDecorators, initial)
 }
 
 /**
@@ -151,8 +139,10 @@
 
         DisposableEffect(contentKey) {
             onDispose {
+                val originalRoot = entries.first().contentKey
+                val sameBackStack = originalRoot == latestBackStack.first()
                 val popped =
-                    if (!latestBackStack.contains(contentKey)) {
+                    if (sameBackStack && !latestBackStack.contains(contentKey)) {
                         contentKeys.remove(contentKey)
                     } else false
                 // run onPop callback
diff --git a/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt b/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt
index 6a3fb6d..d5a5452 100644
--- a/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt
+++ b/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt
@@ -29,8 +29,10 @@
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.material3.Text
 import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.compositionLocalOf
 import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -60,7 +62,7 @@
     @get:Rule val composeTestRule = createComposeRule()
 
     @Test
-    fun testNavHostAnimations() {
+    fun testNavigateAnimations() {
         lateinit var backStack: MutableList
         lateinit var firstLifecycle: Lifecycle
         lateinit var secondLifecycle: Lifecycle
@@ -116,7 +118,73 @@
     }
 
     @Test
-    fun testNavHostAnimationsCustom() {
+    fun testNavigateAnimationsImmutableBackStack() {
+        lateinit var backStack: List
+        lateinit var backStackState: MutableState
+        lateinit var firstLifecycle: Lifecycle
+        lateinit var secondLifecycle: Lifecycle
+
+        composeTestRule.mainClock.autoAdvance = false
+
+        composeTestRule.setContent {
+            backStackState = remember { mutableStateOf(1) }
+            backStack =
+                when (backStackState.value) {
+                    1 -> {
+                        listOf(first)
+                    }
+                    else -> {
+                        listOf(first, second)
+                    }
+                }
+            NavDisplay(backStack) {
+                when (it) {
+                    first ->
+                        NavEntry(first) {
+                            firstLifecycle = LocalLifecycleOwner.current.lifecycle
+                            Text(first)
+                        }
+                    second ->
+                        NavEntry(second) {
+                            secondLifecycle = LocalLifecycleOwner.current.lifecycle
+                            Text(second)
+                        }
+                    else -> error("Invalid key passed")
+                }
+            }
+        }
+
+        composeTestRule.mainClock.autoAdvance = true
+
+        composeTestRule.waitForIdle()
+        assertThat(composeTestRule.onNodeWithText(first).isDisplayed()).isTrue()
+        assertThat(firstLifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)
+
+        composeTestRule.mainClock.autoAdvance = false
+
+        composeTestRule.runOnIdle { backStackState.value = 2 }
+
+        // advance half way between animations
+        composeTestRule.mainClock.advanceTimeBy(
+            DEFAULT_TRANSITION_DURATION_MILLISECOND.toLong() / 2
+        )
+
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(first).assertExists()
+        assertThat(firstLifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
+        composeTestRule.onNodeWithText(second).assertExists()
+        assertThat(secondLifecycle.currentState).isEqualTo(Lifecycle.State.STARTED)
+
+        composeTestRule.mainClock.autoAdvance = true
+
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(first).assertDoesNotExist()
+        composeTestRule.onNodeWithText(second).assertExists()
+        assertThat(secondLifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)
+    }
+
+    @Test
+    fun testNavigateAnimationsCustom() {
         lateinit var backStack: MutableList
 
         composeTestRule.mainClock.autoAdvance = false
@@ -212,6 +280,54 @@
     }
 
     @Test
+    fun testPopAnimationsImmutableBackStack() {
+        lateinit var backStack: List
+        lateinit var backStackState: MutableState
+        composeTestRule.setContent {
+            backStackState = remember { mutableStateOf(1) }
+            backStack =
+                when (backStackState.value) {
+                    1 -> {
+                        listOf(first, second)
+                    }
+                    else -> {
+                        listOf(first)
+                    }
+                }
+            NavDisplay(backStack) {
+                when (it) {
+                    first -> NavEntry(first) { Text(first) }
+                    second -> NavEntry(second) { Text(second) }
+                    else -> error("Invalid key passed")
+                }
+            }
+        }
+
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(second).assertIsDisplayed()
+        assertThat(backStack).containsExactly(first, second)
+
+        composeTestRule.mainClock.autoAdvance = false
+        composeTestRule.runOnIdle { backStackState.value = 2 }
+
+        // advance half way between animations
+        composeTestRule.mainClock.advanceTimeBy(
+            (DEFAULT_TRANSITION_DURATION_MILLISECOND / 2).toLong()
+        )
+
+        composeTestRule.waitForIdle()
+        // pop to first
+        assertThat(backStack).containsExactly(first)
+        composeTestRule.onNodeWithText(first).assertIsDisplayed()
+        composeTestRule.onNodeWithText(second).assertIsDisplayed()
+
+        composeTestRule.mainClock.autoAdvance = true
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(first).assertIsDisplayed()
+        composeTestRule.onNodeWithText(second).assertDoesNotExist()
+    }
+
+    @Test
     fun testPopMultiple() {
         lateinit var backStack: MutableList
         val testDuration = DEFAULT_TRANSITION_DURATION_MILLISECOND / 5
diff --git a/privacysandbox/tools/tools-apipackager/build.gradle b/privacysandbox/tools/tools-apipackager/build.gradle
index c55dcb6..66f007ca 100644
--- a/privacysandbox/tools/tools-apipackager/build.gradle
+++ b/privacysandbox/tools/tools-apipackager/build.gradle
@@ -32,7 +32,6 @@
     api(project(":privacysandbox:tools:tools"))
     api(project(":privacysandbox:tools:tools-core"))
     api(libs.kotlinStdlib)
-    implementation(libs.kotlinStdlibJdk8)
     implementation(libs.asm)
     implementation(libs.asmCommons)
     implementation(libs.kotlinMetadataJvm) {
diff --git a/room/room-compiler-processing-testing/build.gradle b/room/room-compiler-processing-testing/build.gradle
index f0cc0bb..20b0811 100644
--- a/room/room-compiler-processing-testing/build.gradle
+++ b/room/room-compiler-processing-testing/build.gradle
@@ -33,7 +33,6 @@
 
 dependencies {
     api(project(":room:room-compiler-processing"))
-    implementation(libs.kotlinStdlibJdk8)
     // For Java source compilation
     implementation(libs.googleCompileTesting)
     // For KSP processing
diff --git a/room/room-compiler-processing/build.gradle b/room/room-compiler-processing/build.gradle
index 01071b4..d2f4d60 100644
--- a/room/room-compiler-processing/build.gradle
+++ b/room/room-compiler-processing/build.gradle
@@ -34,7 +34,7 @@
 
 dependencies {
     api(libs.jspecify)
-    api(libs.kotlinStdlibJdk8)
+    api(libs.kotlinStdlib)
     api(libs.kspApi)
     api(libs.javapoet)
     api("com.squareup:kotlinpoet:2.0.0")
diff --git a/savedstate/savedstate/build.gradle b/savedstate/savedstate/build.gradle
index 1ed826b..b799311 100644
--- a/savedstate/savedstate/build.gradle
+++ b/savedstate/savedstate/build.gradle
@@ -37,7 +37,7 @@
             dependencies {
                 api(libs.kotlinStdlib)
                 api("androidx.annotation:annotation:1.9.1")
-                implementation(project(":lifecycle:lifecycle-common"))
+                implementation("androidx.lifecycle:lifecycle-common:2.9.2")
                 api(libs.kotlinCoroutinesCore)
                 api(libs.kotlinSerializationCore)
             }
@@ -45,7 +45,7 @@
 
         commonTest {
             dependencies {
-                implementation(project(":lifecycle:lifecycle-runtime"))
+                implementation("androidx.lifecycle:lifecycle-runtime:2.9.2")
                 implementation(project(":kruth:kruth"))
                 implementation(libs.kotlinTest)
                 implementation(libs.kotlinTestCommon)
@@ -81,7 +81,7 @@
         androidInstrumentedTest {
             dependsOn(jvmTest)
             dependencies {
-                implementation("androidx.lifecycle:lifecycle-runtime:2.9.1")
+                implementation("androidx.lifecycle:lifecycle-runtime:2.9.2")
                 implementation(libs.testExtJunit)
                 implementation(libs.testCore)
                 implementation(libs.testRunner)
diff --git a/slidingpanelayout/slidingpanelayout/api/current.txt b/slidingpanelayout/slidingpanelayout/api/current.txt
index 0308d92..1096f5a 100644
--- a/slidingpanelayout/slidingpanelayout/api/current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/current.txt
@@ -13,6 +13,8 @@
     method public boolean closePane();
     method public final boolean closePane(int duration, android.view.animation.Interpolator interpolator);
     method @Deprecated @ColorInt public int getCoveredFadeColor();
+    method @Px public final int getDividerVisualOffsetHorizontal();
+    method @Px public final int getDividerVisualOffsetVertical();
     method public final int getLockMode();
     method @Px public final int getPaneSpacing();
     method @Px public int getParallaxDistance();
@@ -33,6 +35,8 @@
     method public void removeSlideableStateListener(androidx.slidingpanelayout.widget.SlidingPaneLayout.SlideableStateListener listener);
     method public final void setChildClippingToResizeDividerEnabled(boolean);
     method @Deprecated public void setCoveredFadeColor(int);
+    method public final void setDividerVisualOffsetHorizontal(int);
+    method public final void setDividerVisualOffsetVertical(int);
     method public final void setLockMode(int);
     method public final void setOnUserResizingDividerClickListener(android.view.View.OnClickListener? listener);
     method public final void setOverlappingEnabled(boolean);
@@ -55,6 +59,8 @@
     method @Deprecated public void smoothSlideClosed();
     method @Deprecated public void smoothSlideOpen();
     property @Deprecated @ColorInt public int coveredFadeColor;
+    property @Px public final int dividerVisualOffsetHorizontal;
+    property @Px public final int dividerVisualOffsetVertical;
     property public final boolean isChildClippingToResizeDividerEnabled;
     property public final boolean isDividerDragging;
     property public final boolean isOverlappingEnabled;
diff --git a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
index 0308d92..1096f5a 100644
--- a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
@@ -13,6 +13,8 @@
     method public boolean closePane();
     method public final boolean closePane(int duration, android.view.animation.Interpolator interpolator);
     method @Deprecated @ColorInt public int getCoveredFadeColor();
+    method @Px public final int getDividerVisualOffsetHorizontal();
+    method @Px public final int getDividerVisualOffsetVertical();
     method public final int getLockMode();
     method @Px public final int getPaneSpacing();
     method @Px public int getParallaxDistance();
@@ -33,6 +35,8 @@
     method public void removeSlideableStateListener(androidx.slidingpanelayout.widget.SlidingPaneLayout.SlideableStateListener listener);
     method public final void setChildClippingToResizeDividerEnabled(boolean);
     method @Deprecated public void setCoveredFadeColor(int);
+    method public final void setDividerVisualOffsetHorizontal(int);
+    method public final void setDividerVisualOffsetVertical(int);
     method public final void setLockMode(int);
     method public final void setOnUserResizingDividerClickListener(android.view.View.OnClickListener? listener);
     method public final void setOverlappingEnabled(boolean);
@@ -55,6 +59,8 @@
     method @Deprecated public void smoothSlideClosed();
     method @Deprecated public void smoothSlideOpen();
     property @Deprecated @ColorInt public int coveredFadeColor;
+    property @Px public final int dividerVisualOffsetHorizontal;
+    property @Px public final int dividerVisualOffsetVertical;
     property public final boolean isChildClippingToResizeDividerEnabled;
     property public final boolean isDividerDragging;
     property public final boolean isOverlappingEnabled;
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt
index d489e1e..646157e 100644
--- a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt
@@ -42,6 +42,8 @@
 import androidx.test.platform.app.InstrumentationRegistry
 import com.google.common.truth.Truth.assertThat
 import com.google.common.truth.Truth.assertWithMessage
+import kotlin.math.max
+import kotlin.math.roundToInt
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -198,7 +200,7 @@
     }
 
     @Test
-    fun dragDividerUpdatesUserDivierDrawableState() {
+    fun dragDividerUpdatesUserDividerDrawableState() {
         val context = InstrumentationRegistry.getInstrumentation().context
         val spl = createTestSpl(context, childPanesAcceptTouchEvents = true)
 
@@ -677,6 +679,152 @@
         spl.dispatchGenericMotionEvent(mouseEvent(MotionEvent.ACTION_DOWN, x, y))
         assertThat(android.R.attr.state_hovered in testDrawable.drawableState).isFalse()
     }
+
+    @Test
+    fun dividerVisualOffsetHorizontal_updatesTouchBounds() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val touchTargetMinSize = (context.resources.displayMetrics.density * 48).roundToInt()
+
+        val dividerWidth = max(100, touchTargetMinSize)
+        val dividerHeight = max(200, touchTargetMinSize)
+
+        val spl =
+            createTestSpl(
+                context,
+                childPanesAcceptTouchEvents = true,
+                dividerWidth = dividerWidth,
+                dividerHeight = dividerHeight,
+            )
+
+        spl.measureAndLayoutForTest(width = 800, height = 600)
+
+        val left = 400 - dividerWidth / 2
+        val top = 300 - dividerHeight / 2
+
+        val initialTouchBounds = Rect(left, top, left + dividerWidth, top + dividerHeight)
+        // Gut check that the initial touch bounds is correct.
+        spl.assertDividerTouchBounds(initialTouchBounds)
+
+        spl.dividerVisualOffsetHorizontal = 10
+        spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(10, 0) })
+
+        spl.dividerVisualOffsetHorizontal = -50
+        spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(-50, 0) })
+    }
+
+    @Test
+    fun dividerVisualOffsetVertical_updatesTouchBounds() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val touchTargetMinSize = (context.resources.displayMetrics.density * 48).roundToInt()
+
+        val dividerWidth = max(100, touchTargetMinSize)
+        val dividerHeight = max(200, touchTargetMinSize)
+
+        val spl =
+            createTestSpl(
+                context,
+                childPanesAcceptTouchEvents = true,
+                dividerWidth = dividerWidth,
+                dividerHeight = dividerHeight,
+            )
+
+        spl.measureAndLayoutForTest(width = 800, height = 600)
+
+        val left = 400 - dividerWidth / 2
+        val top = 300 - dividerHeight / 2
+
+        val initialTouchBounds = Rect(left, top, left + dividerWidth, top + dividerHeight)
+        // Gut check that the initial touch bounds is correct.
+        spl.assertDividerTouchBounds(initialTouchBounds)
+
+        spl.dividerVisualOffsetVertical = 10
+        spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(0, 10) })
+
+        spl.dividerVisualOffsetVertical = -50
+        spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(0, -50) })
+    }
+
+    @Test
+    fun dividerVisualOffsetHorizontal_updatesDrawBounds() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+
+        val spl =
+            createTestSpl(
+                context,
+                childPanesAcceptTouchEvents = true,
+                dividerWidth = 10,
+                dividerHeight = 20,
+            )
+
+        var drawBounds: Rect? = null
+
+        spl.setUserResizingDividerDrawable(
+            TestDividerDrawable(10, 20) { left, top, right, bottom ->
+                drawBounds = Rect(left, top, right, bottom)
+            }
+        )
+        val canvas = Canvas()
+        spl.draw(canvas)
+
+        val initialDrawBounds = Rect(45, 40, 55, 60)
+
+        assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds")
+            .that(drawBounds)
+            .isEqualTo(initialDrawBounds)
+
+        spl.dividerVisualOffsetHorizontal = 10
+        spl.draw(canvas)
+        assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds")
+            .that(drawBounds)
+            .isEqualTo(Rect(initialDrawBounds).apply { offset(10, 0) })
+
+        spl.dividerVisualOffsetHorizontal = -20
+        spl.draw(canvas)
+        assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds")
+            .that(drawBounds)
+            .isEqualTo(Rect(initialDrawBounds).apply { offset(-20, 0) })
+    }
+
+    @Test
+    fun dividerVisualOffsetVertical_updatesDrawBounds() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+
+        val spl =
+            createTestSpl(
+                context,
+                childPanesAcceptTouchEvents = true,
+                dividerWidth = 10,
+                dividerHeight = 20,
+            )
+
+        var drawBounds: Rect? = null
+
+        spl.setUserResizingDividerDrawable(
+            TestDividerDrawable(10, 20) { left, top, right, bottom ->
+                drawBounds = Rect(left, top, right, bottom)
+            }
+        )
+        val canvas = Canvas()
+        spl.draw(canvas)
+
+        val initialDrawBounds = Rect(45, 40, 55, 60)
+
+        assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds")
+            .that(drawBounds)
+            .isEqualTo(initialDrawBounds)
+
+        spl.dividerVisualOffsetVertical = 10
+        spl.draw(canvas)
+        assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds")
+            .that(drawBounds)
+            .isEqualTo(Rect(initialDrawBounds).apply { offset(0, 10) })
+
+        spl.dividerVisualOffsetVertical = -5
+        spl.draw(canvas)
+        assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds")
+            .that(drawBounds)
+            .isEqualTo(Rect(initialDrawBounds).apply { offset(0, -5) })
+    }
 }
 
 private fun SlidingPaneLayout.leftAndRightViews(): Pair {
@@ -705,6 +853,8 @@
     setDividerDrawable: Boolean = true,
     childPanesAcceptTouchEvents: Boolean = false,
     collapsibleContentViews: Boolean = false,
+    dividerWidth: Int = 10,
+    dividerHeight: Int = 20,
 ): SlidingPaneLayout =
     SlidingPaneLayout(context).apply {
         addView(
@@ -742,7 +892,7 @@
         isUserResizingEnabled = true
         isOverlappingEnabled = false
         if (setDividerDrawable) {
-            setUserResizingDividerDrawable(TestDividerDrawable())
+            setUserResizingDividerDrawable(TestDividerDrawable(dividerWidth, dividerHeight))
         }
         measureAndLayoutForTest()
     }
@@ -758,6 +908,8 @@
 private class TestDividerDrawable(
     private val intrinsicWidth: Int = 10,
     private val intrinsicHeight: Int = 20,
+    private val setBounds: (left: Int, top: Int, right: Int, bottom: Int) -> Unit = { _, _, _, _ ->
+    },
 ) : Drawable() {
 
     override fun draw(canvas: Canvas) {}
@@ -771,6 +923,11 @@
     override fun getIntrinsicWidth(): Int = intrinsicWidth
 
     override fun getIntrinsicHeight(): Int = intrinsicHeight
+
+    override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
+        setBounds.invoke(left, top, right, bottom)
+        super.setBounds(left, top, right, bottom)
+    }
 }
 
 private class TestPaneView(context: Context) : View(context) {
@@ -853,3 +1010,36 @@
 
 private fun stylusEvent(action: Int, x: Int, y: Int) =
     motionEvent(action, x, y, InputDevice.SOURCE_STYLUS, MotionEvent.TOOL_TYPE_STYLUS)
+
+private fun SlidingPaneLayout.assertDividerTouchBounds(touchBounds: Rect) {
+    var wasClicked = false
+    setOnUserResizingDividerClickListener { wasClicked = true }
+
+    tap(touchBounds.left.toFloat(), touchBounds.top.toFloat())
+    assertWithMessage("Divider touchBounds doesn't contain the expected touchBounds $touchBounds")
+        .that(wasClicked)
+        .isTrue()
+
+    wasClicked = false
+    tap(touchBounds.left - 1f, touchBounds.top - 1f)
+    assertWithMessage("Divider touchBounds is larger than expected touchBounds $touchBounds")
+        .that(wasClicked)
+        .isFalse()
+
+    wasClicked = false
+    tap(touchBounds.right - 1f, touchBounds.bottom - 1f)
+    assertWithMessage("Divider touchBounds doesn't contain the expected touchBounds $touchBounds")
+        .that(wasClicked)
+        .isTrue()
+
+    wasClicked = false
+    tap(touchBounds.right.toFloat(), touchBounds.bottom.toFloat())
+    assertWithMessage("Divider touchBounds is larger than expected touchBounds $touchBounds")
+        .that(wasClicked)
+        .isFalse()
+}
+
+private fun SlidingPaneLayout.tap(x: Float, y: Float) {
+    onTouchEvent(downEvent(x, y))
+    onTouchEvent(upEvent(x, y))
+}
diff --git a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
index eea16d4..e71f19a 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
+++ b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
@@ -512,6 +512,21 @@
      */
     val visualDividerPosition: Int
         get() =
+            visualDividerPositionWithoutOffset.let {
+                if (it < 0) {
+                    it
+                } else {
+                    it + dividerVisualOffsetHorizontal
+                }
+            }
+
+    /**
+     * The visual divider position without the [dividerVisualOffsetHorizontal] applied. It's used
+     * for layout and draw the child panes. And the other one with visual is used for drawing the
+     * divider drawable, touch gestures, a11y touch bounds, etc.
+     */
+    private val visualDividerPositionWithoutOffset: Int
+        get() =
             when {
                 !isUserResizable -> -1
                 isDividerDragging -> draggableDividerHandler.dragPositionX
@@ -545,7 +560,7 @@
             // paddingLeft + paddingRight >= width.
             val paneSpacing =
                 paneSpacing.coerceAtMost(width - paddingLeft - paddingRight).coerceAtLeast(0)
-            return visualDividerPosition <= paddingLeft + paneSpacing / 2
+            return visualDividerPositionWithoutOffset <= paddingLeft + paneSpacing / 2
         }
 
     private val dividerAtRightEdge: Boolean
@@ -554,7 +569,8 @@
             // paddingLeft + paddingRight >= width.
             val paneSpacing =
                 paneSpacing.coerceAtMost(width - paddingLeft - paddingRight).coerceAtLeast(0)
-            return visualDividerPosition >= width - paddingRight - (paneSpacing + 1) / 2
+            return visualDividerPositionWithoutOffset >=
+                width - paddingRight - (paneSpacing + 1) / 2
         }
 
     private fun createUserResizingDividerDrawableState(viewState: IntArray): IntArray {
@@ -644,6 +660,38 @@
             }
         }
 
+    /**
+     * The amount of pixels that the divider will be visually offset from its original horizontal
+     * position. A positive value moves divider rightwards and a negative value moves divider
+     * leftwards. Changing this value does no impact on the layout of the panes. It only affects the
+     * drawing and touch position of the divider. This offset is also reflected on the return value
+     * of [visualDividerPosition].
+     */
+    @get:Px
+    var dividerVisualOffsetHorizontal: Int = 0
+        set(value) {
+            if (value != field) {
+                field = value
+                invalidate()
+            }
+        }
+
+    /**
+     * The amount of pixels that the divider will be visually offset from its original vertical
+     * position. A positive value moves divider downwards and a negative value moves divider
+     * upwards. Changing this value does no impact on the layout of the panes. It only affects the
+     * drawing and touch position of the divider. This offset is also reflected on the value of
+     * [visualDividerPosition].
+     */
+    @get:Px
+    var dividerVisualOffsetVertical: Int = 0
+        set(value) {
+            if (value != field) {
+                field = value
+                invalidate()
+            }
+        }
+
     private var onUserResizingDividerClickListener: OnClickListener? = null
 
     /**
@@ -732,7 +780,9 @@
         val height = max(dividerHeight, touchTargetMin)
         val left = dividerPositionX - width / 2
         val right = left + width
-        val top = (this.height - paddingTop - paddingBottom) / 2 + paddingTop - height / 2
+        val top =
+            (this.height - paddingTop - paddingBottom) / 2 + paddingTop - height / 2 +
+                dividerVisualOffsetVertical
         val bottom = top + height
         outRect.set(left, top, right, bottom)
         return outRect
@@ -860,7 +910,7 @@
             userResizingDividerDrawable?.apply {
                 val layoutCenterY = (height - paddingTop - paddingBottom) / 2 + paddingTop
                 val dividerLeft = dividerPositionX - intrinsicWidth / 2
-                val dividerTop = layoutCenterY - intrinsicHeight / 2
+                val dividerTop = layoutCenterY - intrinsicHeight / 2 + dividerVisualOffsetVertical
                 setBounds(
                     dividerLeft,
                     dividerTop,
@@ -1621,7 +1671,7 @@
             canvas.clipRect(tmpRect)
         }
         if (!isSlideable && isChildClippingToResizeDividerEnabled) {
-            val visualDividerPosition = visualDividerPosition
+            val visualDividerPosition = visualDividerPositionWithoutOffset
             val paneSpacing =
                 paneSpacing.coerceAtMost(width - paddingLeft - paddingRight).coerceAtLeast(0)
             if (visualDividerPosition >= 0) {
diff --git a/wear/protolayout/protolayout-lint/build.gradle b/wear/protolayout/protolayout-lint/build.gradle
index 7b7d4cf..9074899 100644
--- a/wear/protolayout/protolayout-lint/build.gradle
+++ b/wear/protolayout/protolayout-lint/build.gradle
@@ -35,7 +35,6 @@
 
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.kotlinReflect)
-    testImplementation(libs.kotlinStdlibJdk8)
     testImplementation(libs.androidLint)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
diff --git a/xr/compose/compose/api/current.txt b/xr/compose/compose/api/current.txt
index a33dbc2..cd77ede 100644
--- a/xr/compose/compose/api/current.txt
+++ b/xr/compose/compose/api/current.txt
@@ -220,6 +220,19 @@
   @SuppressCompatibility @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.WARNING, message="This is an experimental API. It may be changed or removed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalSubspaceVolumeApi {
   }
 
+  public final class SceneCoreEntityKt {
+    method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static  void SceneCoreEntity(kotlin.jvm.functions.Function0 factory, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional kotlin.jvm.functions.Function1 update, optional androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter? sizeAdapter, optional kotlin.jvm.functions.Function0 content);
+    method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static  void SceneCoreEntity(kotlin.jvm.functions.Function0, androidx.xr.compose.subspace.layout.SubspaceModifier?, kotlin.jvm.functions.Function1?, androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter?, kotlin.jvm.functions.Function2?, androidx.compose.runtime.Composer?, int, int);
+  }
+
+  public final class SceneCoreEntitySizeAdapter {
+    ctor public SceneCoreEntitySizeAdapter(kotlin.jvm.functions.Function2 onLayoutSizeChanged, optional kotlin.jvm.functions.Function1? intrinsicSize);
+    method public kotlin.jvm.functions.Function1? getIntrinsicSize();
+    method public kotlin.jvm.functions.Function2 getOnLayoutSizeChanged();
+    property public kotlin.jvm.functions.Function1? intrinsicSize;
+    property public kotlin.jvm.functions.Function2 onLayoutSizeChanged;
+  }
+
   public final class SpatialBoxKt {
     method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional boolean propagateMinConstraints, kotlin.jvm.functions.Function1 content);
     method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SpatialAlignment?, boolean, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer?, int, int);
diff --git a/xr/compose/compose/api/restricted_current.txt b/xr/compose/compose/api/restricted_current.txt
index 647f789..5aec1c2 100644
--- a/xr/compose/compose/api/restricted_current.txt
+++ b/xr/compose/compose/api/restricted_current.txt
@@ -263,6 +263,19 @@
     method @BytecodeOnly @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.CoreContentlessEntity rememberCoreContentlessEntity(kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer?, int);
   }
 
+  public final class SceneCoreEntityKt {
+    method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static  void SceneCoreEntity(kotlin.jvm.functions.Function0 factory, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional kotlin.jvm.functions.Function1 update, optional androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter? sizeAdapter, optional kotlin.jvm.functions.Function0 content);
+    method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static  void SceneCoreEntity(kotlin.jvm.functions.Function0, androidx.xr.compose.subspace.layout.SubspaceModifier?, kotlin.jvm.functions.Function1?, androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter?, kotlin.jvm.functions.Function2?, androidx.compose.runtime.Composer?, int, int);
+  }
+
+  public final class SceneCoreEntitySizeAdapter {
+    ctor public SceneCoreEntitySizeAdapter(kotlin.jvm.functions.Function2 onLayoutSizeChanged, optional kotlin.jvm.functions.Function1? intrinsicSize);
+    method public kotlin.jvm.functions.Function1? getIntrinsicSize();
+    method public kotlin.jvm.functions.Function2 getOnLayoutSizeChanged();
+    property public kotlin.jvm.functions.Function1? intrinsicSize;
+    property public kotlin.jvm.functions.Function2 onLayoutSizeChanged;
+  }
+
   public final class SpatialBoxKt {
     method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional boolean propagateMinConstraints, kotlin.jvm.functions.Function1 content);
     method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SpatialAlignment?, boolean, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer?, int, int);
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SceneCoreEntity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SceneCoreEntity.kt
new file mode 100644
index 0000000..7f47043
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SceneCoreEntity.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.xr.compose.subspace
+
+import androidx.compose.runtime.Applier
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ComposeNode
+import androidx.compose.runtime.currentComposer
+import androidx.compose.runtime.remember
+import androidx.compose.ui.util.fastForEachIndexed
+import androidx.xr.compose.subspace.layout.AdaptableCoreEntity
+import androidx.xr.compose.subspace.layout.SubspaceMeasurable
+import androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy
+import androidx.xr.compose.subspace.layout.SubspaceMeasureResult
+import androidx.xr.compose.subspace.layout.SubspaceMeasureScope
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.SubspacePlaceable
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetCompositionLocalMap
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetCoreEntity
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetMeasurePolicy
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetModifier
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.compose.unit.Meter
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+import androidx.xr.scenecore.Entity
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * A composable that attaches to a SceneCore entity and allow compose to size, position, reparent,
+ * add children, and apply modifiers to the entity.
+ *
+ * Usage of this API requires the SceneCore dependency to be added. See
+ * https://developer.android.com/jetpack/androidx/releases/xr-scenecore
+ *
+ * @param factory the factory method for creating the SceneCore [Entity].
+ * @param modifier the [SubspaceModifier] that will be applied to this node.
+ * @param update a callback to be invoked on recomposition to apply any state changes to the Entity.
+ *   This will track snapshot state reads and call [update] when they change.
+ * @param sizeAdapter an adapter that allows compose to integrate its layout size changes with the
+ *   rendered entity size. This adapter implementation will likely be different for every entity and
+ *   some SceneCore entities may not require sizing at all (this may be null).
+ * @param content the children of this [Entity].
+ * @see SceneCoreEntitySizeAdapter for more information on how compose sizes SceneCore entities.
+ */
+@Composable
+@SubspaceComposable
+public fun  SceneCoreEntity(
+    factory: () -> T,
+    modifier: SubspaceModifier = SubspaceModifier,
+    update: (T) -> Unit = {},
+    sizeAdapter: SceneCoreEntitySizeAdapter? = null,
+    content: @Composable @SubspaceComposable () -> Unit = {},
+) {
+    val compositionLocalMap = currentComposer.currentCompositionLocalMap
+    val entity = remember(factory)
+
+    ComposeNode>(
+        factory = {
+            ComposeSubspaceNode.Constructor().apply {
+                SetCoreEntity(AdaptableCoreEntity(entity, sizeAdapter))
+                SetMeasurePolicy(
+                    SceneCoreEntityMeasurePolicy(sizeAdapter?.intrinsicSize?.invoke(entity))
+                )
+            }
+        },
+        update = {
+            set(compositionLocalMap, SetCompositionLocalMap)
+            set(modifier, SetModifier)
+            update(sizeAdapter) {
+                getAdaptableCoreEntity()?.sceneCoreEntitySizeAdapter = sizeAdapter
+            }
+            update(entity)
+        },
+        content = content,
+    )
+}
+
+/**
+ * The sizing strategy used by [SceneCoreEntity] to control and read the size of an entity.
+ *
+ * The developer should use [onLayoutSizeChanged] to apply compose layout size changes to the
+ * entity. Compose will not inherently affect the size of the [Entity].
+ *
+ * If the developer uses [onLayoutSizeChanged] to change the size of the entity, but [intrinsicSize]
+ * is not provided, then the intrinsic size of the entity will be ignored and the layout size as
+ * determined solely by compose will be used to size the entity. If the [SceneCoreEntity] has no
+ * children or size modifiers then compose doesn't know how to size this node and it will be size 0,
+ * causing it not to render at all. In such a case, please do one of the following: (1) provide
+ * [intrinsicSize] so compose can infer the size from the entity, (2) add a sizing modifier to
+ * control the size of the entity, or (3) remove the adapter from the [SceneCoreEntity] as without
+ * an adapter compose will not try to control the size of this entity.
+ *
+ * Note that many SceneCore entities accept sizes in meter units instead of pixels. The [Meter] type
+ * may be used to convert from pixels to meters.
+ *
+ * ```kotlin
+ * Meter.fromPixel(px, density).toM()
+ * ```
+ *
+ * @param onLayoutSizeChanged a callback that is invoked with the final layout size of the
+ *   composable in pixels.
+ * @param intrinsicSize a getter method that returns the current [IntVolumeSize] in pixels of the
+ *   entity. This isn't as critical for compose as [onLayoutSizeChanged]; however, this can help to
+ *   inform compose of the intrinsic size of the entity.
+ */
+public class SceneCoreEntitySizeAdapter(
+    public val onLayoutSizeChanged: T.(IntVolumeSize) -> Unit,
+    public val intrinsicSize: (T.() -> IntVolumeSize)? = null,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as SceneCoreEntitySizeAdapter<*>
+
+        if (onLayoutSizeChanged !== other.onLayoutSizeChanged) return false
+        if (intrinsicSize !== other.intrinsicSize) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = onLayoutSizeChanged.hashCode()
+        result = 31 * result + (intrinsicSize?.hashCode() ?: 0)
+        return result
+    }
+}
+
+private class SceneCoreEntityMeasurePolicy(private val originalSize: IntVolumeSize?) :
+    SubspaceMeasurePolicy {
+    override fun SubspaceMeasureScope.measure(
+        measurables: List,
+        constraints: VolumeConstraints,
+    ): SubspaceMeasureResult {
+        if (measurables.isEmpty()) {
+            return if (originalSize == null) {
+                layout(constraints.minWidth, constraints.minHeight, constraints.minDepth) {}
+            } else {
+                layout(
+                    max(originalSize.width, constraints.minWidth),
+                    max(originalSize.height, constraints.minHeight),
+                    max(originalSize.depth, constraints.minDepth),
+                ) {}
+            }
+        }
+
+        val placeables = arrayOfNulls(measurables.size)
+        var width = max(constraints.minWidth, min(originalSize?.width ?: 0, constraints.maxWidth))
+        var height =
+            max(constraints.minHeight, min(originalSize?.height ?: 0, constraints.maxHeight))
+        var depth = max(constraints.minDepth, min(originalSize?.depth ?: 0, constraints.maxDepth))
+        measurables.fastForEachIndexed { index, measurable ->
+            val placeable = measurable.measure(constraints)
+            placeables[index] = placeable
+            width = max(width, placeable.measuredWidth)
+            height = max(height, placeable.measuredHeight)
+            depth = max(depth, placeable.measuredDepth)
+        }
+
+        return layout(width, height, depth) {
+            placeables.forEachIndexed { index, placeable ->
+                placeable as SubspacePlaceable
+                placeable.place(Pose.Identity)
+            }
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as SceneCoreEntityMeasurePolicy
+
+        return originalSize == other.originalSize
+    }
+
+    override fun hashCode(): Int {
+        return originalSize?.hashCode() ?: 0
+    }
+}
+
+private fun  ComposeSubspaceNode.getAdaptableCoreEntity(): AdaptableCoreEntity? =
+    coreEntity.castTo>()
+
+private inline fun  Any?.castTo() = this as? T
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt
index 6a57438..0219bcd 100644
--- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt
@@ -19,6 +19,7 @@
 import android.util.Log
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.ui.unit.Density
+import androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter
 import androidx.xr.compose.subspace.SpatialPanelDefaults
 import androidx.xr.compose.subspace.node.SubspaceLayoutNode
 import androidx.xr.compose.unit.IntVolumeSize
@@ -310,6 +311,23 @@
 }
 
 /**
+ * A [CoreEntity] used in a [androidx.xr.compose.subspace.SceneCoreEntity]. The exact semantics of
+ * this entity are unknown to compose; however, the developer may supply information that we may use
+ * to set and derive the size of the entity.
+ */
+internal class AdaptableCoreEntity(
+    val coreEntity: T,
+    var sceneCoreEntitySizeAdapter: SceneCoreEntitySizeAdapter? = null,
+) : CoreEntity(coreEntity) {
+    override var size: IntVolumeSize
+        get() = sceneCoreEntitySizeAdapter?.intrinsicSize?.invoke(coreEntity) ?: super.size
+        set(value) {
+            sceneCoreEntitySizeAdapter?.onLayoutSizeChanged?.let { coreEntity.it(value) }
+            super.size = value
+        }
+}
+
+/**
  * Wrapper class for sphere-based surface entities from SceneCore. Head pose is not a dynamic
  * property, and should just be calculated upon instantiation to avoid head locking the sphere.
  */
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt
index 76b0576..9bd2727 100644
--- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt
@@ -109,7 +109,7 @@
         val result = CoreEntityAccumulator()
         result.alpha = alpha * next.alpha
         result.scale = scale * next.scale
-        result.size = next.size
+        result.size = next.size ?: size
         return result
     }
 
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt
index 6265062..4c473b3 100644
--- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt
@@ -57,6 +57,11 @@
     modifier: SubspaceModifier = SubspaceModifier,
     measurePolicy: SubspaceMeasurePolicy,
 ) {
+    check(currentComposer.applier.current is ComposeSubspaceNode) {
+        "SubspaceComposable functions are expected to be used within the context of a " +
+            "Subspace composition. Please ensure that this component is in a Subspace or " +
+            " is a child of another SubspaceComposable."
+    }
     val compositionLocalMap = currentComposer.currentCompositionLocalMap
     ComposeNode>(
         factory = ComposeSubspaceNode.Constructor,
@@ -102,6 +107,11 @@
     modifier: SubspaceModifier = SubspaceModifier,
     measurePolicy: SubspaceMeasurePolicy,
 ) {
+    check(currentComposer.applier.current is ComposeSubspaceNode) {
+        "SubspaceComposable functions are expected to be used within the context of a " +
+            "Subspace composition. Please ensure that this component is in a Subspace or " +
+            " is a child of another SubspaceComposable."
+    }
     val coreEntity = rememberCoreContentlessEntity {
         ContentlessEntity.create(session = this, name = entityName("Entity"))
     }
@@ -142,6 +152,11 @@
     coreEntity: CoreEntity? = null,
     measurePolicy: SubspaceMeasurePolicy,
 ) {
+    check(currentComposer.applier.current is ComposeSubspaceNode) {
+        "SubspaceComposable functions are expected to be used within the context of a " +
+            "Subspace composition. Please ensure that this component is in a Subspace or " +
+            " is a child of another SubspaceComposable."
+    }
     val compositionLocalMap = currentComposer.currentCompositionLocalMap
     ComposeNode>(
         factory = ComposeSubspaceNode.Constructor,
@@ -182,6 +197,12 @@
     },
     measurePolicy: SubspaceMeasurePolicy,
 ) {
+    check(currentComposer.applier.current is ComposeSubspaceNode) {
+        "SubspaceComposable functions are expected to be used within the context of a " +
+            "Subspace composition. Please ensure that this component is in a Subspace or " +
+            " is a child of another SubspaceComposable."
+    }
+
     val compositionLocalMap = currentComposer.currentCompositionLocalMap
     ComposeNode>(
         factory = ComposeSubspaceNode.Constructor,
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SceneCoreEntityTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SceneCoreEntityTest.kt
new file mode 100644
index 0000000..c3ff2b7
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SceneCoreEntityTest.kt
@@ -0,0 +1,512 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.xr.compose.subspace
+
+import android.view.View
+import androidx.compose.material3.Text
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.spatial.Subspace
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.size
+import androidx.xr.compose.subspace.layout.testTag
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.TestSetup
+import androidx.xr.compose.testing.assertHeightIsEqualTo
+import androidx.xr.compose.testing.assertPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertWidthIsEqualTo
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.runtime.math.IntSize2d
+import androidx.xr.scenecore.ContentlessEntity
+import androidx.xr.scenecore.PanelEntity
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [SceneCoreEntity]. */
+@RunWith(AndroidJUnit4::class)
+class SceneCoreEntityTest {
+
+    @get:Rule val composeTestRule = createAndroidComposeRule()
+
+    @Test
+    fun sceneCoreEntity_childrenAreComposed() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    SceneCoreEntity(factory = { ContentlessEntity.create(session, "TestEntity") }) {
+                        SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) {
+                            Text(text = "Panel 1")
+                        }
+                        SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) {
+                            Text(text = "Panel 2")
+                        }
+                    }
+                }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("panel1").assertExists()
+        composeTestRule.onSubspaceNodeWithTag("panel2").assertExists()
+    }
+
+    @Test
+    fun sceneCoreEntity_childrenAreCentered() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    SceneCoreEntity(factory = { ContentlessEntity.create(session, "TestEntity") }) {
+                        SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) {
+                            Text(text = "Panel 1")
+                        }
+                        SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) {
+                            Text(text = "Panel 2")
+                        }
+                    }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel1")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel2")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+    }
+
+    @Test
+    fun sceneCoreEntity_size_modifierSizeIsAppliedToEntity() {
+        var testEntity by mutableStateOf(null)
+        var targetSize by mutableStateOf(500.dp)
+
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    testEntity = remember {
+                        PanelEntity.create(
+                            session,
+                            View(composeTestRule.activity),
+                            IntSize2d(100, 100),
+                            "TestPanel",
+                        )
+                    }
+                    SceneCoreEntity(
+                        factory = { testEntity!! },
+                        sizeAdapter =
+                            SceneCoreEntitySizeAdapter(
+                                onLayoutSizeChanged = {
+                                    sizeInPixels = IntSize2d(it.width, it.height)
+                                }
+                            ),
+                        modifier = SubspaceModifier.size(targetSize).testTag("mainPanel"),
+                    )
+                }
+            }
+        }
+
+        // Subsequent tests assume that density is 1.0
+        assertThat(composeTestRule.density.density).isEqualTo(1.0f)
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(500.dp)
+            .assertHeightIsEqualTo(500.dp)
+        assertThat(
+                (composeTestRule
+                        .onSubspaceNodeWithTag("mainPanel")
+                        .fetchSemanticsNode()
+                        .semanticsEntity as PanelEntity)
+                    .sizeInPixels
+            )
+            .isEqualTo(IntSize2d(500, 500))
+
+        targetSize = 1000.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(1000.dp)
+            .assertHeightIsEqualTo(1000.dp)
+        assertThat(
+                (composeTestRule
+                        .onSubspaceNodeWithTag("mainPanel")
+                        .fetchSemanticsNode()
+                        .semanticsEntity as PanelEntity)
+                    .sizeInPixels
+            )
+            .isEqualTo(IntSize2d(1000, 1000))
+    }
+
+    @Test
+    fun sceneCoreEntity_size_modifierSizeChangesWithDensity() {
+        var testEntity by mutableStateOf(null)
+        var targetSize by mutableStateOf(500.dp)
+
+        composeTestRule.setContent {
+            CompositionLocalProvider(LocalDensity provides Density(2.0f)) {
+                TestSetup {
+                    Subspace {
+                        val session = checkNotNull(LocalSession.current)
+                        testEntity = remember {
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(100, 100),
+                                "TestPanel",
+                            )
+                        }
+                        SceneCoreEntity(
+                            factory = { testEntity!! },
+                            sizeAdapter =
+                                SceneCoreEntitySizeAdapter({
+                                    sizeInPixels = IntSize2d(it.width, it.height)
+                                }),
+                            modifier = SubspaceModifier.size(targetSize).testTag("mainPanel"),
+                        )
+                    }
+                }
+            }
+        }
+
+        // Subsequent tests assume that density is 2.0
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(1000.dp)
+            .assertHeightIsEqualTo(1000.dp)
+        assertThat(
+                (composeTestRule
+                        .onSubspaceNodeWithTag("mainPanel")
+                        .fetchSemanticsNode()
+                        .semanticsEntity as PanelEntity)
+                    .sizeInPixels
+            )
+            .isEqualTo(IntSize2d(1000, 1000))
+
+        targetSize = 1000.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(2000.dp)
+            .assertHeightIsEqualTo(2000.dp)
+        assertThat(
+                (composeTestRule
+                        .onSubspaceNodeWithTag("mainPanel")
+                        .fetchSemanticsNode()
+                        .semanticsEntity as PanelEntity)
+                    .sizeInPixels
+            )
+            .isEqualTo(IntSize2d(2000, 2000))
+    }
+
+    @Test
+    fun sceneCoreEntity_size_usesInitialSizeIfNoModifierOrChildren() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    SceneCoreEntity(
+                        factory = {
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(100, 100),
+                                "TestPanel",
+                            )
+                        },
+                        sizeAdapter =
+                            SceneCoreEntitySizeAdapter(
+                                onLayoutSizeChanged = {
+                                    sizeInPixels = IntSize2d(it.width, it.height)
+                                },
+                                intrinsicSize = {
+                                    IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0)
+                                },
+                            ),
+                        modifier = SubspaceModifier.testTag("mainPanel"),
+                    )
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(100.dp)
+            .assertHeightIsEqualTo(100.dp)
+    }
+
+    @Test
+    fun sceneCoreEntity_size_isZeroIfNoModifierOrChildrenOrIntrinsicSize() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    SceneCoreEntity(
+                        factory = {
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(100, 100),
+                                "TestPanel",
+                            )
+                        },
+                        sizeAdapter =
+                            SceneCoreEntitySizeAdapter(
+                                onLayoutSizeChanged = {
+                                    sizeInPixels = IntSize2d(it.width, it.height)
+                                }
+                            ),
+                        modifier = SubspaceModifier.testTag("mainPanel"),
+                    )
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(0.dp)
+            .assertHeightIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun sceneCoreEntity_size_usesInitialSizeIfChildrenAreSmaller() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    SceneCoreEntity(
+                        factory = {
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(100, 100),
+                                "TestPanel",
+                            )
+                        },
+                        sizeAdapter =
+                            SceneCoreEntitySizeAdapter(
+                                onLayoutSizeChanged = {
+                                    sizeInPixels = IntSize2d(it.width, it.height)
+                                },
+                                intrinsicSize = {
+                                    IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0)
+                                },
+                            ),
+                        modifier = SubspaceModifier.testTag("mainPanel"),
+                    ) {
+                        SpatialPanel(SubspaceModifier.size(50.dp)) {}
+                    }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(100.dp)
+            .assertHeightIsEqualTo(100.dp)
+    }
+
+    @Test
+    fun sceneCoreEntity_size_doesNotThrowExceptionIfSetterAndGetter() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    SceneCoreEntity(
+                        factory = {
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(0, 0),
+                                "TestPanel",
+                            )
+                        },
+                        sizeAdapter =
+                            SceneCoreEntitySizeAdapter(
+                                onLayoutSizeChanged = {
+                                    sizeInPixels = IntSize2d(it.width, it.height)
+                                },
+                                intrinsicSize = {
+                                    IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0)
+                                },
+                            ),
+                        modifier = SubspaceModifier.testTag("mainPanel"),
+                    )
+                }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("mainPanel").assertExists()
+    }
+
+    @Test
+    fun sceneCoreEntity_size_matchesSizeOfChildrenIfLarger() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    val session = checkNotNull(LocalSession.current)
+                    SceneCoreEntity(
+                        factory = {
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(100, 100),
+                                "TestPanel",
+                            )
+                        },
+                        sizeAdapter =
+                            SceneCoreEntitySizeAdapter(
+                                onLayoutSizeChanged = {
+                                    sizeInPixels = IntSize2d(it.width, it.height)
+                                },
+                                intrinsicSize = {
+                                    IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0)
+                                },
+                            ),
+                        modifier = SubspaceModifier.testTag("mainPanel"),
+                    ) {
+                        SpatialPanel(SubspaceModifier.size(200.dp)) {}
+                    }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("mainPanel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(200.dp)
+            .assertHeightIsEqualTo(200.dp)
+    }
+
+    @Test
+    fun sceneCoreEntity_factoryAndUpdate_areCalledTheAppropriateNumberOfTimes() {
+        var factoryCalled = 0
+        var updateCalled = 0
+        val cornerRadius = mutableStateOf(0.5f)
+
+        composeTestRule.setContent {
+            TestSetup {
+                val session = LocalSession.current ?: error("No session")
+                Subspace {
+                    SceneCoreEntity(
+                        factory = {
+                            factoryCalled += 1
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(100, 100),
+                                "TestPanel",
+                            )
+                        },
+                        update = {
+                            updateCalled += 1
+                            it.cornerRadius = cornerRadius.value
+                        },
+                    ) {
+                        SpatialPanel(SubspaceModifier.testTag("TestPanel")) {}
+                    }
+                }
+            }
+        }
+
+        composeTestRule.waitForIdle()
+        assertThat(factoryCalled).isEqualTo(1)
+        assertThat(updateCalled).isEqualTo(1)
+        cornerRadius.value = 0.3f
+        composeTestRule.waitForIdle()
+        assertThat(factoryCalled).isEqualTo(1)
+        assertThat(updateCalled).isEqualTo(2)
+        cornerRadius.value = 0.2f
+        composeTestRule.waitForIdle()
+        assertThat(factoryCalled).isEqualTo(1)
+        assertThat(updateCalled).isEqualTo(3)
+        cornerRadius.value = 0.1f
+        composeTestRule.waitForIdle()
+        assertThat(factoryCalled).isEqualTo(1)
+        assertThat(updateCalled).isEqualTo(4)
+    }
+
+    @Test
+    fun sceneCoreEntity_update_getsMutableStateChanges() {
+        val cornerRadius = mutableStateOf(0.5f)
+
+        composeTestRule.setContent {
+            TestSetup {
+                val session = LocalSession.current ?: error("No session")
+                Subspace {
+                    SceneCoreEntity(
+                        factory = {
+                            PanelEntity.create(
+                                session,
+                                View(composeTestRule.activity),
+                                IntSize2d(100, 100),
+                                "TestPanel",
+                            )
+                        },
+                        update = { it.cornerRadius = cornerRadius.value },
+                        modifier = SubspaceModifier.testTag("TestPanel"),
+                    )
+                }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("TestPanel").assertExists()
+        assertThat(
+                (composeTestRule
+                        .onSubspaceNodeWithTag("TestPanel")
+                        .fetchSemanticsNode()
+                        .semanticsEntity as PanelEntity)
+                    .cornerRadius
+            )
+            .isEqualTo(0.5f)
+
+        cornerRadius.value = 0.4f
+
+        assertThat(
+                (composeTestRule
+                        .onSubspaceNodeWithTag("TestPanel")
+                        .fetchSemanticsNode()
+                        .semanticsEntity as PanelEntity)
+                    .cornerRadius
+            )
+            .isEqualTo(0.4f)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNodeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNodeTest.kt
new file mode 100644
index 0000000..80f20b6
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNodeTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.xr.compose.subspace.layout
+
+import android.view.View
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.spatial.ApplicationSubspace
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.subspace.node.SubspaceModifierNodeElement
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.TestSetup
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.scenecore.PanelEntity
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertNotNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CoreEntityNodeTest {
+
+    /**
+     * Applies a modification to the [CoreEntity] associated with this subspace layout.
+     *
+     * This provides a generic way to apply changes to the underlying entity for testing purposes.
+     *
+     * @param modify The function literal to execute on the [CoreEntityScope].
+     */
+    private fun SubspaceModifier.modifyCoreEntity(
+        modify: CoreEntityScope.() -> Unit
+    ): SubspaceModifier = this.then(ModifyCoreEntityElement(modify))
+
+    private class ModifyCoreEntityElement(private var modify: CoreEntityScope.() -> Unit) :
+        SubspaceModifierNodeElement() {
+        override fun create(): ModifyCoreEntityNode = ModifyCoreEntityNode(modify)
+
+        override fun update(node: ModifyCoreEntityNode) {
+            node.modify = modify
+        }
+
+        override fun hashCode(): Int {
+            return modify.hashCode()
+        }
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is ModifyCoreEntityElement) return false
+
+            return modify === other.modify
+        }
+    }
+
+    private class ModifyCoreEntityNode(var modify: CoreEntityScope.() -> Unit) :
+        SubspaceModifier.Node(), CoreEntityNode {
+        override fun CoreEntityScope.modifyCoreEntity() {
+            modify()
+        }
+    }
+
+    @get:Rule val composeTestRule = createAndroidComposeRule()
+
+    @Test
+    fun testRenderedSize_shouldBeApplied() {
+        composeTestRule.setContent {
+            TestSetup {
+                ApplicationSubspace {
+                    SpatialPanel(
+                        factory = { View(it) },
+                        SubspaceModifier.modifyCoreEntity {
+                                setRenderedSize(IntVolumeSize(100, 100, 0))
+                            }
+                            .modifyCoreEntity { setOrAppendScale(4f) }
+                            .testTag("panel"),
+                    )
+                }
+            }
+        }
+
+        val panelNode = composeTestRule.onSubspaceNodeWithTag("panel").fetchSemanticsNode()
+        val panelSceneCoreEntity = panelNode.semanticsEntity as PanelEntity?
+        assertNotNull(panelSceneCoreEntity)
+        assertThat(panelSceneCoreEntity.sizeInPixels.width).isEqualTo(100)
+        assertThat(panelSceneCoreEntity.sizeInPixels.height).isEqualTo(100)
+    }
+}
diff --git a/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt b/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt
index 666f174..c53186c 100644
--- a/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt
+++ b/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt
@@ -58,7 +58,6 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
 import androidx.compose.ui.unit.dp
-import androidx.lifecycle.lifecycleScope
 import androidx.xr.compose.integration.common.AnotherActivity
 import androidx.xr.compose.integration.layout.spatialcomposeapp.components.TestDialog
 import androidx.xr.compose.platform.LocalSession
@@ -69,14 +68,13 @@
 import androidx.xr.compose.spatial.OrbiterOffsetType
 import androidx.xr.compose.spatial.SpatialElevationLevel
 import androidx.xr.compose.spatial.Subspace
-import androidx.xr.compose.subspace.ExperimentalSubspaceVolumeApi
 import androidx.xr.compose.subspace.MainPanel
+import androidx.xr.compose.subspace.SceneCoreEntity
 import androidx.xr.compose.subspace.SpatialColumn
 import androidx.xr.compose.subspace.SpatialCurvedRow
 import androidx.xr.compose.subspace.SpatialLayoutSpacer
 import androidx.xr.compose.subspace.SpatialPanel
 import androidx.xr.compose.subspace.SubspaceComposable
-import androidx.xr.compose.subspace.Volume
 import androidx.xr.compose.subspace.layout.SpatialAlignment
 import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape
 import androidx.xr.compose.subspace.layout.SubspaceModifier
@@ -88,10 +86,10 @@
 import androidx.xr.compose.subspace.layout.offset
 import androidx.xr.compose.subspace.layout.padding
 import androidx.xr.compose.subspace.layout.resizable
+import androidx.xr.compose.subspace.layout.rotate
 import androidx.xr.compose.subspace.layout.size
 import androidx.xr.compose.subspace.layout.width
 import androidx.xr.compose.unit.Meter.Companion.meters
-import androidx.xr.runtime.math.Pose
 import androidx.xr.runtime.math.Quaternion
 import androidx.xr.runtime.math.Vector3
 import androidx.xr.scenecore.GltfModel
@@ -102,7 +100,6 @@
 import kotlin.math.sin
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.guava.await
-import kotlinx.coroutines.launch
 
 /**
  * Main activity for the Spatial Compose App.
@@ -330,49 +327,42 @@
         )
     }
 
-    @OptIn(ExperimentalSubspaceVolumeApi::class)
     @SubspaceComposable
     @Composable
     fun XyzArrows(modifier: SubspaceModifier = SubspaceModifier) {
-        val session =
-            checkNotNull(LocalSession.current) {
-                "LocalSession.current was null. Session must be available."
-            }
-        var arrows by remember { mutableStateOf(null) }
-        val gltfEntity = arrows?.let { remember { GltfModelEntity.create(session, it) } }
+        val session = LocalSession.current ?: return
+        var rotation by remember { mutableStateOf(Quaternion.Identity) }
+        var gltfModel by remember { mutableStateOf(null) }
 
         LaunchedEffect(Unit) {
-            arrows = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await()
+            gltfModel = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await()
+
+            val pi = 3.14159F
+            val timeSource = Clock.systemUTC()
+            val startTime = timeSource.millis()
+            val rotateTimeMs = 10000F
+
+            while (true) {
+                delay(16L)
+                val elapsedMs = timeSource.millis() - startTime
+                val angle = (2 * pi) * (elapsedMs / rotateTimeMs)
+
+                val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized()
+
+                val qX = normalized.x * sin(angle / 2)
+                val qY = normalized.y * sin(angle / 2)
+                val qZ = normalized.z * sin(angle / 2)
+                val qW = cos(angle / 2)
+
+                rotation = Quaternion(qX, qY, qZ, qW)
+            }
         }
 
-        if (gltfEntity != null) {
-            Volume(modifier) {
-                gltfEntity.parent = it
-
-                lifecycleScope.launch {
-                    val pi = 3.14159F
-                    val timeSource = Clock.systemUTC()
-                    val startTime = timeSource.millis()
-                    val rotateTimeMs = 10000F
-
-                    while (true) {
-                        delay(16L)
-                        val elapsedMs = timeSource.millis() - startTime
-                        val angle = (2 * pi) * (elapsedMs / rotateTimeMs)
-
-                        val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized()
-
-                        val qX = normalized.x * sin(angle / 2)
-                        val qY = normalized.y * sin(angle / 2)
-                        val qZ = normalized.z * sin(angle / 2)
-                        val qW = cos(angle / 2)
-
-                        val q = Quaternion(qX, qY, qZ, qW)
-
-                        gltfEntity.setPose(Pose(rotation = q))
-                    }
-                }
-            }
+        if (gltfModel != null) {
+            SceneCoreEntity(
+                factory = { GltfModelEntity.create(session, gltfModel!!) },
+                modifier = modifier.rotate(rotation),
+            )
         }
     }
 }
diff --git a/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt b/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt
index e4f4bb8..277015d 100644
--- a/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt
+++ b/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt
@@ -59,7 +59,6 @@
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
 import androidx.compose.ui.unit.dp
-import androidx.lifecycle.lifecycleScope
 import androidx.xr.compose.platform.LocalSession
 import androidx.xr.compose.platform.LocalSpatialCapabilities
 import androidx.xr.compose.platform.LocalSpatialConfiguration
@@ -69,12 +68,12 @@
 import androidx.xr.compose.spatial.Subspace
 import androidx.xr.compose.subspace.ExperimentalSubspaceVolumeApi
 import androidx.xr.compose.subspace.MainPanel
+import androidx.xr.compose.subspace.SceneCoreEntity
 import androidx.xr.compose.subspace.SpatialColumn
 import androidx.xr.compose.subspace.SpatialCurvedRow
 import androidx.xr.compose.subspace.SpatialLayoutSpacer
 import androidx.xr.compose.subspace.SpatialPanel
 import androidx.xr.compose.subspace.SubspaceComposable
-import androidx.xr.compose.subspace.Volume
 import androidx.xr.compose.subspace.layout.SpatialAlignment
 import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape
 import androidx.xr.compose.subspace.layout.SubspaceModifier
@@ -86,13 +85,13 @@
 import androidx.xr.compose.subspace.layout.offset
 import androidx.xr.compose.subspace.layout.padding
 import androidx.xr.compose.subspace.layout.resizable
+import androidx.xr.compose.subspace.layout.rotate
 import androidx.xr.compose.subspace.layout.testTag
 import androidx.xr.compose.subspace.layout.width
 import androidx.xr.compose.testapp.common.AnotherActivity
 import androidx.xr.compose.testapp.ui.components.CommonTestScaffold
 import androidx.xr.compose.testapp.ui.components.TestDialog
 import androidx.xr.compose.unit.Meter.Companion.meters
-import androidx.xr.runtime.math.Pose
 import androidx.xr.runtime.math.Quaternion
 import androidx.xr.runtime.math.Vector3
 import androidx.xr.scenecore.GltfModel
@@ -103,7 +102,6 @@
 import kotlin.math.sin
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.guava.await
-import kotlinx.coroutines.launch
 
 class SpatialCompose : ComponentActivity() {
 
@@ -361,45 +359,38 @@
     @OptIn(ExperimentalSubspaceVolumeApi::class)
     @Composable
     fun XyzArrows(modifier: SubspaceModifier = SubspaceModifier) {
-        val session =
-            checkNotNull(LocalSession.current) {
-                "LocalSession.current was null. Session must be available."
-            }
-        var arrows by remember { mutableStateOf(null) }
-        val gltfEntity = arrows?.let { remember { GltfModelEntity.create(session, it) } }
+        val session = LocalSession.current ?: return
+        var rotation by remember { mutableStateOf(Quaternion.Identity) }
+        var gltfModel by remember { mutableStateOf(null) }
 
         LaunchedEffect(Unit) {
-            arrows = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await()
+            gltfModel = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await()
+            val pi = 3.14159F
+            val timeSource = Clock.systemUTC()
+            val startTime = timeSource.millis()
+            val rotateTimeMs = 10000F
+
+            while (true) {
+                delay(16L)
+                val elapsedMs = timeSource.millis() - startTime
+                val angle = (2 * pi) * (elapsedMs / rotateTimeMs)
+
+                val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized()
+
+                val qX = normalized.x * sin(angle / 2)
+                val qY = normalized.y * sin(angle / 2)
+                val qZ = normalized.z * sin(angle / 2)
+                val qW = cos(angle / 2)
+
+                rotation = Quaternion(qX, qY, qZ, qW)
+            }
         }
 
-        if (gltfEntity != null) {
-            Volume(modifier) {
-                gltfEntity.parent = it
-
-                lifecycleScope.launch {
-                    val pi = 3.14159F
-                    val timeSource = Clock.systemUTC()
-                    val startTime = timeSource.millis()
-                    val rotateTimeMs = 10000F
-
-                    while (true) {
-                        delay(16L)
-                        val elapsedMs = timeSource.millis() - startTime
-                        val angle = (2 * pi) * (elapsedMs / rotateTimeMs)
-
-                        val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized()
-
-                        val qX = normalized.x * sin(angle / 2)
-                        val qY = normalized.y * sin(angle / 2)
-                        val qZ = normalized.z * sin(angle / 2)
-                        val qW = cos(angle / 2)
-
-                        val q = Quaternion(qX, qY, qZ, qW)
-
-                        gltfEntity.setPose(Pose(rotation = q))
-                    }
-                }
-            }
+        if (gltfModel != null) {
+            SceneCoreEntity(
+                factory = { GltfModelEntity.create(session, gltfModel!!) },
+                modifier = modifier.rotate(rotation),
+            )
         }
     }
 }
diff --git a/xr/projected/projected/api/current.txt b/xr/projected/projected/api/current.txt
index e6f50d0..cc00cc6 100644
--- a/xr/projected/projected/api/current.txt
+++ b/xr/projected/projected/api/current.txt
@@ -1 +1,15 @@
 // Signature format: 4.0
+package androidx.xr.projected {
+
+  public final class ProjectedContext {
+    method public static android.content.Intent addProjectedFlags(android.content.Intent intent);
+    method public static android.content.Context createHostDeviceContext(android.content.Context context);
+    method public static android.app.ActivityOptions createProjectedActivityOptions(android.content.Context context);
+    method public static android.content.Context createProjectedDeviceContext(android.content.Context context);
+    method public static String? getProjectedDeviceName(android.content.Context context);
+    method public static boolean isProjectedDeviceContext(android.content.Context context);
+    field public static final androidx.xr.projected.ProjectedContext INSTANCE;
+  }
+
+}
+
diff --git a/xr/projected/projected/api/restricted_current.txt b/xr/projected/projected/api/restricted_current.txt
index e6f50d0..cc00cc6 100644
--- a/xr/projected/projected/api/restricted_current.txt
+++ b/xr/projected/projected/api/restricted_current.txt
@@ -1 +1,15 @@
 // Signature format: 4.0
+package androidx.xr.projected {
+
+  public final class ProjectedContext {
+    method public static android.content.Intent addProjectedFlags(android.content.Intent intent);
+    method public static android.content.Context createHostDeviceContext(android.content.Context context);
+    method public static android.app.ActivityOptions createProjectedActivityOptions(android.content.Context context);
+    method public static android.content.Context createProjectedDeviceContext(android.content.Context context);
+    method public static String? getProjectedDeviceName(android.content.Context context);
+    method public static boolean isProjectedDeviceContext(android.content.Context context);
+    field public static final androidx.xr.projected.ProjectedContext INSTANCE;
+  }
+
+}
+
diff --git a/xr/projected/projected/build.gradle b/xr/projected/projected/build.gradle
index d7c2aab..7d99562 100644
--- a/xr/projected/projected/build.gradle
+++ b/xr/projected/projected/build.gradle
@@ -31,9 +31,17 @@
 
 dependencies {
     api(libs.kotlinStdlib)
+
+    implementation("androidx.annotation:annotation:1.8.1")
+
+    testImplementation(libs.junit)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
 }
 
 android {
+    compileSdk = 35
     namespace = "androidx.xr.projected"
 
     defaultConfig {
@@ -46,4 +54,5 @@
     type = SoftwareType.PUBLISHED_LIBRARY
     inceptionYear = "2025"
     description = "Provides components for using backwards-compatible features on Projected XR devices."
+    enableRobolectric()
 }
diff --git a/xr/projected/projected/src/main/kotlin/androidx/xr/projected/ProjectedContext.kt b/xr/projected/projected/src/main/kotlin/androidx/xr/projected/ProjectedContext.kt
new file mode 100644
index 0000000..2bf6c4a
--- /dev/null
+++ b/xr/projected/projected/src/main/kotlin/androidx/xr/projected/ProjectedContext.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.xr.projected
+
+import android.app.ActivityOptions
+import android.companion.virtual.VirtualDeviceManager
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.annotation.VisibleForTesting
+
+/**
+ * Helper for accessing Projected device [Context] and its features.
+ *
+ * Projected device is an XR device connected to an Android device (host). Host can project the
+ * application content to the Projected device and let users interact with it.
+ *
+ * The Projected device context will ensure Projected device system services are returned, when
+ * queried for system services from this object.
+ *
+ * Note: The application context's deviceId can switch between the Projected and host deviceId
+ * depending on which activity was most recently in the foreground. Prefer using the Activity
+ * context to minimize the risk of running into this problem.
+ */
+public object ProjectedContext {
+
+    private const val TAG = "ProjectedContext"
+
+    @VisibleForTesting internal const val PROJECTED_DEVICE_NAME = "ProjectionDevice"
+
+    @VisibleForTesting
+    internal const val REQUIRED_LAUNCH_FLAGS =
+        (Intent.FLAG_ACTIVITY_NEW_TASK or
+            Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
+            Intent.FLAG_ACTIVITY_SINGLE_TOP)
+
+    /**
+     * Explicitly create the Projected device context from any context object. It returns null if
+     * the projected device was not found.
+     */
+    @JvmStatic
+    public fun createProjectedDeviceContext(context: Context): Context {
+        val deviceId =
+            getProjectedDeviceId(context)
+                ?: throw IllegalStateException("Projected device not found.")
+        return context.createDeviceContext(deviceId)
+    }
+
+    /**
+     * Explicitly create the host device context from any context object. The host is the device
+     * that connects to a Projected device.
+     *
+     * If an application is using a Projected device context and it wants to use system services
+     * from the host (e.g. phone), it needs to use the host device context.
+     */
+    @JvmStatic
+    public fun createHostDeviceContext(context: Context): Context =
+        context.createDeviceContext(Context.DEVICE_ID_DEFAULT)
+
+    /**
+     * Returns the name of the Projected device or null if either virtual device wasn't found or the
+     * name of the virtual device wasn't set.
+     *
+     * @throws IllegalArgumentException If another context is used (e.g. the host context).
+     */
+    @JvmStatic
+    public fun getProjectedDeviceName(context: Context): String? =
+        // TODO: b/424812882 - Turn this into a lint check with an annotation.
+        if (isProjectedDeviceContext(context)) {
+            getVirtualDevice(context)?.name
+        } else {
+            throw IllegalArgumentException(
+                "Provided context is not the Projected device context. Can't get the device name."
+            )
+        }
+
+    /** Returns whether the provided context is the Projected device context. */
+    @JvmStatic
+    public fun isProjectedDeviceContext(context: Context): Boolean =
+        getVirtualDevice(context)?.name?.startsWith(PROJECTED_DEVICE_NAME) == true
+
+    /**
+     * Takes an [Intent] with the description of the activity to start and returns it with added
+     * flags to start the activity on the Projected device.
+     *
+     * @param intent The description of the activity to start.
+     */
+    @JvmStatic
+    public fun addProjectedFlags(intent: Intent): Intent = intent.addFlags(REQUIRED_LAUNCH_FLAGS)
+
+    /**
+     * Creates [ActivityOptions] that should be used to start an activity on the Projected device.
+     *
+     * If the Projected device have more than one associated display, the activity will be started
+     * on the first one.
+     *
+     * @param context The Projected device context.
+     */
+    @JvmStatic
+    public fun createProjectedActivityOptions(context: Context): ActivityOptions {
+        // TODO: b/424812882 - Turn this into a lint check with an annotation.
+        if (!isProjectedDeviceContext(context)) {
+            throw IllegalArgumentException("Provided context is not the Projected device context.")
+        }
+
+        val displayIds = getProjectedDisplayIds(context)
+
+        if (displayIds.isEmpty()) {
+            throw IllegalStateException("No projected display found.")
+        }
+
+        if (displayIds.size > 1) {
+            // TODO: b/424812731 - Add support for multiple display IDs.
+            Log.w(TAG, "More than one projected display found. Selecting the first one.")
+        }
+
+        return ActivityOptions.makeBasic().setLaunchDisplayId(displayIds.first())
+    }
+
+    private fun getProjectedDeviceId(context: Context) =
+        context
+            .getSystemService(VirtualDeviceManager::class.java)
+            .virtualDevices
+            // TODO: b/424824481 - Replace the name matching with a better method.
+            .find { it.name?.startsWith(PROJECTED_DEVICE_NAME) ?: false }
+            ?.deviceId
+
+    private fun getVirtualDevice(context: Context) =
+        context.getSystemService(VirtualDeviceManager::class.java).virtualDevices.find {
+            it.deviceId == context.deviceId
+        }
+
+    private fun getProjectedDisplayIds(context: Context) =
+        getVirtualDevice(context)?.displayIds ?: IntArray(size = 0)
+}
diff --git a/xr/projected/projected/src/test/kotlin/androidx/xr/projected/ProjectedContextTest.kt b/xr/projected/projected/src/test/kotlin/androidx/xr/projected/ProjectedContextTest.kt
new file mode 100644
index 0000000..fafe4c4
--- /dev/null
+++ b/xr/projected/projected/src/test/kotlin/androidx/xr/projected/ProjectedContextTest.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.xr.projected
+
+import android.companion.virtual.VirtualDeviceManager
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.projected.ProjectedContext.PROJECTED_DEVICE_NAME
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.util.ReflectionHelpers
+import org.robolectric.util.ReflectionHelpers.ClassParameter
+
+@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM])
+@RunWith(AndroidJUnit4::class)
+class ProjectedContextTest {
+
+    val context: ContextWrapper = ApplicationProvider.getApplicationContext()
+    val virtualDeviceManager =
+        context.getSystemService(Context.VIRTUAL_DEVICE_SERVICE) as VirtualDeviceManager
+
+    val projectedDeviceContext: Context
+        get() = LocalContextWrapper(context, virtualDeviceManager.virtualDevices.first().deviceId)
+
+    @Test
+    fun createProjectedDeviceContext_hasVirtualDevice_returnsContext() {
+        createVirtualDevice()
+
+        assertThat(ProjectedContext.createProjectedDeviceContext(context)).isNotNull()
+    }
+
+    @Test
+    fun createProjectedDeviceContext_noVirtualDevice_throwsIllegalStateException() {
+        assertThrows(IllegalStateException::class.java) {
+            assertThat(ProjectedContext.createProjectedDeviceContext(context)).isNull()
+        }
+    }
+
+    @Test
+    fun createHostDeviceContext_returnsContext() {
+        assertThat(ProjectedContext.createHostDeviceContext(context).deviceId)
+            .isEqualTo(Context.DEVICE_ID_DEFAULT)
+    }
+
+    @Test
+    fun getProjectedDeviceName_projectedDeviceContext_returnsName() {
+        createVirtualDevice()
+
+        assertThat(ProjectedContext.getProjectedDeviceName(projectedDeviceContext))
+            .isEqualTo(PROJECTED_DEVICE_NAME)
+    }
+
+    @Test
+    fun getProjectedDeviceName_anotherContext_throwsIllegalArgumentException() {
+        createVirtualDevice()
+
+        assertThrows(IllegalArgumentException::class.java) {
+            ProjectedContext.getProjectedDeviceName(context)
+        }
+    }
+
+    @Test
+    fun isProjectedDeviceContext_returnsTrue() {
+        createVirtualDevice()
+
+        assertThat(ProjectedContext.isProjectedDeviceContext(projectedDeviceContext)).isTrue()
+    }
+
+    @Test
+    fun isProjectedDeviceContext_returnsFalse() {
+        createVirtualDevice()
+
+        assertThat(ProjectedContext.isProjectedDeviceContext(context)).isFalse()
+    }
+
+    @Test
+    fun addProjectedFlags_returnsIntentWithAddedFlags() {
+        val intent = Intent().setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
+        val expectedFlags = intent.flags or ProjectedContext.REQUIRED_LAUNCH_FLAGS
+
+        ProjectedContext.addProjectedFlags(intent)
+
+        assertThat(intent.flags).isEqualTo(expectedFlags)
+    }
+
+    @Test
+    @Ignore // Bring back this test once a new Robolectric version is available
+    fun createProjectedActivityOptions_projectedDeviceContext_returnsActivityOptionsWithLaunchDisplayId() {
+        createVirtualDevice()
+
+        val activityOptions = ProjectedContext.createProjectedActivityOptions(context)
+
+        assertThat(activityOptions.launchDisplayId).isEqualTo(DISPLAY_ID)
+    }
+
+    @Test
+    fun createProjectedActivityOptions_anotherContext_throwsIllegalArgumentException() {
+        assertThrows(IllegalArgumentException::class.java) {
+            ProjectedContext.createProjectedActivityOptions(context)
+        }
+    }
+
+    private fun createVirtualDevice() {
+        val virtualDeviceParamsBuilderClass =
+            Class.forName("android.companion.virtual.VirtualDeviceParams\$Builder")
+        val virtualDeviceParamsClass =
+            Class.forName("android.companion.virtual.VirtualDeviceParams")
+        var virtualDeviceParamsBuilder =
+            ReflectionHelpers.callConstructor(virtualDeviceParamsBuilderClass)
+        virtualDeviceParamsBuilder =
+            ReflectionHelpers.callInstanceMethod(
+                virtualDeviceParamsBuilder,
+                "setName",
+                ClassParameter(String::class.java, PROJECTED_DEVICE_NAME),
+            )
+        virtualDeviceParamsBuilder =
+            ReflectionHelpers.callInstanceMethod(virtualDeviceParamsBuilder, "build")
+        ReflectionHelpers.callInstanceMethod(
+            virtualDeviceManager,
+            "createVirtualDevice",
+            ClassParameter(Int::class.javaPrimitiveType, 1),
+            ClassParameter(virtualDeviceParamsClass, virtualDeviceParamsBuilder),
+        )
+    }
+
+    companion object {
+        private const val DISPLAY_ID = 5
+    }
+
+    class LocalContextWrapper(context: Context, private val deviceId: Int) :
+        ContextWrapper(context) {
+        override fun getDeviceId() = deviceId
+    }
+}