@hyperframes/studio 0.8.56 → 0.8.58

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.
Files changed (91) hide show
  1. package/dist/assets/hyperframes-player-zGyOcwwh.js +464 -0
  2. package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
  3. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
  6. package/dist/index.d.ts +124 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15686 -14882
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +4 -0
  13. package/src/components/EditorShell.tsx +3 -1
  14. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  15. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +0 -15
  17. package/src/components/editor/GridOverlay.tsx +12 -23
  18. package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  20. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  21. package/src/components/editor/PreviewGuides.tsx +8 -10
  22. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  23. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  24. package/src/components/editor/SnapToolbar.test.tsx +25 -12
  25. package/src/components/editor/SnapToolbar.tsx +30 -59
  26. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  27. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  28. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  29. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  30. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  31. package/src/components/editor/useVolumeAutomation.ts +57 -16
  32. package/src/components/nle/NLEContext.tsx +48 -6
  33. package/src/components/nle/NLEPreview.test.ts +67 -1
  34. package/src/components/nle/NLEPreview.tsx +88 -44
  35. package/src/components/nle/PreviewOverlays.tsx +4 -16
  36. package/src/components/nle/PreviewPane.tsx +17 -2
  37. package/src/components/nle/previewReadOnly.test.tsx +1 -0
  38. package/src/components/nle/useCompositionStack.ts +1 -1
  39. package/src/hooks/useCompositionDimensions.test.tsx +37 -0
  40. package/src/hooks/useCompositionDimensions.ts +17 -1
  41. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  42. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  43. package/src/hooks/useFileTree.test.tsx +56 -0
  44. package/src/hooks/useFileTree.ts +8 -1
  45. package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
  46. package/src/hooks/useSdkSession.ts +77 -14
  47. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  48. package/src/hooks/useServerConnection.ts +81 -8
  49. package/src/index.ts +21 -1
  50. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  51. package/src/player/components/Player.test.ts +175 -13
  52. package/src/player/components/Player.tsx +58 -2
  53. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  54. package/src/player/components/Timeline.tsx +23 -572
  55. package/src/player/components/TimelineCanvas.tsx +9 -30
  56. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  57. package/src/player/components/TimelineOverlays.test.ts +67 -32
  58. package/src/player/components/TimelineOverlays.tsx +46 -107
  59. package/src/player/components/TimelineProvider.tsx +191 -0
  60. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  61. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  62. package/src/player/components/testRootHarness.ts +18 -0
  63. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  64. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  65. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  66. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  67. package/src/player/components/useTimelineProviderState.tsx +583 -0
  68. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  69. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  70. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  71. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  72. package/src/player/hooks/usePlayerHandle.ts +94 -0
  73. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  74. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  75. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  78. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  79. package/src/player/index.ts +7 -0
  80. package/src/telemetry/events.test.ts +18 -0
  81. package/src/telemetry/events.ts +10 -0
  82. package/src/telemetry/system.test.ts +49 -0
  83. package/src/telemetry/system.ts +6 -0
  84. package/src/telemetry/tabId.test.ts +43 -0
  85. package/src/telemetry/tabId.ts +44 -0
  86. package/src/utils/previewCompositionSize.ts +26 -0
  87. package/src/utils/reloadDebug.ts +2 -4
  88. package/src/utils/sourcePatcher.ts +1 -1
  89. package/src/utils/studioTelemetry.test.ts +14 -0
  90. package/src/utils/studioTelemetry.ts +4 -0
  91. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -54,26 +54,39 @@ afterEach(() => {
54
54
  document.body.innerHTML = "";
55
55
  });
56
56
 
57
- async function mountPlayer() {
57
+ type PlayerProps = Parameters<typeof Player>[0];
58
+
59
+ async function mountPlayer(props: Partial<PlayerProps> = {}) {
58
60
  const host = document.createElement("div");
59
61
  document.body.append(host);
60
62
  root = createRoot(host);
61
- await act(async () => {
62
- root?.render(
63
- createElement(Player, {
64
- directUrl: "/api/projects/demo/preview",
65
- onLoad: vi.fn(),
66
- suppressLoadingOverlay: true,
67
- }),
68
- );
69
- await Promise.resolve();
70
- });
63
+ const render = () =>
64
+ act(async () => {
65
+ root?.render(
66
+ createElement(Player, {
67
+ directUrl: "/api/projects/demo/preview",
68
+ onLoad: vi.fn(),
69
+ suppressLoadingOverlay: true,
70
+ ...props,
71
+ }),
72
+ );
73
+ await Promise.resolve();
74
+ });
75
+ await render();
71
76
 
72
77
  const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
73
78
  if (!player) throw new Error("player did not mount");
74
- return { host, player };
79
+ const rerender = (next: Partial<PlayerProps>) => {
80
+ Object.assign(props, next);
81
+ return render();
82
+ };
83
+ return { host, player, rerender };
75
84
  }
76
85
 
86
+ const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
87
+
88
+ const flushEffects = () => act(async () => await Promise.resolve());
89
+
77
90
  function createAudioIframe() {
78
91
  const iframe = document.createElement("iframe");
79
92
  document.body.appendChild(iframe);
@@ -123,6 +136,7 @@ describe("preview errors", () => {
123
136
  "player:click",
124
137
  "player:shadertransitionstate",
125
138
  "player:ready",
139
+ "player:painted",
126
140
  "player:error",
127
141
  ]) {
128
142
  expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
@@ -131,7 +145,10 @@ describe("preview errors", () => {
131
145
  });
132
146
 
133
147
  it("retries a failed preview with a fresh player URL", async () => {
134
- const { host, player } = await mountPlayer();
148
+ const onPainted = vi.fn();
149
+ const { host, player } = await mountPlayer({ onPainted });
150
+
151
+ act(() => void player.dispatchEvent(new Event("painted")));
135
152
 
136
153
  act(() => {
137
154
  player.dispatchEvent(
@@ -152,6 +169,151 @@ describe("preview errors", () => {
152
169
  const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
153
170
  expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
154
171
  expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
172
+
173
+ act(() => void player.dispatchEvent(new Event("painted")));
174
+ expect(onPainted.mock.calls.map(([details]) => details.loadId)).toEqual([1, 2]);
175
+ });
176
+ });
177
+
178
+ describe("callbacks after the player is already mounted", () => {
179
+ it("runs the latest onLoad on a later load, not the one captured at mount", async () => {
180
+ const first = vi.fn();
181
+ const second = vi.fn();
182
+ const { player, rerender } = await mountPlayer({ onLoad: first });
183
+ await rerender({ onLoad: second });
184
+
185
+ act(
186
+ () => void (player as TestHyperframesPlayer).iframeElement.dispatchEvent(new Event("load")),
187
+ );
188
+
189
+ expect(second).toHaveBeenCalledTimes(1);
190
+ expect(first).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("reports the preview error cause", async () => {
194
+ const onPreviewError = vi.fn();
195
+ const { player } = await mountPlayer({ onPreviewError });
196
+ act(() => {
197
+ player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
198
+ });
199
+ expect(onPreviewError).toHaveBeenCalledWith("boom");
200
+ });
201
+ });
202
+
203
+ describe("ready to show", () => {
204
+ const loadAndReady = (player: TestHyperframesPlayer) =>
205
+ act(() => {
206
+ player.iframeElement.dispatchEvent(new Event("load"));
207
+ player.dispatchEvent(new Event("ready"));
208
+ });
209
+ const shaderState = (player: TestHyperframesPlayer, loading: boolean) =>
210
+ act(() => {
211
+ player.dispatchEvent(
212
+ new CustomEvent("shadertransitionstate", {
213
+ detail: { state: { loading, ready: !loading } },
214
+ }),
215
+ );
216
+ });
217
+
218
+ const painted = (player: TestHyperframesPlayer) =>
219
+ act(() => void player.dispatchEvent(new Event("painted")));
220
+
221
+ it("waits two animation frames before notifying that the preview can show", async () => {
222
+ const callbacks: FrameRequestCallback[] = [];
223
+ const requestAnimationFrame = vi
224
+ .spyOn(window, "requestAnimationFrame")
225
+ .mockImplementation((callback) => {
226
+ callbacks.push(callback);
227
+ return callbacks.length;
228
+ });
229
+ try {
230
+ const onReadyToShowChange = vi.fn();
231
+ const { player } = await mountPlayer({ onReadyToShowChange });
232
+ const el = player as TestHyperframesPlayer;
233
+
234
+ loadAndReady(el);
235
+ painted(el);
236
+ await flushEffects();
237
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
238
+ expect(callbacks).toHaveLength(1);
239
+
240
+ act(() => callbacks.shift()?.(0));
241
+ await flushEffects();
242
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
243
+ expect(callbacks).toHaveLength(1);
244
+
245
+ act(() => callbacks.shift()?.(16));
246
+ await flushEffects();
247
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
248
+ } finally {
249
+ requestAnimationFrame.mockRestore();
250
+ }
251
+ });
252
+
253
+ it("promotes only once the player reports painted, not at ready or assetsready", async () => {
254
+ const onReadyToShowChange = vi.fn();
255
+ const { player } = await mountPlayer({ onReadyToShowChange });
256
+ const el = player as TestHyperframesPlayer;
257
+
258
+ loadAndReady(el);
259
+ await twoFrames();
260
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
261
+
262
+ act(() => void el.dispatchEvent(new Event("assetsready")));
263
+ await twoFrames();
264
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
265
+
266
+ painted(el);
267
+ await twoFrames();
268
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
269
+
270
+ act(() => void el.iframeElement.dispatchEvent(new Event("load")));
271
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
272
+ });
273
+
274
+ it("reports the document start time with the painted iframe", async () => {
275
+ const onPainted = vi.fn();
276
+ const now = vi.spyOn(performance, "now").mockReturnValue(100);
277
+ const { player } = await mountPlayer({ onPainted });
278
+
279
+ painted(player as TestHyperframesPlayer);
280
+
281
+ expect(onPainted).toHaveBeenCalledWith({
282
+ iframe: (player as TestHyperframesPlayer).iframeElement,
283
+ startedAt: 100,
284
+ loadId: 1,
285
+ });
286
+ now.mockRestore();
287
+ });
288
+
289
+ it("holds while the shader transition loader is up and fires once it clears", async () => {
290
+ const onReadyToShowChange = vi.fn();
291
+ const { player } = await mountPlayer({ onReadyToShowChange });
292
+ const el = player as TestHyperframesPlayer;
293
+
294
+ shaderState(el, true);
295
+ loadAndReady(el);
296
+ shaderState(el, true);
297
+ await twoFrames();
298
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
299
+
300
+ shaderState(el, false);
301
+ painted(el);
302
+ await twoFrames();
303
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
304
+
305
+ shaderState(el, true);
306
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
307
+ });
308
+
309
+ it("does not fire for a document that failed to load", async () => {
310
+ const onReadyToShowChange = vi.fn();
311
+ const { player } = await mountPlayer({ onReadyToShowChange });
312
+ act(() => {
313
+ player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
314
+ });
315
+ await twoFrames();
316
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
155
317
  });
156
318
  });
157
319
 
@@ -26,7 +26,11 @@ interface PlayerProps {
26
26
  projectId?: string;
27
27
  directUrl?: string;
28
28
  onLoad: () => void;
29
+ /** Fires once the loaded document is painted and every loader (shader, assets) has cleared. */
30
+ onReadyToShowChange?: (ready: boolean) => void;
31
+ onPreviewError?: (message: string) => void;
29
32
  onCompositionLoadingChange?: (loading: boolean) => void;
33
+ onPainted?: (details: { iframe: HTMLIFrameElement; startedAt: number; loadId: number }) => void;
30
34
  portrait?: boolean;
31
35
  style?: React.CSSProperties;
32
36
  suppressLoadingOverlay?: boolean;
@@ -134,7 +138,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
134
138
  projectId,
135
139
  directUrl,
136
140
  onLoad,
141
+ onReadyToShowChange,
142
+ onPreviewError,
137
143
  onCompositionLoadingChange,
144
+ onPainted,
138
145
  portrait,
139
146
  style,
140
147
  suppressLoadingOverlay,
@@ -146,13 +153,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
146
153
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
147
154
  const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
148
155
  const retryPreviewRef = useRef<(() => void) | null>(null);
156
+ const loadStartedAtRef = useRef(0);
157
+ const loadIdRef = useRef(0);
149
158
  const retryCountRef = useRef(0);
159
+ // Read at call time: this element outlives its first props (a shadow preview is
160
+ // promoted in place), so mount-time closures would go stale.
161
+ const onLoadRef = useRef(onLoad);
162
+ onLoadRef.current = onLoad;
163
+ const onReadyToShowChangeRef = useRef(onReadyToShowChange);
164
+ onReadyToShowChangeRef.current = onReadyToShowChange;
165
+ const onPreviewErrorRef = useRef(onPreviewError);
166
+ onPreviewErrorRef.current = onPreviewError;
167
+ const [loaded, setLoaded] = useState(false);
150
168
  const [assetsLoading, setAssetsLoading] = useState(false);
151
169
  const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
152
170
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
153
171
  const [assetWaitLong, setAssetWaitLong] = useState(false);
154
172
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
155
173
  const [compositionLoading, setCompositionLoading] = useState(true);
174
+ const [painted, setPainted] = useState(false);
156
175
  const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
157
176
  const [previewError, setPreviewError] = useState<string | null>(null);
158
177
 
@@ -194,6 +213,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
194
213
  retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
195
214
  setPreviewError(null);
196
215
  setCompositionLoading(true);
216
+ loadStartedAtRef.current = performance.now();
217
+ loadIdRef.current += 1;
197
218
  player.setAttribute("src", retryUrl.pathname + retryUrl.search);
198
219
  };
199
220
  retryPreviewRef.current = retryPreview;
@@ -207,15 +228,23 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
207
228
  setPreviewError(null);
208
229
  setCompositionLoading(false);
209
230
  };
231
+ const handlePainted = () => {
232
+ setPainted(true);
233
+ onPainted?.({ iframe, startedAt: loadStartedAtRef.current, loadId: loadIdRef.current });
234
+ };
210
235
  const handleError = (event: Event) => {
211
- setPreviewError(readPreviewErrorMessage(event));
236
+ const message = readPreviewErrorMessage(event);
237
+ onPreviewErrorRef.current?.(message);
238
+ setPreviewError(message);
212
239
  setCompositionLoading(false);
213
240
  };
214
241
  const handleLoad = () => {
215
242
  loadCountRef.current++;
243
+ setLoaded(true);
216
244
  setPreviewError(null);
217
245
  setShaderTransitionLoading(false);
218
246
  setCompositionLoading(true);
247
+ setPainted(false);
219
248
  // Reveal animation on reload (hot-reload, composition switch)
220
249
  if (loadCountRef.current > 1) {
221
250
  container.classList.remove("preview-revealing");
@@ -224,7 +253,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
224
253
  const onEnd = () => container.classList.remove("preview-revealing");
225
254
  container.addEventListener("animationend", onEnd, { once: true });
226
255
  }
227
- onLoad();
256
+ onLoadRef.current();
228
257
 
229
258
  // Show a loading overlay until every `<video>`/`<audio>` and Lottie
230
259
  // asset is ready. Without this users can click play before audio has
@@ -274,6 +303,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
274
303
  player.addEventListener("click", preventToggle, { capture: true });
275
304
  player.addEventListener("shadertransitionstate", handleShaderTransitionState);
276
305
  player.addEventListener("ready", handleReady);
306
+ player.addEventListener("painted", handlePainted);
277
307
  player.addEventListener("error", handleError);
278
308
 
279
309
  // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
@@ -291,6 +321,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
291
321
  player.style.height = "100%";
292
322
  player.style.display = "block";
293
323
  player.style.background = "transparent";
324
+ loadStartedAtRef.current = performance.now();
325
+ loadIdRef.current += 1;
294
326
  player.setAttribute("src", src);
295
327
  container.appendChild(player);
296
328
 
@@ -313,6 +345,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
313
345
  player.removeEventListener("click", preventToggle, { capture: true });
314
346
  player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
315
347
  player.removeEventListener("ready", handleReady);
348
+ player.removeEventListener("painted", handlePainted);
316
349
  player.removeEventListener("error", handleError);
317
350
  if (assetPollRef.current) clearInterval(assetPollRef.current);
318
351
  assetPollRef.current = null;
@@ -394,6 +427,29 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
394
427
  setAssetsLoading(false);
395
428
  };
396
429
 
430
+ const readyToShow =
431
+ loaded &&
432
+ painted &&
433
+ !compositionLoading &&
434
+ !shaderTransitionLoading &&
435
+ !assetsLoading &&
436
+ !previewError;
437
+ // `painted` means the player's own loader has finished fading; two frames of grace on top.
438
+ useEffect(() => {
439
+ if (!readyToShow) {
440
+ onReadyToShowChangeRef.current?.(false);
441
+ return;
442
+ }
443
+ let second = 0;
444
+ const first = requestAnimationFrame(() => {
445
+ second = requestAnimationFrame(() => onReadyToShowChangeRef.current?.(true));
446
+ });
447
+ return () => {
448
+ cancelAnimationFrame(first);
449
+ cancelAnimationFrame(second);
450
+ };
451
+ }, [readyToShow]);
452
+
397
453
  const showCompositionOverlay =
398
454
  !suppressLoadingOverlay &&
399
455
  !compositionOverlayDeferred &&
@@ -1,24 +1,18 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act, Profiler } from "react";
4
- import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { describe, expect, it, vi } from "vitest";
6
5
  import { ShortcutsPanel } from "./ShortcutsPanel";
6
+ import { createHappyDomRootHarness } from "./testRootHarness";
7
7
 
8
8
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
9
 
10
- const roots: Root[] = [];
11
-
12
- afterEach(() => {
13
- for (const root of roots.splice(0)) act(() => root.unmount());
14
- document.body.innerHTML = "";
15
- });
10
+ const { mount } = createHappyDomRootHarness();
16
11
 
17
12
  function renderPanel(onRender = vi.fn()) {
18
13
  const host = document.createElement("div");
19
14
  document.body.append(host);
20
- const root = createRoot(host);
21
- roots.push(root);
15
+ const root = mount(host);
22
16
  act(() => {
23
17
  root.render(
24
18
  <Profiler id="shortcuts-panel" onRender={onRender}>