Repository: napari/napari

Issue:
Error when using asynchronous image rendering with linked layers
### 🐛 Bug Report

When using asynchronous image rendering with linked layers, I get the error shown below.

I only get the error when not explicitly specifying which attributes to share between layers in `link_layers`. Therefore I suspect that the problem comes from the attribute `loaded` (used during async image rendering) being shared between layers, although it shouldn't.

### 💡 Steps to Reproduce

1. Activate experimental option "Render Images Asynchronously".
2. 
```python
import dask.array as da
import napari
from napari.experimental import link_layers

viewer = napari.Viewer()

layers = [viewer.add_image([da.random.randint(0, 100, (3,3))], multiscale=True)
      for _ in range(2)]

link_layers(layers)
```
3. Zoom in and out

Error:
```
Traceback (most recent call last):
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/_qt/experimental/qt_chunk_receiver.py", line 61, in _slot
    self.events.gui_event(original_event=event)
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/utils/events/event.py", line 768, in __call__
    self._invoke_callback(cb, event if pass_event else None)
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/utils/events/event.py", line 806, in _invoke_callback
    _handle_exception(
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/utils/events/event.py", line 793, in _invoke_callback
    cb(event)
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/_qt/experimental/qt_chunk_receiver.py", line 123, in _on_chunk_loaded_gui
    layer.on_chunk_loaded(request)  # Pass the chunk to its layer.
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/layers/image/image.py", line 1032, in on_chunk_loaded
    self._on_data_loaded(data, sync=False)
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/layers/image/image.py", line 897, in _on_data_loaded
    self.events.loaded()
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/utils/events/event.py", line 768, in __call__
    self._invoke_callback(cb, event if pass_event else None)
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/utils/events/event.py", line 806, in _invoke_callback
    _handle_exception(
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/utils/events/event.py", line 793, in _invoke_callback
    cb(event)
  File "/Users/malbert/mambaforge/envs/napari_test/lib/python3.10/site-packages/napari/layers/utils/_link_layers.py", line 124, in setter
    setattr(l2, attr, new_val)
AttributeError: can't set attribute 'loaded'
```

### 💡 Expected Behavior

_No response_

### 🌎 Environment

napari: 0.4.18
Platform: macOS-12.6-arm64-arm-64bit
System: MacOS 12.6
Python: 3.10.12 | packaged by conda-forge | (main, Jun 23 2023, 22:41:52) [Clang 15.0.7 ]
Qt: 5.15.8
PyQt5: 5.15.9
NumPy: 1.26.0
SciPy: 1.11.3
Dask: 2023.10.0
VisPy: 0.12.2
magicgui: 0.8.0
superqt: 0.6.1
in-n-out: 0.1.9
app-model: 0.2.2
npe2: 0.7.2

OpenGL:
  - GL version:  2.1 Metal - 76.3
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 1440x900, scale 2.0
  - screen 2: resolution 1920x1080, scale 2.0
  - screen 3: resolution 1920x1080, scale 2.0

Settings path:
  - /Users/malbert/Library/Application Support/napari/napari_test_cedd16f321c09946af74e2bb09095696d358ee04/settings.yaml
Plugins:
  - napari: 0.4.18 (77 contributions)
  - napari-console: 0.0.8 (0 contributions)
  - napari-manual-transforms: 0.0.3 (2 contributions)
  - napari-svg: 0.1.10 (2 contributions)

### 💡 Additional Context

_No response_


Discussion:
While I think this experimental async image rendering has unfortunately been broken and unmaintained for a while, it seems like this might be a [more general problem with linking layers](https://github.com/napari/napari/blob/10775732425017c84d881d52391070c987ed6209/napari/layers/utils/_link_layers.py#L120). A derived property of a layer could also have an associated event, but is not settable, so should be skipped.

Also, if you're feeling brave, there are efforts on `main` to replace both simple async slicing (https://github.com/napari/napari/issues/4795) and more sophisticated tiled rendering in 2D and 3D (https://github.com/napari/napari/issues/5561) (though it's very possible the same bug may be present!).

Resolving patch:
diff --git a/napari/layers/utils/_link_layers.py b/napari/layers/utils/_link_layers.py
--- a/napari/layers/utils/_link_layers.py
+++ b/napari/layers/utils/_link_layers.py
@@ -130,7 +130,7 @@ def setter(event=None):
             setter.__qualname__ = f"set_{attr}_on_layer_{id(l2)}"
             return setter
 
-        # acually make the connection
+        # actually make the connection
         callback = _make_l2_setter()
         emitter_group = getattr(lay1.events, attribute)
         emitter_group.connect(callback)
@@ -191,7 +191,7 @@ def layers_linked(layers: Iterable[Layer], attributes: Iterable[str] = ()):
 def _get_common_evented_attributes(
     layers: Iterable[Layer],
     exclude: abc.Set[str] = frozenset(
-        ('thumbnail', 'status', 'name', 'data', 'extent')
+        ('thumbnail', 'status', 'name', 'data', 'extent', 'loaded')
     ),
     with_private=False,
 ) -> set[str]:


Test patch:
diff --git a/napari/layers/utils/_tests/test_link_layers.py b/napari/layers/utils/_tests/test_link_layers.py
--- a/napari/layers/utils/_tests/test_link_layers.py
+++ b/napari/layers/utils/_tests/test_link_layers.py
@@ -90,7 +90,7 @@ def test_removed_linked_target():
     l1.opacity = 0.5
     assert l1.opacity == l2.opacity == l3.opacity == 0.5
 
-    # if we delete layer3 we shouldn't get an error when updating otherlayers
+    # if we delete layer3 we shouldn't get an error when updating other layers
     del l3
     l1.opacity = 0.25
     assert l1.opacity == l2.opacity
@@ -142,7 +142,7 @@ def test_unlink_single_layer():
 
     link_layers([l1, l2, l3])
     assert len(l1.events.opacity.callbacks) == 2
-    unlink_layers([l1], ('opacity',))  # just unlink L1 opacicity from others
+    unlink_layers([l1], ('opacity',))  # just unlink L1 opacity from others
     assert len(l1.events.opacity.callbacks) == 0
     assert len(l2.events.opacity.callbacks) == 1
     assert len(l3.events.opacity.callbacks) == 1
@@ -161,3 +161,17 @@ def test_mode_recursion():
     l2 = layers.Points(None, name='l2')
     link_layers([l1, l2])
     l1.mode = 'add'
+
+
+def test_link_layers_with_images_then_loaded_not_linked():
+    """See https://github.com/napari/napari/issues/6372"""
+    l1 = layers.Image(np.zeros((5, 5)))
+    l2 = layers.Image(np.ones((5, 5)))
+    assert l1.loaded
+    assert l2.loaded
+
+    link_layers([l1, l2])
+    l1._set_loaded(False)
+
+    assert not l1.loaded
+    assert l2.loaded