[web] fix SkFinalizationRegistry for dart2js (attempt 4) (#40938)

(this is attempt 4; details below)

Remove obsolete object caches and introduce a simpler way to manage
native objects:

* Remove the unused `SynchronousSkiaObjectCache`.
* Introduce new library `native_memory.dart` that's smaller and simpler
than `skia_object_cache.dart`.
* Introduce two types of native object references:
  * `UniqueRef` a reference with a unique Dart object owner.
* `CountedRef` a ref-counted reference with multiple Dart object owners.
* All native references use GC (via `FinalizationRegistry`) as a
back-up.
* The new library removes everything related to object resurrection that
was needed only in browsers that didn't support `FinalizationRegistry`.
All browsers support it now.
* Remove the ad hoc `SkParagraph` cache that predates the introduction
of `Paragraph.dispose`.
* Rewrite `CkParagraph` in terms of `UniqueRef`.
* Rewrite `CkImage` in terms of `CountedRef`; delete `SkiaObjectBox`.

This PR does not migrate all objects from the old
`skia_object_cache.dart` to `native_memory.dart`. That would be too big
of a change. The migration can be done in multiple smaller PRs.

This also removes a few unnecessary relayouts observed in
https://github.com/flutter/flutter/issues/120921, but not all of them
(more details in
https://github.com/flutter/flutter/issues/120921#issuecomment-1481958762)

## About attempt 4

More info about the revert of attempt 3 in
https://github.com/flutter/engine/pull/40937.

In this attempt I check that the browser supports `FinalizationRegistry`
before registering the object. This will allow the code to run in older
browsers, but there will be no protection from memory leaks when the app
fails to dispose of the respective objects.

## Benchmarks

Now that this landed in flutter/flutter I have some benchmark numbers
from the devicelab. The `text_out_of_picture_bounds` benchmark dropped
by 3-4x (lower is better):

<img width="358" alt="Screenshot 2023-04-04 at 6 13 06 PM"
src="https://user-images.githubusercontent.com/211513/229956170-a5399ed3-c779-4af0-babb-ea40440f96ff.png">

The repro provided in https://github.com/flutter/flutter/issues/123204
dropped from 110ms/frame to 10ms/frame.
diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter
index e501125..bfa34e3 100644
--- a/ci/licenses_golden/licenses_flutter
+++ b/ci/licenses_golden/licenses_flutter
@@ -1876,6 +1876,7 @@
 ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/layer_tree.dart + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/mask_filter.dart + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/n_way_canvas.dart + ../../../flutter/LICENSE
+ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/native_memory.dart + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/noto_font.dart + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/painting.dart + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/path.dart + ../../../flutter/LICENSE
@@ -4457,6 +4458,7 @@
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/layer_tree.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/mask_filter.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/n_way_canvas.dart
+FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/native_memory.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/noto_font.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/painting.dart
 FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/path.dart
diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart
index a82b9d9..07e54ca 100644
--- a/lib/web_ui/lib/src/engine.dart
+++ b/lib/web_ui/lib/src/engine.dart
@@ -39,6 +39,7 @@
 export 'engine/canvaskit/layer_tree.dart';
 export 'engine/canvaskit/mask_filter.dart';
 export 'engine/canvaskit/n_way_canvas.dart';
+export 'engine/canvaskit/native_memory.dart';
 export 'engine/canvaskit/noto_font.dart';
 export 'engine/canvaskit/painting.dart';
 export 'engine/canvaskit/path.dart';
diff --git a/lib/web_ui/lib/src/engine/canvaskit/canvas.dart b/lib/web_ui/lib/src/engine/canvaskit/canvas.dart
index 3d35df9..9a5dd90 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/canvas.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/canvas.dart
@@ -215,7 +215,6 @@
       offset.dx,
       offset.dy,
     );
-    paragraph.markUsed();
   }
 
   void drawPath(CkPath path, CkPaint paint) {
@@ -1112,7 +1111,6 @@
       offset.dx,
       offset.dy,
     );
-    paragraph.markUsed();
   }
 }
 
diff --git a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
index 884f38f..faacd1d 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
@@ -3392,7 +3392,7 @@
 class ProductionCollector implements Collector {
   ProductionCollector() {
     _skObjectFinalizationRegistry =
-        SkObjectFinalizationRegistry((SkDeletable deletable) {
+        createSkObjectFinalizationRegistry((SkDeletable deletable) {
       // This is called when GC decides to collect the wrapper object and
       // notify us, which may happen after the object is already deleted
       // explicitly, e.g. when its ref count drops to zero. When that happens
@@ -3568,10 +3568,13 @@
 /// 6. We call `delete` on SkPaint.
 @JS('window.FinalizationRegistry')
 @staticInterop
-class SkObjectFinalizationRegistry {
-  // TODO(hterkelsen): Add a type for the `cleanup` function when
-  // native constructors support type parameters.
-  external factory SkObjectFinalizationRegistry(JSFunction cleanup);
+class SkObjectFinalizationRegistry {}
+
+SkObjectFinalizationRegistry createSkObjectFinalizationRegistry(JSFunction cleanup) {
+  return js_util.callConstructor(
+    _finalizationRegistryConstructor!.toObjectShallow,
+    <Object>[cleanup],
+  );
 }
 
 extension SkObjectFinalizationRegistryExtension on SkObjectFinalizationRegistry {
diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart
index e43157d..3e16742 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/image.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart
@@ -15,10 +15,10 @@
 import 'canvaskit_api.dart';
 import 'image_wasm_codecs.dart';
 import 'image_web_codecs.dart';
+import 'native_memory.dart';
 import 'painting.dart';
 import 'picture.dart';
 import 'picture_recorder.dart';
-import 'skia_object_cache.dart';
 
 /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia.
 FutureOr<ui.Codec> skiaInstantiateImageCodec(Uint8List list,
@@ -227,52 +227,8 @@
 /// A [ui.Image] backed by an `SkImage` from Skia.
 class CkImage implements ui.Image, StackTraceDebugger {
   CkImage(SkImage skImage, { this.videoFrame }) {
+    box = CountedRef<CkImage, SkImage>(skImage, this, 'SkImage');
     _init();
-    if (browserSupportsFinalizationRegistry) {
-      box = SkiaObjectBox<CkImage, SkImage>(this, skImage);
-    } else {
-      // If finalizers are not supported we need to be able to resurrect the
-      // image if it was temporarily deleted. To do that, we keep the original
-      // pixels and ask the SkiaObjectBox to make an image from them when
-      // resurrecting.
-      //
-      // IMPORTANT: the alphaType, colorType, and colorSpace passed to
-      // _encodeImage and to canvasKit.MakeImage must be the same. Otherwise
-      // Skia will misinterpret the pixels and corrupt the image.
-      final ByteData? originalBytes = _encodeImage(
-        skImage: skImage,
-        format: ui.ImageByteFormat.rawRgba,
-        alphaType: canvasKit.AlphaType.Premul,
-        colorType: canvasKit.ColorType.RGBA_8888,
-        colorSpace: SkColorSpaceSRGB,
-      );
-      if (originalBytes == null) {
-        printWarning('Unable to encode image to bytes. We will not '
-            'be able to resurrect it once it has been garbage collected.');
-        return;
-      }
-      final int originalWidth = skImage.width().toInt();
-      final int originalHeight = skImage.height().toInt();
-      box = SkiaObjectBox<CkImage, SkImage>.resurrectable(this, skImage, () {
-        final SkImage? skImage = canvasKit.MakeImage(
-          SkImageInfo(
-            alphaType: canvasKit.AlphaType.Premul,
-            colorType: canvasKit.ColorType.RGBA_8888,
-            colorSpace: SkColorSpaceSRGB,
-            width: originalWidth.toDouble(),
-            height: originalHeight.toDouble(),
-          ),
-          originalBytes.buffer.asUint8List(),
-          (4 * originalWidth).toDouble(),
-        );
-        if (skImage == null) {
-          throw ImageCodecException(
-            'Failed to resurrect image from pixels.'
-          );
-        }
-        return skImage;
-      });
-    }
   }
 
   CkImage.cloneOf(this.box, {this.videoFrame}) {
@@ -291,9 +247,9 @@
   StackTrace get debugStackTrace => _debugStackTrace;
   late StackTrace _debugStackTrace;
 
-  // Use a box because `SkImage` may be deleted either due to this object
+  // Use ref counting because `SkImage` may be deleted either due to this object
   // being garbage-collected, or by an explicit call to [delete].
-  late final SkiaObjectBox<CkImage, SkImage> box;
+  late final CountedRef<CkImage, SkImage> box;
 
   /// For browsers that support `ImageDecoder` this field holds the video frame
   /// from which this image was created.
@@ -305,9 +261,9 @@
 
   /// The underlying Skia image object.
   ///
-  /// Do not store the returned value. It is memory-managed by [SkiaObjectBox].
+  /// Do not store the returned value. It is memory-managed by [CountedRef].
   /// Storing it may result in use-after-free bugs.
-  SkImage get skImage => box.skiaObject;
+  SkImage get skImage => box.nativeObject;
 
   bool _disposed = false;
 
diff --git a/lib/web_ui/lib/src/engine/canvaskit/native_memory.dart b/lib/web_ui/lib/src/engine/canvaskit/native_memory.dart
new file mode 100644
index 0000000..a4f0648
--- /dev/null
+++ b/lib/web_ui/lib/src/engine/canvaskit/native_memory.dart
@@ -0,0 +1,218 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:js_interop';
+import 'package:meta/meta.dart';
+
+import '../../engine.dart' show Instrumentation;
+import '../util.dart';
+import 'canvaskit_api.dart';
+
+/// Collects native objects that weren't explicitly disposed of using
+/// [UniqueRef.dispose] or [CountedRef.unref].
+SkObjectFinalizationRegistry _finalizationRegistry = createSkObjectFinalizationRegistry(
+  (UniqueRef<Object> uniq) {
+    uniq.collect();
+  }.toJS
+);
+
+NativeMemoryFinalizationRegistry nativeMemoryFinalizationRegistry = NativeMemoryFinalizationRegistry();
+
+/// An indirection to [SkObjectFinalizationRegistry] to enable tests provide a
+/// mock implementation of a finalization registry.
+class NativeMemoryFinalizationRegistry {
+  void register(Object owner, UniqueRef<Object> ref) {
+    if (browserSupportsFinalizationRegistry) {
+      _finalizationRegistry.register(owner, ref);
+    }
+  }
+}
+
+/// Manages the lifecycle of a C++ object referenced by a single Dart object.
+///
+/// It is expected that when the C++ object is no longer needed [dispose] is
+/// called.
+///
+/// To prevent memory leaks, the underlying C++ object is deleted by the GC if
+/// it wasn't previously disposed of explicitly.
+class UniqueRef<T extends Object> {
+  UniqueRef(Object owner, T nativeObject, this._debugOwnerLabel) {
+    _nativeObject = nativeObject;
+    if (Instrumentation.enabled) {
+      Instrumentation.instance.incrementCounter('$_debugOwnerLabel Created');
+    }
+    nativeMemoryFinalizationRegistry.register(owner, this);
+  }
+
+  T? _nativeObject;
+  final String _debugOwnerLabel;
+
+  /// Returns the underlying native object reference, if it has not been
+  /// disposed of yet.
+  ///
+  /// The returned reference must not be stored. I should only be borrowed
+  /// temporarily. Storing this reference may result in dangling pointer errors.
+  T get nativeObject {
+    assert(!isDisposed, 'Native object was disposed.');
+    return _nativeObject!;
+  }
+
+  /// Returns whether the underlying native object has been disposed and
+  /// therefore can no longer be used.
+  bool get isDisposed => _nativeObject == null;
+
+  /// Disposes the underlying native object.
+  ///
+  /// The underlying object may be deleted or its ref count may be bumped down.
+  /// The exact action taken depends on the sharing model of that particular
+  /// object. For example, an [SkImage] may not be immediately deleted if a
+  /// [SkPicture] exists that still references it. On the other hand, [SkPaint]
+  /// is deleted eagerly.
+  void dispose() {
+    assert(!isDisposed, 'A native object reference cannot be disposed more than once.');
+    if (Instrumentation.enabled) {
+      Instrumentation.instance.incrementCounter('$_debugOwnerLabel Deleted');
+    }
+    final SkDeletable object = nativeObject as SkDeletable;
+    if (!object.isDeleted()) {
+      object.delete();
+    }
+    _nativeObject = null;
+  }
+
+  /// Called by the garbage [Collector] when the owner of this handle is
+  /// collected.
+  ///
+  /// Garbage collection is used as a back-up for the cases when the handle
+  /// isn't disposed of explicitly by calling [dispose]. It most likely
+  /// indicates a memory leak or inefficiency in the framework or application
+  /// code.
+  @visibleForTesting
+  void collect() {
+    if (!isDisposed) {
+      if (Instrumentation.enabled) {
+        Instrumentation.instance.incrementCounter('$_debugOwnerLabel Leaked');
+      }
+      dispose();
+    }
+  }
+}
+
+/// Interface that classes wrapping [UniqueRef] must implement.
+///
+/// Used to collect stack traces in debug mode.
+abstract class StackTraceDebugger {
+  /// The stack trace pointing to code location that created or upreffed a
+  /// [CountedRef].
+  StackTrace get debugStackTrace;
+}
+
+/// Manages the lifecycle of a C++ object referenced by multiple Dart objects.
+///
+/// Uses reference counting to manage the lifecycle of the C++ object.
+///
+/// If the C++ object has a unique owner, use [UniqueRef] instead.
+///
+/// The [ref] method can be used to increment the refcount to tell this box to
+/// keep the underlying C++ object alive.
+///
+/// The [unref] method can be used to decrement the refcount indicating that a
+/// referring object no longer needs it. When the refcount drops to zero the
+/// underlying C++ object is deleted.
+///
+/// In addition to ref counting, this object is also managed by GC. When this
+/// reference is garbage collected, the underlying C++ object is automatically
+/// deleted. This is mostly done to prevent memory leaks in production. Well
+/// behaving framework and app code are expected to rely on [ref] and [unref]
+/// for timely collection of resources.
+class CountedRef<R extends StackTraceDebugger, T extends Object> {
+  /// Creates a counted reference.
+  CountedRef(T nativeObject, R debugReferrer, String debugLabel) {
+    _ref = UniqueRef<T>(this, nativeObject, debugLabel);
+    if (assertionsEnabled) {
+      debugReferrers.add(debugReferrer);
+    }
+    assert(refCount == debugReferrers.length);
+  }
+
+  /// The native object reference whose lifecycle is being managed by this ref
+  /// count.
+  ///
+  /// Do not store this value outside this class.
+  late final UniqueRef<T> _ref;
+
+  /// Returns the underlying native object reference, if it has not been
+  /// disposed of yet.
+  ///
+  /// The returned reference must not be stored. I should only be borrowed
+  /// temporarily. Storing this reference may result in dangling pointer errors.
+  T get nativeObject => _ref.nativeObject;
+
+  /// The number of objects sharing references to this box.
+  ///
+  /// When this count reaches zero, the underlying [nativeObject] is scheduled
+  /// for deletion.
+  int get refCount => _refCount;
+  int _refCount = 1;
+
+  /// Whether the underlying [nativeObject] has been disposed and is no longer
+  /// accessible.
+  bool get isDisposed => _ref.isDisposed;
+
+  /// When assertions are enabled, stores all objects that share this box.
+  ///
+  /// The length of this list is always identical to [refCount].
+  ///
+  /// This list can be used for debugging ref counting issues.
+  final Set<R> debugReferrers = <R>{};
+
+  /// If asserts are enabled, the [StackTrace]s representing when a reference
+  /// was created.
+  List<StackTrace> debugGetStackTraces() {
+    if (assertionsEnabled) {
+      return debugReferrers
+          .map<StackTrace>((R referrer) => referrer.debugStackTrace)
+          .toList();
+    }
+    throw UnsupportedError('');
+  }
+
+  /// Increases the reference count of this box because a new object began
+  /// sharing ownership of the underlying [nativeObject].
+  void ref(R debugReferrer) {
+    assert(
+      !_ref.isDisposed,
+      'Cannot increment ref count on a deleted handle.',
+    );
+    assert(_refCount > 0);
+    assert(
+      debugReferrers.add(debugReferrer),
+      'Attempted to increment ref count by the same referrer more than once.',
+    );
+    _refCount += 1;
+    assert(refCount == debugReferrers.length);
+  }
+
+  /// Decrements the reference count for the [nativeObject].
+  ///
+  /// Does nothing if the object has already been deleted.
+  ///
+  /// If this causes the reference count to drop to zero, deletes the
+  /// [nativeObject].
+  void unref(R debugReferrer) {
+    assert(
+      !_ref.isDisposed,
+      'Attempted to unref an already deleted native object.',
+    );
+    assert(
+      debugReferrers.remove(debugReferrer),
+      'Attempted to decrement ref count by the same referrer more than once.',
+    );
+    _refCount -= 1;
+    assert(refCount == debugReferrers.length);
+    if (_refCount == 0) {
+      _ref.dispose();
+    }
+  }
+}
diff --git a/lib/web_ui/lib/src/engine/canvaskit/picture.dart b/lib/web_ui/lib/src/engine/canvaskit/picture.dart
index 28511bb..936d589 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/picture.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/picture.dart
@@ -46,8 +46,7 @@
   /// false.
   ///
   /// This extra flag is necessary on top of [rawSkiaObject] because
-  /// [rawSkiaObject] being null does not indicate permanent deletion. See
-  /// similar flag [SkiaObjectBox.isDeletedPermanently].
+  /// [rawSkiaObject] being null does not indicate permanent deletion.
   bool _isDisposed = false;
 
   /// The stack trace taken when [dispose] was called.
diff --git a/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart b/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart
index 21debea..677397b 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart
@@ -7,7 +7,6 @@
 import 'package:meta/meta.dart';
 
 import '../../engine.dart' show Instrumentation;
-import '../util.dart';
 import 'canvaskit_api.dart';
 import 'renderer.dart';
 
@@ -88,85 +87,6 @@
   }
 }
 
-/// Like [SkiaObjectCache] but enforces the [maximumSize] of the cache
-/// synchronously instead of waiting until a post-frame callback.
-class SynchronousSkiaObjectCache {
-  SynchronousSkiaObjectCache(this.maximumSize)
-      : _itemQueue = DoubleLinkedQueue<SkiaObject<Object>>(),
-        _itemMap = <SkiaObject<Object>, DoubleLinkedQueueEntry<SkiaObject<Object>>>{};
-
-  /// This cache will never exceed this limit, even temporarily.
-  final int maximumSize;
-
-  /// A doubly linked list of the objects in the cache.
-  ///
-  /// This makes it fast to move a recently used object to the front.
-  final DoubleLinkedQueue<SkiaObject<Object>> _itemQueue;
-
-  /// A map of objects to their associated node in the [_itemQueue].
-  ///
-  /// This makes it fast to find the node in the queue when we need to
-  /// move the object to the front of the queue.
-  final Map<SkiaObject<Object>, DoubleLinkedQueueEntry<SkiaObject<Object>>> _itemMap;
-
-  /// The number of objects in the cache.
-  int get length => _itemQueue.length;
-
-  /// Whether or not [object] is in the cache.
-  ///
-  /// This is only for testing.
-  @visibleForTesting
-  bool debugContains(SkiaObject<Object> object) {
-    return _itemMap.containsKey(object);
-  }
-
-  /// Adds [object] to the cache.
-  ///
-  /// If adding [object] causes the total size of the cache to exceed
-  /// [maximumSize], then the least recently used objects are evicted and
-  /// deleted.
-  void add(SkiaObject<Object> object) {
-    assert(
-      !_itemMap.containsKey(object),
-      'Cannot add object. Object is already in the cache: $object',
-    );
-    _itemQueue.addFirst(object);
-    _itemMap[object] = _itemQueue.firstEntry()!;
-    _enforceCacheLimit();
-  }
-
-  /// Marks the [object] as most recently used.
-  ///
-  /// If [object] is in the cache returns true. If the object is not in
-  /// the cache, for example, because it was never added or because it was
-  /// evicted as a result of the app reaching the cache limit, returns false.
-  bool markUsed(SkiaObject<Object> object) {
-    final DoubleLinkedQueueEntry<SkiaObject<Object>>? item = _itemMap[object];
-
-    if (item == null) {
-      return false;
-    }
-
-    item.remove();
-    _itemQueue.addFirst(object);
-    _itemMap[object] = _itemQueue.firstEntry()!;
-    return true;
-  }
-
-  /// Ensures the cache does not exceed [maximumSize], evicting objects if
-  /// necessary.
-  ///
-  /// Calls `delete` and `didDelete` on objects evicted from the cache.
-  void _enforceCacheLimit() {
-    while (_itemQueue.length > maximumSize) {
-      final SkiaObject<Object> oldObject = _itemQueue.removeLast();
-      _itemMap.remove(oldObject);
-      oldObject.delete();
-      oldObject.didDelete();
-    }
-  }
-}
-
 /// An object backed by a JavaScript object mapped onto a Skia C++ object in the
 /// WebAssembly heap.
 ///
@@ -311,205 +231,9 @@
   bool get isResurrectionExpensive => false;
 }
 
-/// Interface that classes wrapping [SkiaObjectBox] must implement.
-///
-/// Used to collect stack traces in debug mode.
-abstract class StackTraceDebugger {
-  /// The stack trace pointing to code location that created or upreffed a
-  /// [SkiaObjectBox].
-  StackTrace get debugStackTrace;
-}
-
 /// A function that restores a Skia object that was temporarily deleted.
 typedef Resurrector<T> = T Function();
 
-/// Uses reference counting to manage the lifecycle of a Skia object owned by a
-/// wrapper object.
-///
-/// The [ref] method can be used to increment the refcount to tell this box to
-/// keep the underlying Skia object alive.
-///
-/// The [unref] method can be used to decrement the refcount to tell this box
-/// that a wrapper object no longer needs it. When the refcount drops to zero
-/// the underlying Skia object is deleted permanently (see [isDeletedPermanently]).
-///
-/// In addition to ref counting, this object is also managed by GC. In browsers
-/// that support [SkFinalizationRegistry] the underlying Skia object is deleted
-/// permanently when no JavaScript objects have references to this box. In
-/// browsers that do not support [SkFinalizationRegistry] the underlying Skia
-/// object may undergo several cycles of temporary deletions and resurrections
-/// prior to being deleted permanently. A temporary deletion may effectively
-/// be permanent if this object is garbage collected. This is safe because a
-/// temporarily deleted object has no C++ resources to collect.
-class SkiaObjectBox<R extends StackTraceDebugger, T extends Object>
-    extends SkiaObject<T> {
-  /// Creates an object box that's memory-managed using [SkFinalizationRegistry].
-  ///
-  /// This constructor must only be used if [browserSupportsFinalizationRegistry] is true.
-  SkiaObjectBox(R debugReferrer, T initialValue) :
-        assert(browserSupportsFinalizationRegistry), _resurrector = null {
-    _initialize(debugReferrer, initialValue);
-    Collector.instance.register(this, _skDeletable!);
-  }
-
-  /// Creates an object box that's memory-managed using a [Resurrector].
-  ///
-  /// This constructor must only be used if [browserSupportsFinalizationRegistry] is false.
-  SkiaObjectBox.resurrectable(
-      R debugReferrer, T initialValue, this._resurrector) :
-        assert(!browserSupportsFinalizationRegistry) {
-    _initialize(debugReferrer, initialValue);
-    if (Instrumentation.enabled) {
-      Instrumentation.instance.incrementCounter(
-        '${_skDeletable?.constructor.name} created',
-      );
-    }
-    SkiaObjects.manageExpensive(this);
-  }
-
-  void _initialize(R debugReferrer, T initialValue) {
-    _update(initialValue);
-    if (assertionsEnabled) {
-      debugReferrers.add(debugReferrer);
-    }
-    assert(refCount == debugReferrers.length);
-  }
-
-  /// The number of objects sharing references to this box.
-  ///
-  /// When this count reaches zero, the underlying [skiaObject] is scheduled
-  /// for deletion.
-  int get refCount => _refCount;
-  int _refCount = 1;
-
-  /// When assertions are enabled, stores all objects that share this box.
-  ///
-  /// The length of this list is always identical to [refCount].
-  ///
-  /// This list can be used for debugging ref counting issues.
-  final Set<R> debugReferrers = <R>{};
-
-  /// If asserts are enabled, the [StackTrace]s representing when a reference
-  /// was created.
-  List<StackTrace> debugGetStackTraces() {
-    if (assertionsEnabled) {
-      return debugReferrers
-          .map<StackTrace>((R referrer) => referrer.debugStackTrace)
-          .toList();
-    }
-    throw UnsupportedError('');
-  }
-
-  /// The Skia object whose lifecycle is being managed.
-  ///
-  /// Do not store this value outside this box. It is memory-managed by
-  /// [SkiaObjectBox]. Storing it may result in use-after-free bugs.
-  T? rawSkiaObject;
-  SkDeletable? _skDeletable;
-  Resurrector<T>? _resurrector;
-
-  void _update(T? newSkiaObject) {
-    rawSkiaObject = newSkiaObject;
-    _skDeletable = newSkiaObject as SkDeletable?;
-  }
-
-  @override
-  T get skiaObject => rawSkiaObject ?? _doResurrect();
-
-  T _doResurrect() {
-    assert(!browserSupportsFinalizationRegistry);
-    assert(_resurrector != null);
-    assert(!_isDeletedPermanently, 'Cannot use deleted object.');
-    _update(_resurrector!());
-    if (Instrumentation.enabled) {
-      Instrumentation.instance.incrementCounter(
-        '${_skDeletable?.constructor.name} resurrected',
-      );
-    }
-    SkiaObjects.manageExpensive(this);
-    return skiaObject;
-  }
-
-  @override
-  void delete() {
-    _skDeletable?.delete();
-  }
-
-  @override
-  void didDelete() {
-    if (Instrumentation.enabled) {
-      Instrumentation.instance.incrementCounter(
-        '${_skDeletable?.constructor.name} deleted',
-      );
-    }
-    assert(!browserSupportsFinalizationRegistry);
-    _update(null);
-  }
-
-  /// Whether this object has been deleted permanently.
-  ///
-  /// If this is true it will remain true forever, and the Skia object is no
-  /// longer resurrectable.
-  ///
-  /// See also [isDeletedTemporarily].
-  bool get isDeletedPermanently => _isDeletedPermanently;
-  bool _isDeletedPermanently = false;
-
-  /// Whether the underlying [rawSkiaObject] has been deleted, but it may still
-  /// be resurrected (see [SkiaObjectBox.resurrectable]).
-  bool get isDeletedTemporarily =>
-      rawSkiaObject == null && !_isDeletedPermanently;
-
-  /// Increases the reference count of this box because a new object began
-  /// sharing ownership of the underlying [skiaObject].
-  ///
-  /// Clones must be [dispose]d when finished.
-  void ref(R debugReferrer) {
-    assert(!_isDeletedPermanently,
-        'Cannot increment ref count on a deleted handle.');
-    assert(_refCount > 0);
-    assert(
-      debugReferrers.add(debugReferrer),
-      'Attempted to increment ref count by the same referrer more than once.',
-    );
-    _refCount += 1;
-    assert(refCount == debugReferrers.length);
-  }
-
-  /// Decrements the reference count for the [skObject].
-  ///
-  /// Does nothing if the object has already been deleted.
-  ///
-  /// If this causes the reference count to drop to zero, deletes the
-  /// [skObject].
-  void unref(R debugReferrer) {
-    assert(!_isDeletedPermanently,
-        'Attempted to unref an already deleted Skia object.');
-    assert(
-      debugReferrers.remove(debugReferrer),
-      'Attempted to decrement ref count by the same referrer more than once.',
-    );
-    _refCount -= 1;
-    assert(refCount == debugReferrers.length);
-    if (_refCount == 0) {
-      // The object may be null because it was deleted temporarily, i.e. it was
-      // expecting the possibility of resurrection.
-      if (_skDeletable != null) {
-        if (browserSupportsFinalizationRegistry) {
-          Collector.instance.collect(_skDeletable!);
-        } else {
-          delete();
-          didDelete();
-        }
-      }
-      rawSkiaObject = null;
-      _skDeletable = null;
-      _resurrector = null;
-      _isDeletedPermanently = true;
-    }
-  }
-}
-
 // ignore: avoid_classes_with_only_static_members
 /// Singleton that manages the lifecycles of [SkiaObject] instances.
 class SkiaObjects {
diff --git a/lib/web_ui/lib/src/engine/canvaskit/text.dart b/lib/web_ui/lib/src/engine/canvaskit/text.dart
index 2b4ab31..ed14a91 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/text.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/text.dart
@@ -10,6 +10,7 @@
 import '../util.dart';
 import 'canvaskit_api.dart';
 import 'font_fallbacks.dart';
+import 'native_memory.dart';
 import 'painting.dart';
 import 'renderer.dart';
 import 'skia_object_cache.dart';
@@ -561,13 +562,20 @@
 /// while painting a small subset of it. To achieve this a
 /// [SynchronousSkiaObjectCache] is used that limits the number of live laid out
 /// paragraphs at any point in time within or outside the frame.
-class CkParagraph extends SkiaObject<SkParagraph> implements ui.Paragraph {
-  CkParagraph(this._skParagraph, this._paragraphStyle, this._paragraphCommands);
+class CkParagraph implements ui.Paragraph {
+  CkParagraph(SkParagraph skParagraph, this._paragraphStyle) {
+    _ref = UniqueRef<SkParagraph>(this, skParagraph, 'Paragraph');
+  }
 
-  /// The result of calling `build()` on the JS CkParagraphBuilder.
+  late final UniqueRef<SkParagraph> _ref;
+
+  SkParagraph get skiaObject => _ref.nativeObject;
+
+  /// The constraints from the last time we laid the paragraph out.
   ///
-  /// This may be invalidated later.
-  SkParagraph? _skParagraph;
+  /// This is used to resurrect the paragraph if the initial paragraph
+  /// is deleted.
+  double _lastLayoutConstraints = double.negativeInfinity;
 
   /// The paragraph style used to build this paragraph.
   ///
@@ -575,114 +583,6 @@
   /// is deleted.
   final CkParagraphStyle _paragraphStyle;
 
-  /// The paragraph builder commands used to build this paragraph.
-  ///
-  /// This is used to resurrect the paragraph if the initial paragraph
-  /// is deleted.
-  final List<_ParagraphCommand> _paragraphCommands;
-
-  /// The constraints from the last time we laid the paragraph out.
-  ///
-  /// This is used to resurrect the paragraph if the initial paragraph
-  /// is deleted.
-  ui.ParagraphConstraints? _lastLayoutConstraints;
-
-  @override
-  SkParagraph get skiaObject => _ensureInitialized(_lastLayoutConstraints!);
-
-  SkParagraph _ensureInitialized(ui.ParagraphConstraints constraints) {
-    SkParagraph? paragraph = _skParagraph;
-
-    // Paragraph objects are immutable. It's OK to skip initialization and reuse
-    // existing object.
-    bool didRebuildSkiaObject = false;
-    if (paragraph == null) {
-      final CkParagraphBuilder builder = CkParagraphBuilder(_paragraphStyle);
-      for (final _ParagraphCommand command in _paragraphCommands) {
-        switch (command.type) {
-          case _ParagraphCommandType.addText:
-            builder.addText(command.text!);
-          case _ParagraphCommandType.pop:
-            builder.pop();
-          case _ParagraphCommandType.pushStyle:
-            builder.pushStyle(command.style!);
-          case _ParagraphCommandType.addPlaceholder:
-            builder._addPlaceholder(command.placeholderStyle!);
-        }
-      }
-      paragraph = builder._buildSkParagraph();
-      _skParagraph = paragraph;
-      didRebuildSkiaObject = true;
-    }
-
-    final bool constraintsChanged = _lastLayoutConstraints != constraints;
-    if (didRebuildSkiaObject || constraintsChanged) {
-      _lastLayoutConstraints = constraints;
-      // TODO(het): CanvasKit throws an exception when laid out with
-      // a font that wasn't registered.
-      try {
-        paragraph.layout(constraints.width);
-        _alphabeticBaseline = paragraph.getAlphabeticBaseline();
-        _didExceedMaxLines = paragraph.didExceedMaxLines();
-        _height = paragraph.getHeight();
-        _ideographicBaseline = paragraph.getIdeographicBaseline();
-        _longestLine = paragraph.getLongestLine();
-        _maxIntrinsicWidth = paragraph.getMaxIntrinsicWidth();
-        _minIntrinsicWidth = paragraph.getMinIntrinsicWidth();
-        _width = paragraph.getMaxWidth();
-        _boxesForPlaceholders =
-            skRectsToTextBoxes(paragraph.getRectsForPlaceholders());
-      } catch (e) {
-        printWarning('CanvasKit threw an exception while laying '
-            'out the paragraph. The font was "${_paragraphStyle._fontFamily}". '
-            'Exception:\n$e');
-        rethrow;
-      }
-    }
-
-    return paragraph;
-  }
-
-  // Caches laid out paragraphs and synchronously reclaims memory if there's
-  // memory pressure.
-  //
-  // On May 26, 2021, 500 seemed like a reasonable number to pick for the cache
-  // size. At the time a single laid out SkParagraph used 100KB of memory. So,
-  // 500 items in the cache is roughly 50MB of memory, which is not too high,
-  // while at the same time enough for most use-cases.
-  //
-  // TODO(yjbanov): this strategy is not sufficient for the use-case where a
-  //                lot of paragraphs are laid out _and_ rendered. To support
-  //                this use-case without blowing up memory usage we need this:
-  //                https://github.com/flutter/flutter/issues/81224
-  static final SynchronousSkiaObjectCache _paragraphCache =
-      SynchronousSkiaObjectCache(500);
-
-  /// Marks this paragraph as having been used this frame.
-  ///
-  /// Puts this paragraph in a [SynchronousSkiaObjectCache], which will delete it
-  /// if there's memory pressure to do so. This protects our memory usage from
-  /// blowing up if within a single frame the framework needs to layout a lot of
-  /// paragraphs. One common use-case is `ListView.builder`, which needs to layout
-  /// more of its content than it actually renders to compute the scroll position.
-  void markUsed() {
-    // If the paragraph is already in the cache, just mark it as most recently
-    // used. Otherwise, add to cache.
-    if (!_paragraphCache.markUsed(this)) {
-      _paragraphCache.add(this);
-    }
-  }
-
-  @override
-  void delete() {
-    _skParagraph?.delete();
-    _skParagraph = null;
-  }
-
-  @override
-  void didDelete() {
-    _skParagraph = null;
-  }
 
   @override
   double get alphabeticBaseline => _alphabeticBaseline;
@@ -727,12 +627,12 @@
     ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight,
     ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight,
   }) {
+    assert(!_disposed, 'Paragraph has been disposed.');
     if (start < 0 || end < 0) {
       return const <ui.TextBox>[];
     }
 
-    final SkParagraph paragraph = _ensureInitialized(_lastLayoutConstraints!);
-    final List<SkRectWithDirection> skRects = paragraph.getRectsForRange(
+    final List<SkRectWithDirection> skRects = skiaObject.getRectsForRange(
       start.toDouble(),
       end.toDouble(),
       toSkRectHeightStyle(boxHeightStyle),
@@ -743,6 +643,7 @@
   }
 
   List<ui.TextBox> skRectsToTextBoxes(List<SkRectWithDirection> skRects) {
+    assert(!_disposed, 'Paragraph has been disposed.');
     final List<ui.TextBox> result = <ui.TextBox>[];
 
     for (int i = 0; i < skRects.length; i++) {
@@ -763,9 +664,8 @@
 
   @override
   ui.TextPosition getPositionForOffset(ui.Offset offset) {
-    final SkParagraph paragraph = _ensureInitialized(_lastLayoutConstraints!);
-    final SkTextPosition positionWithAffinity =
-        paragraph.getGlyphPositionAtCoordinate(
+    assert(!_disposed, 'Paragraph has been disposed.');
+    final SkTextPosition positionWithAffinity = skiaObject.getGlyphPositionAtCoordinate(
       offset.dx,
       offset.dy,
     );
@@ -774,7 +674,7 @@
 
   @override
   ui.TextRange getWordBoundary(ui.TextPosition position) {
-    final SkParagraph paragraph = _ensureInitialized(_lastLayoutConstraints!);
+    assert(!_disposed, 'Paragraph has been disposed.');
     final int characterPosition;
     switch (position.affinity) {
       case ui.TextAffinity.upstream:
@@ -782,26 +682,46 @@
       case ui.TextAffinity.downstream:
         characterPosition = position.offset;
     }
-    final SkTextRange skRange = paragraph.getWordBoundary(characterPosition.toDouble());
+    final SkTextRange skRange = skiaObject.getWordBoundary(characterPosition.toDouble());
     return ui.TextRange(start: skRange.start.toInt(), end: skRange.end.toInt());
   }
 
   @override
   void layout(ui.ParagraphConstraints constraints) {
-    if (_lastLayoutConstraints == constraints) {
+    assert(!_disposed, 'Paragraph has been disposed.');
+    if (_lastLayoutConstraints == constraints.width) {
       return;
     }
-    _ensureInitialized(constraints);
 
-    // See class-level and _paragraphCache doc comments for why we're releasing
-    // the paragraph immediately after layout.
-    markUsed();
+    _lastLayoutConstraints = constraints.width;
+
+    // TODO(het): CanvasKit throws an exception when laid out with
+    // a font that wasn't registered.
+    try {
+      final SkParagraph paragraph = skiaObject;
+      paragraph.layout(constraints.width);
+      _alphabeticBaseline = paragraph.getAlphabeticBaseline();
+      _didExceedMaxLines = paragraph.didExceedMaxLines();
+      _height = paragraph.getHeight();
+      _ideographicBaseline = paragraph.getIdeographicBaseline();
+      _longestLine = paragraph.getLongestLine();
+      _maxIntrinsicWidth = paragraph.getMaxIntrinsicWidth();
+      _minIntrinsicWidth = paragraph.getMinIntrinsicWidth();
+      _width = paragraph.getMaxWidth();
+      _boxesForPlaceholders =
+          skRectsToTextBoxes(paragraph.getRectsForPlaceholders());
+    } catch (e) {
+      printWarning('CanvasKit threw an exception while laying '
+          'out the paragraph. The font was "${_paragraphStyle._fontFamily}". '
+          'Exception:\n$e');
+      rethrow;
+    }
   }
 
   @override
   ui.TextRange getLineBoundary(ui.TextPosition position) {
-    final SkParagraph paragraph = _ensureInitialized(_lastLayoutConstraints!);
-    final List<SkLineMetrics> metrics = paragraph.getLineMetrics();
+    assert(!_disposed, 'Paragraph has been disposed.');
+    final List<SkLineMetrics> metrics = skiaObject.getLineMetrics();
     final int offset = position.offset;
     for (final SkLineMetrics metric in metrics) {
       if (offset >= metric.startIndex && offset <= metric.endIndex) {
@@ -813,8 +733,8 @@
 
   @override
   List<ui.LineMetrics> computeLineMetrics() {
-    final SkParagraph paragraph = _ensureInitialized(_lastLayoutConstraints!);
-    final List<SkLineMetrics> skLineMetrics = paragraph.getLineMetrics();
+    assert(!_disposed, 'Paragraph has been disposed.');
+    final List<SkLineMetrics> skLineMetrics = skiaObject.getLineMetrics();
     final List<ui.LineMetrics> result = <ui.LineMetrics>[];
     for (final SkLineMetrics metric in skLineMetrics) {
       result.add(CkLineMetrics._(metric));
@@ -826,8 +746,8 @@
 
   @override
   void dispose() {
-    delete();
-    didDelete();
+    assert(!_disposed, 'Paragraph has been disposed.');
+    _ref.dispose();
     _disposed = true;
   }
 
@@ -877,8 +797,7 @@
 
 class CkParagraphBuilder implements ui.ParagraphBuilder {
   CkParagraphBuilder(ui.ParagraphStyle style)
-      : _commands = <_ParagraphCommand>[],
-        _style = style as CkParagraphStyle,
+      : _style = style as CkParagraphStyle,
         _placeholderCount = 0,
         _placeholderScales = <double>[],
         _styleStack = <CkTextStyle>[],
@@ -891,7 +810,6 @@
 
   final SkParagraphBuilder _paragraphBuilder;
   final CkParagraphStyle _style;
-  final List<_ParagraphCommand> _commands;
   int _placeholderCount;
   final List<double> _placeholderScales;
   final List<CkTextStyle> _styleStack;
@@ -923,7 +841,6 @@
   }
 
   void _addPlaceholder(_CkParagraphPlaceholder placeholderStyle) {
-    _commands.add(_ParagraphCommand.addPlaceholder(placeholderStyle));
     _paragraphBuilder.addPlaceholder(
       placeholderStyle.width,
       placeholderStyle.height,
@@ -961,14 +878,13 @@
       fontFamilies.addAll(style.fontFamilyFallback!);
     }
     FontFallbackData.instance.ensureFontsSupportText(text, fontFamilies);
-    _commands.add(_ParagraphCommand.addText(text));
     _paragraphBuilder.addText(text);
   }
 
   @override
   CkParagraph build() {
     final SkParagraph builtParagraph = _buildSkParagraph();
-    return CkParagraph(builtParagraph, _style, _commands);
+    return CkParagraph(builtParagraph, _style);
   }
 
   /// Builds the CkParagraph with the builder and deletes the builder.
@@ -999,7 +915,6 @@
       }
       return;
     }
-    _commands.add(const _ParagraphCommand.pop());
     _styleStack.removeLast();
     _paragraphBuilder.pop();
   }
@@ -1026,7 +941,6 @@
     final CkTextStyle ckStyle = style as CkTextStyle;
     final CkTextStyle skStyle = baseStyle.mergeWith(ckStyle);
     _styleStack.add(skStyle);
-    _commands.add(_ParagraphCommand.pushStyle(ckStyle));
     if (skStyle.foreground != null || skStyle.background != null) {
       SkPaint? foreground = skStyle.foreground?.skiaObject;
       if (foreground == null) {
@@ -1062,41 +976,6 @@
   final double offset;
 }
 
-class _ParagraphCommand {
-  const _ParagraphCommand._(
-    this.type,
-    this.text,
-    this.style,
-    this.placeholderStyle,
-  );
-
-  const _ParagraphCommand.addText(String text)
-      : this._(_ParagraphCommandType.addText, text, null, null);
-
-  const _ParagraphCommand.pop()
-      : this._(_ParagraphCommandType.pop, null, null, null);
-
-  const _ParagraphCommand.pushStyle(CkTextStyle style)
-      : this._(_ParagraphCommandType.pushStyle, null, style, null);
-
-  const _ParagraphCommand.addPlaceholder(
-      _CkParagraphPlaceholder placeholderStyle)
-      : this._(
-            _ParagraphCommandType.addPlaceholder, null, null, placeholderStyle);
-
-  final _ParagraphCommandType type;
-  final String? text;
-  final CkTextStyle? style;
-  final _CkParagraphPlaceholder? placeholderStyle;
-}
-
-enum _ParagraphCommandType {
-  addText,
-  pop,
-  pushStyle,
-  addPlaceholder,
-}
-
 List<String> _getEffectiveFontFamilies(String? fontFamily,
     [List<String>? fontFamilyFallback]) {
   final List<String> fontFamilies = <String>[];
diff --git a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
index 6bdd2b3..d2140d9 100644
--- a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
+++ b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'dart:js_interop';
 import 'dart:math';
 import 'dart:typed_data';
 
@@ -1432,7 +1433,6 @@
     builder.addText('Hello');
     final CkParagraph paragraph = builder.build();
 
-    paragraph.delete();
     paragraph.dispose();
     expect(paragraph.debugDisposed, true);
   });
@@ -1894,6 +1894,15 @@
       'http://localhost:1234/foo/canvaskit.wasm',
     );
   });
+
+  test('SkObjectFinalizationRegistry', () {
+    // There's no reliable way to test the actual functionality of
+    // FinalizationRegistry because it depends on GC, which cannot be controlled,
+    // So the test simply tests that a FinalizationRegistry can be constructed
+    // and its `register` method can be called.
+    final SkObjectFinalizationRegistry registry = createSkObjectFinalizationRegistry((String arg) {}.toJS);
+    registry.register(Object(), Object());
+  });
 }
 
 
diff --git a/lib/web_ui/test/canvaskit/image_golden_test.dart b/lib/web_ui/test/canvaskit/image_golden_test.dart
index 3fae53f..ffc9507 100644
--- a/lib/web_ui/test/canvaskit/image_golden_test.dart
+++ b/lib/web_ui/test/canvaskit/image_golden_test.dart
@@ -137,10 +137,10 @@
               .makeImageAtCurrentFrame();
       final CkImage image = CkImage(skImage);
       expect(image.debugDisposed, isFalse);
-      expect(image.box.isDeletedPermanently, isFalse);
+      expect(image.box.isDisposed, isFalse);
       image.dispose();
       expect(image.debugDisposed, isTrue);
-      expect(image.box.isDeletedPermanently, isTrue);
+      expect(image.box.isDisposed, isTrue);
 
       // Disallow double-dispose.
       expect(() => image.dispose(), throwsAssertionError);
@@ -152,7 +152,7 @@
           canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)!
               .makeImageAtCurrentFrame();
       final CkImage image = CkImage(skImage);
-      final SkiaObjectBox<CkImage, SkImage> box = image.box;
+      final CountedRef<CkImage, SkImage> box = image.box;
       expect(box.refCount, 1);
       expect(box.debugGetStackTraces().length, 1);
 
@@ -161,19 +161,19 @@
       expect(box.debugGetStackTraces().length, 2);
 
       expect(image.isCloneOf(clone), isTrue);
-      expect(box.isDeletedPermanently, isFalse);
+      expect(box.isDisposed, isFalse);
 
       testCollector.collectNow();
       expect(skImage.isDeleted(), isFalse);
       image.dispose();
       expect(box.refCount, 1);
-      expect(box.isDeletedPermanently, isFalse);
+      expect(box.isDisposed, isFalse);
 
       testCollector.collectNow();
       expect(skImage.isDeleted(), isFalse);
       clone.dispose();
       expect(box.refCount, 0);
-      expect(box.isDeletedPermanently, isTrue);
+      expect(box.isDisposed, isTrue);
 
       testCollector.collectNow();
       expect(skImage.isDeleted(), isTrue);
@@ -266,26 +266,6 @@
     // TODO(hterkelsen): Firefox and Safari do not currently support ImageDecoder.
     }, skip: isFirefox || isSafari);
 
-    // Regression test for https://github.com/flutter/flutter/issues/72469
-    test('CkImage can be resurrected', () {
-      browserSupportsFinalizationRegistry = false;
-      final SkImage skImage =
-          canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)!
-              .makeImageAtCurrentFrame();
-      final CkImage image = CkImage(skImage);
-      expect(image.box.rawSkiaObject, isNotNull);
-
-      // Pretend that the image is temporarily deleted.
-      image.box.delete();
-      image.box.didDelete();
-      expect(image.box.rawSkiaObject, isNull);
-
-      // Attempting to access the skia object here would previously throw
-      // "Stack Overflow" in Safari.
-      expect(image.box.skiaObject, isNotNull);
-      testCollector.collectNow();
-    });
-
     test('skiaInstantiateWebImageCodec loads an image from the network',
         () async {
       mockHttpFetchResponseFactory = (String url) async {
diff --git a/lib/web_ui/test/canvaskit/native_memory_test.dart b/lib/web_ui/test/canvaskit/native_memory_test.dart
new file mode 100644
index 0000000..42a99d2
--- /dev/null
+++ b/lib/web_ui/test/canvaskit/native_memory_test.dart
@@ -0,0 +1,258 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:js_interop';
+import 'package:js/js.dart';
+
+import 'package:test/bootstrap/browser.dart';
+import 'package:test/test.dart';
+
+import 'package:ui/src/engine.dart';
+
+import 'common.dart';
+
+void main() {
+  internalBootstrapBrowserTest(() => testMain);
+}
+
+void testMain() {
+  setUpCanvasKitTest();
+
+  late _MockNativeMemoryFinalizationRegistry mockFinalizationRegistry;
+
+  setUp(() {
+    TestSkDeletableMock.deleteCount = 0;
+    nativeMemoryFinalizationRegistry = mockFinalizationRegistry = _MockNativeMemoryFinalizationRegistry();
+  });
+
+  tearDown(() {
+    nativeMemoryFinalizationRegistry = NativeMemoryFinalizationRegistry();
+  });
+
+  group(UniqueRef, () {
+    test('create-dispose-collect cycle', () {
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(0));
+      final Object owner = Object();
+      final TestSkDeletable nativeObject = TestSkDeletable();
+      final UniqueRef<TestSkDeletable> ref = UniqueRef<TestSkDeletable>(owner, nativeObject, 'TestSkDeletable');
+      expect(ref.isDisposed, isFalse);
+      expect(ref.nativeObject, same(nativeObject));
+      expect(TestSkDeletableMock.deleteCount, 0);
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(1));
+      expect(mockFinalizationRegistry.registeredPairs.single.owner, same(owner));
+      expect(mockFinalizationRegistry.registeredPairs.single.ref, same(ref));
+
+      ref.dispose();
+      expect(TestSkDeletableMock.deleteCount, 1);
+      expect(ref.isDisposed, isTrue);
+      expect(
+        reason: 'Cannot access object that was disposed',
+        () => ref.nativeObject, throwsA(isA<AssertionError>()),
+      );
+      expect(
+        reason: 'Cannot dispose object more than once',
+        () => ref.dispose(), throwsA(isA<AssertionError>()),
+      );
+      expect(TestSkDeletableMock.deleteCount, 1);
+
+      // Simulate a GC
+      mockFinalizationRegistry.registeredPairs.single.ref.collect();
+      expect(
+        reason: 'Manually disposed object should not be deleted again by GC.',
+        TestSkDeletableMock.deleteCount, 1,
+      );
+    });
+
+    test('create-collect cycle', () {
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(0));
+      final Object owner = Object();
+      final TestSkDeletable nativeObject = TestSkDeletable();
+      final UniqueRef<TestSkDeletable> ref = UniqueRef<TestSkDeletable>(owner, nativeObject, 'TestSkDeletable');
+      expect(ref.isDisposed, isFalse);
+      expect(ref.nativeObject, same(nativeObject));
+      expect(TestSkDeletableMock.deleteCount, 0);
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(1));
+
+      ref.collect();
+      expect(TestSkDeletableMock.deleteCount, 1);
+      // There's nothing else to test for any practical gain. UniqueRef.collect
+      // is called when GC decided that the owner is no longer reachable. So
+      // there must not be anything else calling into this object for anything
+      // useful.
+    });
+  });
+
+  group(CountedRef, () {
+    test('single owner', () {
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(0));
+      final TestSkDeletable nativeObject = TestSkDeletable();
+      final TestCountedRefOwner owner = TestCountedRefOwner(nativeObject);
+      expect(owner.ref.debugReferrers, hasLength(1));
+      expect(owner.ref.debugReferrers.single, owner);
+      expect(owner.ref.refCount, 1);
+      expect(owner.ref.nativeObject, nativeObject);
+      expect(TestSkDeletableMock.deleteCount, 0);
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(1));
+
+      owner.dispose();
+      expect(owner.ref.debugReferrers, isEmpty);
+      expect(owner.ref.refCount, 0);
+      expect(
+        reason: 'Cannot access object that was disposed',
+        () => owner.ref.nativeObject, throwsA(isA<AssertionError>()),
+      );
+      expect(TestSkDeletableMock.deleteCount, 1);
+
+      expect(
+        reason: 'Cannot dispose object more than once',
+        () => owner.dispose(), throwsA(isA<AssertionError>()),
+      );
+    });
+
+    test('multiple owners', () {
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(0));
+      final TestSkDeletable nativeObject = TestSkDeletable();
+      final TestCountedRefOwner owner1 = TestCountedRefOwner(nativeObject);
+      expect(owner1.ref.debugReferrers, hasLength(1));
+      expect(owner1.ref.debugReferrers.single, owner1);
+      expect(owner1.ref.refCount, 1);
+      expect(owner1.ref.nativeObject, nativeObject);
+      expect(TestSkDeletableMock.deleteCount, 0);
+      expect(mockFinalizationRegistry.registeredPairs, hasLength(1));
+
+      final TestCountedRefOwner owner2 = owner1.clone();
+      expect(owner2.ref, same(owner1.ref));
+      expect(owner2.ref.debugReferrers, hasLength(2));
+      expect(owner2.ref.debugReferrers.first, owner1);
+      expect(owner2.ref.debugReferrers.last, owner2);
+      expect(owner2.ref.refCount, 2);
+      expect(owner2.ref.nativeObject, nativeObject);
+      expect(TestSkDeletableMock.deleteCount, 0);
+      expect(
+        reason: 'Second owner does not add more native object owners. '
+                'The underlying shared UniqueRef is the only one.',
+        mockFinalizationRegistry.registeredPairs, hasLength(1),
+      );
+
+      owner1.dispose();
+      expect(owner2.ref.debugReferrers, hasLength(1));
+      expect(owner2.ref.debugReferrers.single, owner2);
+      expect(owner2.ref.refCount, 1);
+      expect(owner2.ref.nativeObject, nativeObject);
+      expect(TestSkDeletableMock.deleteCount, 0);
+      expect(
+        reason: 'The same owner cannot dispose its CountedRef more than once, even when CountedRef is still alive.',
+        () => owner1.dispose(), throwsA(isA<AssertionError>()),
+      );
+
+      owner2.dispose();
+      expect(owner2.ref.debugReferrers, isEmpty);
+      expect(owner2.ref.refCount, 0);
+      expect(
+        reason: 'Cannot access object that was disposed',
+        () => owner2.ref.nativeObject, throwsA(isA<AssertionError>()),
+      );
+      expect(TestSkDeletableMock.deleteCount, 1);
+
+      expect(
+        reason: 'The same owner cannot dispose its CountedRef more than once.',
+        () => owner2.dispose(), throwsA(isA<AssertionError>()),
+      );
+
+      // Simulate a GC
+      mockFinalizationRegistry.registeredPairs.single.ref.collect();
+      expect(
+        reason: 'Manually disposed object should not be deleted again by GC.',
+        TestSkDeletableMock.deleteCount, 1,
+      );
+    });
+  });
+}
+
+class TestSkDeletableMock {
+  static int deleteCount = 0;
+
+  bool isDeleted() => _isDeleted;
+  bool _isDeleted = false;
+
+  void delete() {
+    expect(_isDeleted, isFalse,
+        reason:
+            'CanvasKit does not allow deleting the same object more than once.');
+    _isDeleted = true;
+    deleteCount++;
+  }
+
+  JsConstructor get constructor => TestJsConstructor(name: 'TestSkDeletable');
+}
+
+@JS()
+@anonymous
+@staticInterop
+class TestSkDeletable implements SkDeletable {
+  factory TestSkDeletable() {
+    final TestSkDeletableMock mock = TestSkDeletableMock();
+    return TestSkDeletable._(
+        isDeleted: () { return mock.isDeleted(); }.toJS,
+        delete: () { return mock.delete(); }.toJS,
+        constructor: mock.constructor);
+  }
+
+  external factory TestSkDeletable._({
+    JSFunction isDeleted,
+    JSFunction delete,
+    JsConstructor constructor});
+}
+
+@JS()
+@anonymous
+@staticInterop
+class TestJsConstructor implements JsConstructor {
+  external factory TestJsConstructor({String name});
+}
+
+class TestCountedRefOwner implements StackTraceDebugger {
+  TestCountedRefOwner(TestSkDeletable nativeObject) {
+    if (assertionsEnabled) {
+      _debugStackTrace = StackTrace.current;
+    }
+    ref = CountedRef<TestCountedRefOwner, TestSkDeletable>(
+        nativeObject, this, 'TestCountedRefOwner');
+  }
+
+  TestCountedRefOwner.cloneOf(this.ref) {
+    if (assertionsEnabled) {
+      _debugStackTrace = StackTrace.current;
+    }
+    ref.ref(this);
+  }
+
+  @override
+  StackTrace get debugStackTrace => _debugStackTrace;
+  late StackTrace _debugStackTrace;
+
+  late final CountedRef<TestCountedRefOwner, TestSkDeletable> ref;
+
+  void dispose() {
+    ref.unref(this);
+  }
+
+  TestCountedRefOwner clone() => TestCountedRefOwner.cloneOf(ref);
+}
+
+class _MockNativeMemoryFinalizationRegistry implements NativeMemoryFinalizationRegistry {
+  final List<_MockPair> registeredPairs = <_MockPair>[];
+
+  @override
+  void register(Object owner, UniqueRef<Object> ref) {
+    registeredPairs.add(_MockPair(owner, ref));
+  }
+}
+
+class _MockPair {
+  _MockPair(this.owner, this.ref);
+
+  Object owner;
+  UniqueRef<Object> ref;
+}
diff --git a/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart b/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart
index 9e7def1..14df75d 100644
--- a/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart
+++ b/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart
@@ -12,8 +12,6 @@
 import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart';
 
-import '../common/matchers.dart';
-import '../common/spy.dart';
 import 'common.dart';
 
 void main() {
@@ -113,242 +111,6 @@
       expect(SkiaObjects.expensiveCache.debugContains(object2), isFalse);
     });
   });
-
-  group(SkiaObjectBox, () {
-    test('Records stack traces and respects refcounts', () async {
-      final ZoneSpy spy = ZoneSpy();
-      spy.run(() {
-        Instrumentation.enabled = true;
-        TestSkDeletableMock.deleteCount = 0;
-        TestBoxWrapper.resurrectCount = 0;
-        final TestBoxWrapper original = TestBoxWrapper();
-
-        expect(original.box.debugGetStackTraces().length, 1);
-        expect(original.box.refCount, 1);
-        expect(original.box.isDeletedPermanently, isFalse);
-
-        final TestBoxWrapper clone = original.clone();
-        expect(clone.box, same(original.box));
-        expect(clone.box.debugGetStackTraces().length, 2);
-        expect(clone.box.refCount, 2);
-        expect(original.box.debugGetStackTraces().length, 2);
-        expect(original.box.refCount, 2);
-        expect(original.box.isDeletedPermanently, isFalse);
-
-        original.dispose();
-
-        testCollector.collectNow();
-        expect(TestSkDeletableMock.deleteCount, 0);
-
-        spy.fakeAsync.elapse(const Duration(seconds: 2));
-        expect(
-          spy.printLog,
-          <String>[
-            'Engine counters:\n  TestSkDeletable created: 1\n'
-          ],
-        );
-
-        expect(clone.box.debugGetStackTraces().length, 1);
-        expect(clone.box.refCount, 1);
-        expect(original.box.debugGetStackTraces().length, 1);
-        expect(original.box.refCount, 1);
-
-        clone.dispose();
-        expect(clone.box.debugGetStackTraces().length, 0);
-        expect(clone.box.refCount, 0);
-        expect(original.box.debugGetStackTraces().length, 0);
-        expect(original.box.refCount, 0);
-        expect(original.box.isDeletedPermanently, isTrue);
-
-        testCollector.collectNow();
-        expect(TestSkDeletableMock.deleteCount, 1);
-        expect(TestBoxWrapper.resurrectCount, 0);
-
-        expect(() => clone.box.unref(clone), throwsAssertionError);
-        spy.printLog.clear();
-        spy.fakeAsync.elapse(const Duration(seconds: 2));
-        expect(
-          spy.printLog,
-          <String>[
-            'Engine counters:\n  TestSkDeletable created: 1\n  TestSkDeletable deleted: 1\n'
-          ],
-        );
-        Instrumentation.enabled = false;
-      });
-    });
-
-    test('Can resurrect Skia objects', () async {
-      TestSkDeletableMock.deleteCount = 0;
-      TestBoxWrapper.resurrectCount = 0;
-      final TestBoxWrapper object = TestBoxWrapper();
-      expect(TestSkDeletableMock.deleteCount, 0);
-      expect(TestBoxWrapper.resurrectCount, 0);
-
-      // Test 3 cycles of delete/resurrect.
-      for (int i = 0; i < 3; i++) {
-        object.box.delete();
-        object.box.didDelete();
-        expect(TestSkDeletableMock.deleteCount, i + 1);
-        expect(TestBoxWrapper.resurrectCount, i);
-        expect(object.box.isDeletedTemporarily, isTrue);
-        expect(object.box.isDeletedPermanently, isFalse);
-
-        expect(object.box.skiaObject, isNotNull);
-        expect(TestSkDeletableMock.deleteCount, i + 1);
-        expect(TestBoxWrapper.resurrectCount, i + 1);
-        expect(object.box.isDeletedTemporarily, isFalse);
-        expect(object.box.isDeletedPermanently, isFalse);
-      }
-
-      object.dispose();
-      expect(object.box.isDeletedPermanently, isTrue);
-    });
-
-    test('Can dispose temporarily deleted object', () async {
-      TestSkDeletableMock.deleteCount = 0;
-      TestBoxWrapper.resurrectCount = 0;
-      final TestBoxWrapper object = TestBoxWrapper();
-      expect(TestSkDeletableMock.deleteCount, 0);
-      expect(TestBoxWrapper.resurrectCount, 0);
-
-      object.box.delete();
-      object.box.didDelete();
-      expect(TestSkDeletableMock.deleteCount, 1);
-      expect(TestBoxWrapper.resurrectCount, 0);
-      expect(object.box.isDeletedTemporarily, isTrue);
-      expect(object.box.isDeletedPermanently, isFalse);
-
-      object.dispose();
-      expect(object.box.isDeletedPermanently, isTrue);
-    });
-  });
-
-  group('$SynchronousSkiaObjectCache', () {
-    test('is initialized empty', () {
-      expect(SynchronousSkiaObjectCache(10), hasLength(0));
-    });
-
-    test('adds objects', () {
-      final SynchronousSkiaObjectCache cache = SynchronousSkiaObjectCache(2);
-      cache.add(TestSelfManagedObject());
-      expect(cache, hasLength(1));
-      cache.add(TestSelfManagedObject());
-      expect(cache, hasLength(2));
-    });
-
-    test('forbids adding the same object twice', () {
-      final SynchronousSkiaObjectCache cache = SynchronousSkiaObjectCache(2);
-      final TestSelfManagedObject object = TestSelfManagedObject();
-      cache.add(object);
-      expect(cache, hasLength(1));
-      expect(() => cache.add(object), throwsAssertionError);
-    });
-
-    void expectObjectInCache(
-      SynchronousSkiaObjectCache cache,
-      TestSelfManagedObject object,
-    ) {
-      expect(cache.debugContains(object), isTrue);
-      expect(object._skiaObject, isNotNull);
-    }
-
-    void expectObjectNotInCache(
-      SynchronousSkiaObjectCache cache,
-      TestSelfManagedObject object,
-    ) {
-      expect(cache.debugContains(object), isFalse);
-      expect(object._skiaObject, isNull);
-    }
-
-    test('respects maximumSize', () {
-      final SynchronousSkiaObjectCache cache = SynchronousSkiaObjectCache(2);
-      final TestSelfManagedObject object1 = TestSelfManagedObject();
-      final TestSelfManagedObject object2 = TestSelfManagedObject();
-      final TestSelfManagedObject object3 = TestSelfManagedObject();
-      final TestSelfManagedObject object4 = TestSelfManagedObject();
-
-      cache.add(object1);
-      expect(cache, hasLength(1));
-      expectObjectInCache(cache, object1);
-
-      cache.add(object2);
-      expect(cache, hasLength(2));
-      expectObjectInCache(cache, object1);
-      expectObjectInCache(cache, object2);
-
-      cache.add(object3);
-      expect(cache, hasLength(2));
-      expectObjectNotInCache(cache, object1);
-      expectObjectInCache(cache, object2);
-      expectObjectInCache(cache, object3);
-
-      cache.add(object4);
-      expect(cache, hasLength(2));
-      expectObjectNotInCache(cache, object1);
-      expectObjectNotInCache(cache, object2);
-      expectObjectInCache(cache, object3);
-      expectObjectInCache(cache, object4);
-    });
-
-    test('uses RLU strategy', () {
-      final SynchronousSkiaObjectCache cache = SynchronousSkiaObjectCache(2);
-      final TestSelfManagedObject object1 = TestSelfManagedObject();
-      final TestSelfManagedObject object2 = TestSelfManagedObject();
-      final TestSelfManagedObject object3 = TestSelfManagedObject();
-      final TestSelfManagedObject object4 = TestSelfManagedObject();
-
-      cache.add(object1);
-      expectObjectInCache(cache, object1);
-      cache.add(object2);
-      expectObjectInCache(cache, object2);
-      cache.add(object3);
-      expectObjectInCache(cache, object3);
-      expectObjectNotInCache(cache, object1);
-
-      cache.markUsed(object2);
-      cache.add(object4);
-      expectObjectInCache(cache, object2);
-      expectObjectNotInCache(cache, object3);
-      expectObjectInCache(cache, object4);
-    });
-  });
-}
-
-/// A simple class that wraps a [SkiaObjectBox].
-///
-/// Can be [clone]d such that the clones share the same ref counted box.
-class TestBoxWrapper implements StackTraceDebugger {
-  TestBoxWrapper() {
-    if (assertionsEnabled) {
-      _debugStackTrace = StackTrace.current;
-    }
-    box = SkiaObjectBox<TestBoxWrapper, TestSkDeletable>.resurrectable(
-        this, TestSkDeletable(), () {
-      resurrectCount += 1;
-      return TestSkDeletable();
-    });
-  }
-
-  TestBoxWrapper.cloneOf(this.box) {
-    if (assertionsEnabled) {
-      _debugStackTrace = StackTrace.current;
-    }
-    box.ref(this);
-  }
-
-  static int resurrectCount = 0;
-
-  @override
-  StackTrace get debugStackTrace => _debugStackTrace;
-  late StackTrace _debugStackTrace;
-
-  late SkiaObjectBox<TestBoxWrapper, TestSkDeletable> box;
-
-  void dispose() {
-    box.unref(this);
-  }
-
-  TestBoxWrapper clone() => TestBoxWrapper.cloneOf(box);
 }
 
 class TestSkDeletableMock {