@reearth/core 0.0.7-alpha.66 → 0.0.7-alpha.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reearth/core",
3
- "version": "0.0.7-alpha.66",
3
+ "version": "0.0.7-alpha.68",
4
4
  "author": "Re:Earth contributors <community@reearth.io>",
5
5
  "license": "Apache-2.0",
6
6
  "description": "A library that abstracts a map engine as one common API.",
@@ -1,10 +1,44 @@
1
1
  import { renderHook } from "@testing-library/react";
2
2
  import { UrlTemplateImageryProvider } from "cesium";
3
- import { expect, test, vi } from "vitest";
3
+ import { expect, test, vi, beforeEach } from "vitest";
4
4
 
5
5
  import type { CustomProviderConfig } from "../../../Map/types/customProvider";
6
6
 
7
- import { type Tile, useImageryProviders } from "./Imagery";
7
+ import ImageryLayers, { type Tile, useImageryProviders } from "./Imagery";
8
+
9
+ // Mock Cesium scene and imageryLayerCollection for ImageryLayers component tests
10
+ const mockAdd = vi.fn();
11
+ const mockRemove = vi.fn();
12
+ const mockContains = vi.fn(() => true);
13
+ const mockIndexOf = vi.fn(() => 0);
14
+ const mockRequestRender = vi.fn();
15
+
16
+ const mockImageryLayerCollection = {
17
+ add: mockAdd,
18
+ remove: mockRemove,
19
+ contains: mockContains,
20
+ indexOf: mockIndexOf,
21
+ };
22
+
23
+ const mockScene = {
24
+ requestRender: mockRequestRender,
25
+ isDestroyed: () => false,
26
+ };
27
+
28
+ vi.mock("resium", () => ({
29
+ useCesium: () => ({
30
+ imageryLayerCollection: mockImageryLayerCollection,
31
+ scene: mockScene,
32
+ }),
33
+ }));
34
+
35
+ beforeEach(() => {
36
+ mockAdd.mockClear();
37
+ mockRemove.mockClear();
38
+ mockContains.mockClear();
39
+ mockIndexOf.mockClear();
40
+ mockRequestRender.mockClear();
41
+ });
8
42
 
9
43
  test("useImageryProviders", () => {
10
44
  const provider = vi.fn(({ url }: { url?: string } = {}): any => ({ hoge: url }));
@@ -153,3 +187,173 @@ test("useImageryProviders", () => {
153
187
  typedRerender({ tiles: [] });
154
188
  expect(result.current.providers).toEqual({});
155
189
  });
190
+
191
+ test("ImageryLayers should not re-render when tiles array reference changes but content is the same", async () => {
192
+ const tiles: Tile[] = [{ id: "1", type: "open_street_map", opacity: 0.8 }];
193
+
194
+ const { rerender } = renderHook(
195
+ ({ tiles }: { tiles: Tile[] }) => {
196
+ return ImageryLayers({
197
+ tiles,
198
+ cesiumIonAccessToken: undefined,
199
+ customProvider: undefined,
200
+ onTilesChange: undefined,
201
+ });
202
+ },
203
+ {
204
+ initialProps: { tiles },
205
+ },
206
+ );
207
+
208
+ // Wait for initial render to complete
209
+ await new Promise(resolve => setTimeout(resolve, 0));
210
+
211
+ // Clear mock calls from initial render
212
+ mockAdd.mockClear();
213
+ mockRemove.mockClear();
214
+
215
+ // Re-render with a NEW tiles array reference but SAME content
216
+ const newTilesArraySameContent: Tile[] = [{ id: "1", type: "open_street_map", opacity: 0.8 }];
217
+
218
+ rerender({ tiles: newTilesArraySameContent });
219
+
220
+ // Wait for any effects to run
221
+ await new Promise(resolve => setTimeout(resolve, 0));
222
+
223
+ // Effect should NOT have run again - no layers should be removed or added
224
+ expect(mockRemove).not.toHaveBeenCalled();
225
+ expect(mockAdd).not.toHaveBeenCalled();
226
+
227
+ // Clear mocks
228
+ mockAdd.mockClear();
229
+ mockRemove.mockClear();
230
+
231
+ // Re-render with ONLY opacity changed
232
+ const opacityChangedTiles: Tile[] = [
233
+ { id: "1", type: "open_street_map", opacity: 0.5 }, // only opacity changed
234
+ ];
235
+
236
+ rerender({ tiles: opacityChangedTiles });
237
+
238
+ // Wait for effects to run
239
+ await new Promise(resolve => setTimeout(resolve, 0));
240
+
241
+ // With our optimization, when ONLY opacity changes, the layer is NOT recreated
242
+ // The layer.alpha property is updated directly without removing/adding the layer
243
+ expect(mockRemove).not.toHaveBeenCalled();
244
+ expect(mockAdd).not.toHaveBeenCalled();
245
+
246
+ // Clear mocks
247
+ mockAdd.mockClear();
248
+ mockRemove.mockClear();
249
+
250
+ // Re-render with DIFFERENT tile type (requires layer recreation)
251
+ const differentTypeTiles: Tile[] = [
252
+ { id: "1", type: "stamen_watercolor", opacity: 0.5 }, // type changed
253
+ ];
254
+
255
+ rerender({ tiles: differentTypeTiles });
256
+
257
+ // Wait for effects to run
258
+ await new Promise(resolve => setTimeout(resolve, 0));
259
+
260
+ // When provider changes (type, url, etc.), layer IS recreated
261
+ expect(mockRemove).toHaveBeenCalled();
262
+ expect(mockAdd).toHaveBeenCalled();
263
+ });
264
+
265
+ test("ImageryLayers should optimize opacity changes without recreating layers", async () => {
266
+ const tiles: Tile[] = [{ id: "1", type: "open_street_map", opacity: 1.0 }];
267
+
268
+ const { rerender } = renderHook(
269
+ ({ tiles }: { tiles: Tile[] }) => {
270
+ return ImageryLayers({
271
+ tiles,
272
+ cesiumIonAccessToken: undefined,
273
+ customProvider: undefined,
274
+ onTilesChange: undefined,
275
+ });
276
+ },
277
+ {
278
+ initialProps: { tiles },
279
+ },
280
+ );
281
+
282
+ // Wait for initial render
283
+ await new Promise(resolve => setTimeout(resolve, 0));
284
+
285
+ // Clear initial render calls
286
+ mockAdd.mockClear();
287
+ mockRemove.mockClear();
288
+
289
+ // Change opacity multiple times
290
+ for (const opacity of [0.8, 0.6, 0.4, 0.2]) {
291
+ rerender({ tiles: [{ id: "1", type: "open_street_map", opacity }] });
292
+ await new Promise(resolve => setTimeout(resolve, 0));
293
+ }
294
+
295
+ // After multiple opacity changes, layers should NEVER be removed/added
296
+ // The optimization updates layer.alpha directly
297
+ expect(mockRemove).not.toHaveBeenCalled();
298
+ expect(mockAdd).not.toHaveBeenCalled();
299
+ });
300
+
301
+ test("ImageryLayers should recreate layer when customProvider changes even if tile properties are same", async () => {
302
+ // Use a custom provider that we can change
303
+ const tiles: Tile[] = [{ id: "1", type: "my_custom", opacity: 0.8 }];
304
+
305
+ const customProvider1: CustomProviderConfig = {
306
+ imagery: {
307
+ providers: [
308
+ {
309
+ id: "my_custom",
310
+ url: "https://tiles1.example.com/{z}/{x}/{y}.png",
311
+ credit: "© Example 1",
312
+ },
313
+ ],
314
+ },
315
+ };
316
+
317
+ const customProvider2: CustomProviderConfig = {
318
+ imagery: {
319
+ providers: [
320
+ {
321
+ id: "my_custom",
322
+ url: "https://tiles2.example.com/{z}/{x}/{y}.png", // Different URL
323
+ credit: "© Example 2",
324
+ },
325
+ ],
326
+ },
327
+ };
328
+
329
+ const { rerender } = renderHook(
330
+ ({ tiles, customProvider }: { tiles: Tile[]; customProvider?: CustomProviderConfig }) => {
331
+ return ImageryLayers({
332
+ tiles,
333
+ cesiumIonAccessToken: undefined,
334
+ customProvider,
335
+ onTilesChange: undefined,
336
+ });
337
+ },
338
+ {
339
+ initialProps: { tiles, customProvider: customProvider1 },
340
+ },
341
+ );
342
+
343
+ // Wait for initial render
344
+ await new Promise(resolve => setTimeout(resolve, 0));
345
+
346
+ // Clear initial render calls
347
+ mockAdd.mockClear();
348
+ mockRemove.mockClear();
349
+
350
+ // Change the customProvider (this creates a new provider with different URL)
351
+ // Tile properties stay the same
352
+ rerender({ tiles, customProvider: customProvider2 });
353
+ await new Promise(resolve => setTimeout(resolve, 0));
354
+
355
+ // Layer SHOULD be recreated because the provider changed
356
+ // (even though tile properties didn't change)
357
+ expect(mockRemove).toHaveBeenCalled();
358
+ expect(mockAdd).toHaveBeenCalled();
359
+ });
@@ -53,20 +53,60 @@ export default function ImageryLayers({
53
53
  }: Props) {
54
54
  const { imageryLayerCollection, scene } = useCesium();
55
55
 
56
+ // Create a stable tiles reference that only changes when the content actually changes
57
+ // Normalize `undefined` to a stable empty array so `useImageryProviders` doesn't allocate a new default `[]` each render.
58
+ const emptyTiles = useMemo<Tile[]>(() => [], []);
59
+ const tilesValue = tiles ?? emptyTiles;
60
+
61
+ const prevTilesRef = useRef<Tile[]>(tilesValue);
62
+ const stableTiles = useMemo(() => {
63
+ if (!isEqual(prevTilesRef.current, tilesValue)) {
64
+ prevTilesRef.current = tilesValue;
65
+ }
66
+ return prevTilesRef.current;
67
+ }, [tilesValue]);
68
+
69
+ // Pass stableTiles to useImageryProviders to prevent providers from being recreated
70
+ // when tiles reference changes but content is the same
56
71
  const { providers } = useImageryProviders({
57
- tiles,
72
+ tiles: stableTiles,
58
73
  cesiumIonAccessToken,
59
74
  customProvider,
60
75
  presets: tilePresets,
61
76
  });
62
77
 
78
+ // Store layers keyed by tile ID to allow incremental updates
79
+ const layersRef = useRef<
80
+ Map<
81
+ string,
82
+ {
83
+ layer: CesiumImageryLayer;
84
+ tile: Tile;
85
+ provider: Promise<ImageryProvider> | ImageryProvider;
86
+ }
87
+ >
88
+ >(new Map());
89
+
63
90
  useEffect(() => {
64
91
  if (!imageryLayerCollection || !scene) return;
65
92
 
66
93
  let cancelled = false;
67
- const addedLayers: CesiumImageryLayer[] = [];
94
+ const currentTileIds = new Set(stableTiles?.map(t => t.id) || []);
95
+
96
+ // Remove layers for tiles that no longer exist
97
+ layersRef.current.forEach(({ layer }, id) => {
98
+ if (!currentTileIds.has(id)) {
99
+ if (imageryLayerCollection.contains(layer)) {
100
+ imageryLayerCollection.remove(layer);
101
+ }
102
+ layersRef.current.delete(id);
103
+ }
104
+ });
105
+
68
106
  // Track layers by their intended index to maintain order with async loading
69
- const layersByIndex: (CesiumImageryLayer | null)[] = new Array(tiles?.length || 0).fill(null);
107
+ const layersByIndex: (CesiumImageryLayer | null)[] = new Array(stableTiles?.length || 0).fill(
108
+ null,
109
+ );
70
110
 
71
111
  const reorderLayers = () => {
72
112
  if (cancelled || scene.isDestroyed()) return;
@@ -91,10 +131,50 @@ export default function ImageryLayers({
91
131
  scene.requestRender();
92
132
  };
93
133
 
94
- tiles?.forEach(({ id, zoomLevel, opacity, heatmap }, i) => {
134
+ stableTiles?.forEach((tile, i) => {
135
+ const { id, zoomLevel, opacity, heatmap } = tile;
136
+ const existing = layersRef.current.get(id);
95
137
  const providerOrPromise = providers[id]?.[3];
138
+
96
139
  if (!providerOrPromise) return;
97
140
 
141
+ // Check if we can reuse the existing layer with just an opacity update
142
+ if (existing) {
143
+ const prevTile = existing.tile;
144
+ const prevProvider = existing.provider;
145
+ // Must check provider reference - if provider changed (e.g. cesiumIonAccessToken updated),
146
+ // the layer needs to be recreated even if tile properties are the same
147
+ const canReuseLayer =
148
+ prevProvider === providerOrPromise &&
149
+ prevTile.type === tile.type &&
150
+ prevTile.url === tile.url &&
151
+ prevTile.cesiumIonAssetId === tile.cesiumIonAssetId &&
152
+ prevTile.zoomLevel?.[0] === zoomLevel?.[0] &&
153
+ prevTile.zoomLevel?.[1] === zoomLevel?.[1] &&
154
+ prevTile.heatmap === heatmap;
155
+
156
+ if (canReuseLayer) {
157
+ // Only opacity might have changed - update it directly if needed
158
+ const nextAlpha = opacity ?? 1;
159
+ if (existing.layer.alpha !== nextAlpha) {
160
+ existing.layer.alpha = nextAlpha;
161
+ scene.requestRender();
162
+ }
163
+ // Update stored tile and provider for next comparison
164
+ existing.tile = tile;
165
+ existing.provider = providerOrPromise;
166
+ layersByIndex[i] = existing.layer;
167
+ reorderLayers();
168
+ return;
169
+ }
170
+
171
+ // Need to recreate the layer - remove the old one
172
+ if (imageryLayerCollection.contains(existing.layer)) {
173
+ imageryLayerCollection.remove(existing.layer);
174
+ }
175
+ layersRef.current.delete(id);
176
+ }
177
+
98
178
  const doAdd = (provider: ImageryProvider) => {
99
179
  if (!provider || cancelled || scene.isDestroyed()) return;
100
180
  const layer = new CesiumImageryLayer(provider, {
@@ -110,14 +190,17 @@ export default function ImageryLayers({
110
190
  // Always append to avoid index out of bounds
111
191
  imageryLayerCollection.add(layer);
112
192
  layersByIndex[i] = layer;
113
- addedLayers.push(layer);
193
+ // Store the provider reference to detect when provider changes (e.g. token update)
194
+ layersRef.current.set(id, { layer, tile, provider: providerOrPromise });
114
195
 
115
196
  // Reorder all layers after each addition
116
197
  reorderLayers();
117
198
  };
118
199
 
119
200
  if (providerOrPromise instanceof Promise) {
120
- providerOrPromise.then(doAdd).catch(err => console.error("Failed to load imagery provider:", err));
201
+ providerOrPromise
202
+ .then(doAdd)
203
+ .catch(err => console.error("Failed to load imagery provider:", err));
121
204
  } else {
122
205
  doAdd(providerOrPromise);
123
206
  }
@@ -128,13 +211,24 @@ export default function ImageryLayers({
128
211
 
129
212
  return () => {
130
213
  cancelled = true;
131
- for (const layer of addedLayers) {
132
- if (!scene.isDestroyed() && imageryLayerCollection.contains(layer)) {
214
+ // Don't remove layers on cleanup - they'll be managed by the next render
215
+ // This prevents flickering when tiles change
216
+ };
217
+ }, [providers, stableTiles, imageryLayerCollection, scene, onTilesChange]);
218
+
219
+ // Cleanup all layers on unmount
220
+ useEffect(() => {
221
+ const layers = layersRef.current;
222
+ return () => {
223
+ if (!imageryLayerCollection || !scene || scene.isDestroyed()) return;
224
+ layers.forEach(({ layer }) => {
225
+ if (imageryLayerCollection.contains(layer)) {
133
226
  imageryLayerCollection.remove(layer);
134
227
  }
135
- }
228
+ });
229
+ layers.clear();
136
230
  };
137
- }, [providers, tiles, imageryLayerCollection, scene, onTilesChange]);
231
+ }, [imageryLayerCollection, scene]);
138
232
 
139
233
  return null;
140
234
  }