@hyperframes/studio 0.8.56 → 0.8.57

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 (82) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
  6. package/dist/index.d.ts +95 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15573 -14835
  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 +66 -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 +40 -4
  33. package/src/components/nle/NLEPreview.test.ts +66 -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/hooks/useCompositionDimensions.test.tsx +38 -0
  39. package/src/hooks/useCompositionDimensions.ts +17 -1
  40. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  41. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  42. package/src/hooks/useFileTree.test.tsx +56 -0
  43. package/src/hooks/useFileTree.ts +8 -1
  44. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  45. package/src/hooks/useSdkSession.ts +46 -10
  46. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  47. package/src/hooks/useServerConnection.ts +81 -8
  48. package/src/index.ts +12 -0
  49. package/src/player/components/Player.test.ts +141 -13
  50. package/src/player/components/Player.tsx +58 -2
  51. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  52. package/src/player/components/Timeline.tsx +23 -572
  53. package/src/player/components/TimelineCanvas.tsx +9 -30
  54. package/src/player/components/TimelineOverlays.test.ts +67 -32
  55. package/src/player/components/TimelineOverlays.tsx +46 -107
  56. package/src/player/components/TimelineProvider.tsx +191 -0
  57. package/src/player/components/testRootHarness.ts +18 -0
  58. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  59. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  60. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  61. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  62. package/src/player/components/useTimelineProviderState.tsx +583 -0
  63. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  64. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  65. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  66. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  67. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  70. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  71. package/src/telemetry/events.test.ts +18 -0
  72. package/src/telemetry/events.ts +10 -0
  73. package/src/telemetry/system.test.ts +49 -0
  74. package/src/telemetry/system.ts +6 -0
  75. package/src/telemetry/tabId.test.ts +43 -0
  76. package/src/telemetry/tabId.ts +44 -0
  77. package/src/utils/previewCompositionSize.ts +26 -0
  78. package/src/utils/reloadDebug.ts +2 -4
  79. package/src/utils/sourcePatcher.ts +1 -1
  80. package/src/utils/studioTelemetry.test.ts +14 -0
  81. package/src/utils/studioTelemetry.ts +4 -0
  82. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -3,6 +3,7 @@ import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { expect, it } from "vitest";
5
5
  import { useCompositionDimensions } from "./useCompositionDimensions";
6
+ import { usePreviewIframeStore } from "../player/store/previewIframeStore";
6
7
 
7
8
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
9
 
@@ -45,3 +46,40 @@ it("accepts only the current preview frame and rejects foreign or missing sender
45
46
  foreign.remove();
46
47
  }
47
48
  });
49
+
50
+ it("re-reads the size from the promoted iframe's document", () => {
51
+ const host = document.createElement("div");
52
+ const a = document.createElement("iframe");
53
+ const b = document.createElement("iframe");
54
+ document.body.append(host, a, b);
55
+ const ref = { current: a };
56
+ const root = createRoot(host);
57
+ const setRoot = (frame: HTMLIFrameElement, width: number, height: number) => {
58
+ const el = frame.contentDocument!.createElement("div");
59
+ el.setAttribute("data-composition-id", "main");
60
+ el.setAttribute("data-width", String(width));
61
+ el.setAttribute("data-height", String(height));
62
+ frame.contentDocument!.body.append(el);
63
+ };
64
+ setRoot(a, 1920, 1080);
65
+ setRoot(b, 1080, 1920);
66
+ function Harness() {
67
+ return JSON.stringify(useCompositionDimensions(ref));
68
+ }
69
+ try {
70
+ act(() => usePreviewIframeStore.getState().setIframe(a));
71
+ act(() => root.render(<Harness />));
72
+ expect(host.textContent).toBe('{"width":1920,"height":1080}');
73
+ act(() => {
74
+ ref.current = b;
75
+ usePreviewIframeStore.getState().setIframe(b);
76
+ });
77
+ expect(host.textContent).toBe('{"width":1080,"height":1920}');
78
+ } finally {
79
+ act(() => root.unmount());
80
+ usePreviewIframeStore.setState({ iframe: null });
81
+ host.remove();
82
+ a.remove();
83
+ b.remove();
84
+ }
85
+ });
@@ -1,4 +1,6 @@
1
- import { useState, type RefObject } from "react";
1
+ import { useEffect, useState, type RefObject } from "react";
2
+ import { useLivePreviewIframe } from "../player/store/previewIframeStore";
3
+ import { readPreviewCompositionSize } from "../utils/previewCompositionSize";
2
4
  import { useMountEffect } from "./useMountEffect";
3
5
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
6
  import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -34,6 +36,20 @@ export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement
34
36
  null,
35
37
  );
36
38
 
39
+ const livePreviewIframe = useLivePreviewIframe();
40
+
41
+ // The runtime posts stage-size once per document, so a promoted reload's message was sent
42
+ // before this listener could accept it. The size is authored on the document: read it there.
43
+ useEffect(() => {
44
+ const dimensions = readPreviewCompositionSize(livePreviewIframe);
45
+ if (!dimensions) return;
46
+ setCompositionDimensions((prev) =>
47
+ prev && prev.width === dimensions.width && prev.height === dimensions.height
48
+ ? prev
49
+ : dimensions,
50
+ );
51
+ }, [livePreviewIframe]);
52
+
37
53
  useMountEffect(() => {
38
54
  const handleMessage = (e: MessageEvent) => {
39
55
  if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveOptimisticAttributeValue } from "./useDomEditAttributeCommits";
3
+
4
+ describe("resolveOptimisticAttributeValue", () => {
5
+ it("removes an HTML boolean attribute when the value is the literal string false", () => {
6
+ expect(resolveOptimisticAttributeValue("loop", "false")).toBeNull();
7
+ expect(resolveOptimisticAttributeValue("muted", "false")).toBeNull();
8
+ });
9
+
10
+ it("keeps the literal string false for a non-boolean attribute, matching what persist() writes", () => {
11
+ expect(resolveOptimisticAttributeValue("data-example", "false")).toBe("false");
12
+ });
13
+
14
+ it("always removes on null regardless of attribute", () => {
15
+ expect(resolveOptimisticAttributeValue("loop", null)).toBeNull();
16
+ expect(resolveOptimisticAttributeValue("data-example", null)).toBeNull();
17
+ });
18
+
19
+ it("passes any other value through unchanged", () => {
20
+ expect(resolveOptimisticAttributeValue("data-example", "true")).toBe("true");
21
+ expect(resolveOptimisticAttributeValue("loop", "true")).toBe("true");
22
+ });
23
+ });
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import type { PatchOperation } from "../utils/sourcePatcher";
2
+ import { HTML_BOOLEAN_ATTRIBUTES, type PatchOperation } from "../utils/sourcePatcher";
3
3
  import {
4
4
  findElementForSelection,
5
5
  getDomEditTargetKey,
@@ -55,6 +55,14 @@ function resolveFullAttrName(attr: string, prefixData: boolean | undefined): str
55
55
  return prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
56
56
  }
57
57
 
58
+ // Matches sourcePatcher's own boolean handling: "false" means "remove" only
59
+ // for HTML_BOOLEAN_ATTRIBUTES (loop, muted, ...), so the live preview node
60
+ // ends up holding what persist() actually writes to disk.
61
+ export function resolveOptimisticAttributeValue(attr: string, value: string | null): string | null {
62
+ if (value === null) return null;
63
+ return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
64
+ }
65
+
58
66
  function setOrRemovePreviewAttribute(
59
67
  el: HTMLElement,
60
68
  fullAttr: string,
@@ -393,8 +401,11 @@ export function useDomEditAttributeCommits({
393
401
  },
394
402
  apply: () => {
395
403
  if (!editedElement) return;
396
- const nextValue = value === null || value === "false" ? null : value;
397
- setOrRemovePreviewAttribute(editedElement, attr, nextValue);
404
+ setOrRemovePreviewAttribute(
405
+ editedElement,
406
+ attr,
407
+ resolveOptimisticAttributeValue(attr, value),
408
+ );
398
409
  },
399
410
  persist: () =>
400
411
  persistDomEditOperations(domEditSelection, [op], {
@@ -5,6 +5,12 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { useFileTree } from "./useFileTree";
7
7
 
8
+ vi.mock("../components/feedback/projectProvenance", () => ({
9
+ captureProjectProvenance: vi.fn(),
10
+ }));
11
+
12
+ import { captureProjectProvenance } from "../components/feedback/projectProvenance";
13
+
8
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
15
 
10
16
  let root: Root | null = null;
@@ -52,6 +58,56 @@ async function renderHarness(projectId: string): Promise<{ current: Handle | nul
52
58
  return handleRef;
53
59
  }
54
60
 
61
+ describe("useFileTree unresolved project", () => {
62
+ const provenance = vi.mocked(captureProjectProvenance);
63
+
64
+ afterEach(() => {
65
+ provenance.mockClear();
66
+ });
67
+
68
+ /** Render the tree for `projectId` against one canned response. */
69
+ async function renderWithResponse(
70
+ projectId: string,
71
+ build: () => Response,
72
+ ): Promise<{ current: Handle | null }> {
73
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => build());
74
+ const handleRef = await renderHarness(projectId);
75
+ fetchSpy.mockRestore();
76
+ return handleRef;
77
+ }
78
+
79
+ const notFound = () =>
80
+ new Response(JSON.stringify({ error: "not found" }), {
81
+ status: 404,
82
+ headers: { "content-type": "application/json" },
83
+ });
84
+ const emptyListing = () =>
85
+ new Response(JSON.stringify({ files: [], compositions: [] }), { status: 200 });
86
+
87
+ // An unresolvable project answers 404 with a JSON body. Without an `r.ok`
88
+ // check that parsed cleanly and took the SUCCESS branch, recording an empty
89
+ // tree and an empty provenance snapshot for a project that was never read —
90
+ // so a bug report filed from that tab described a project that does not
91
+ // exist. The tree itself stays empty either way; the provenance lie is the
92
+ // part that leaves the browser.
93
+ it("does not snapshot provenance for a project the server could not resolve", async () => {
94
+ const handleRef = await renderWithResponse("gone-project", notFound);
95
+
96
+ expect(handleRef.current?.compositions).toEqual([]);
97
+ expect(provenance).not.toHaveBeenCalled();
98
+ });
99
+
100
+ // The counterpart, and the reason the check above cannot be collapsed into
101
+ // "skip provenance whenever the tree is empty": a project with no
102
+ // compositions yet is a real, successful read and must still be snapshotted.
103
+ it("still snapshots provenance when a resolved project has no compositions", async () => {
104
+ const handleRef = await renderWithResponse("empty-project", emptyListing);
105
+
106
+ expect(handleRef.current?.compositions).toEqual([]);
107
+ expect(provenance).toHaveBeenCalledWith("empty-project", [], []);
108
+ });
109
+ });
110
+
55
111
  describe("useFileTree.refreshFileTree", () => {
56
112
  it("updates compositions, not just the raw file list, on refresh", async () => {
57
113
  let call = 0;
@@ -39,7 +39,14 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
39
39
  let cancelled = false;
40
40
  setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
41
41
  fetch(buildProjectApiPath(projectId))
42
- .then((r) => r.json())
42
+ // An unresolvable project answers 404 with a JSON body, so without this
43
+ // the error path parsed cleanly and the success branch below recorded an
44
+ // empty tree — and an empty provenance snapshot — for a project that was
45
+ // never read. Throwing hands it to the catch instead.
46
+ .then((r) => {
47
+ if (!r.ok) throw new Error(`tree fetch failed: ${r.status}`);
48
+ return r.json();
49
+ })
43
50
  .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
44
51
  if (cancelled) return;
45
52
  setFetched({
@@ -147,6 +147,86 @@ describe("useSdkSession ownership", () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe("useSdkSession unreachable project", () => {
151
+ beforeEach(() => {
152
+ openComposition.mockReset();
153
+ trackMock.mockClear();
154
+ class FakeEventSource {
155
+ addEventListener(): void {}
156
+ close(): void {}
157
+ }
158
+ vi.stubGlobal("EventSource", FakeEventSource);
159
+ });
160
+
161
+ afterEach(() => {
162
+ vi.unstubAllGlobals();
163
+ });
164
+
165
+ function probeHandle(projectId: string) {
166
+ const captured: { handle: SdkSessionHandle | null } = { handle: null };
167
+ function HandleProbe() {
168
+ captured.handle = useSdkSession(projectId, "index.html");
169
+ return null;
170
+ }
171
+ return { captured, HandleProbe };
172
+ }
173
+
174
+ const failedRead = (status: number) => async () =>
175
+ ({ ok: false, status, json: async () => ({}) }) as Response;
176
+
177
+ /** Render the hook for `projectId` against a stubbed read and let it settle. */
178
+ async function readOutcome(
179
+ projectId: string,
180
+ read: () => Promise<Response>,
181
+ ): Promise<{ captured: { handle: SdkSessionHandle | null }; unmount: () => Promise<void> }> {
182
+ vi.stubGlobal("fetch", vi.fn(read));
183
+ const { captured, HandleProbe } = probeHandle(projectId);
184
+ const root = createRoot(document.createElement("div"));
185
+ await act(async () => root.render(<HandleProbe />));
186
+ await flushAsyncEffects();
187
+ return { captured, unmount: () => act(async () => root.unmount()) };
188
+ }
189
+
190
+ // A 404 on the composition read is the one failure that says something about
191
+ // the PROJECT rather than the request: under the CLI host it means this
192
+ // Studio serves a different one. Every edit then fails silently, so the UI
193
+ // needs the id to explain that.
194
+ it("names the project when the read is a 404", async () => {
195
+ const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
196
+
197
+ expect(captured.handle?.unreachableProject).toBe("gone-project");
198
+ await unmount();
199
+ });
200
+
201
+ // A 500 says the request failed, not that the project is elsewhere. Claiming
202
+ // otherwise would tell a user their tab is pointed at the wrong project when
203
+ // the server is merely unwell.
204
+ it("stays quiet on a failure that says nothing about the project", async () => {
205
+ const { captured, unmount } = await readOutcome("project-a", failedRead(500));
206
+
207
+ expect(captured.handle?.unreachableProject).toBeNull();
208
+ await unmount();
209
+ });
210
+
211
+ it("clears the state once the project resolves", async () => {
212
+ const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
213
+ expect(captured.handle?.unreachableProject).toBe("gone-project");
214
+
215
+ openComposition.mockResolvedValue(fakeSession());
216
+ vi.stubGlobal(
217
+ "fetch",
218
+ vi.fn(async () => response("PROJECT_A")),
219
+ );
220
+ await act(async () => {
221
+ captured.handle?.forceReload();
222
+ });
223
+ await flushAsyncEffects();
224
+
225
+ expect(captured.handle?.unreachableProject).toBeNull();
226
+ await unmount();
227
+ });
228
+ });
229
+
150
230
  describe("useSdkSession unavailable telemetry", () => {
151
231
  beforeEach(() => {
152
232
  openComposition.mockReset();
@@ -25,6 +25,34 @@ type ProjectFileReadFailure =
25
25
 
26
26
  type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
27
 
28
+ /**
29
+ * Record a read that produced no usable content, and answer which project — if
30
+ * any — the failure identifies as unreachable.
31
+ *
32
+ * No SDK session follows a failed read, so EVERY cutover chokepoint takes the
33
+ * server path and emits nothing — the shadow never runs either. A broken read
34
+ * would otherwise be a silent, total SDK bypass, which is why this is recorded
35
+ * at all.
36
+ *
37
+ * Only a 404 identifies the *project*: the server answered, and its answer was
38
+ * that it does not serve this id. A 5xx, a dropped request, an unexpected body
39
+ * and an empty file all say nothing about which project the server serves, so
40
+ * none of them claim it. See `unreachableProject` on the handle.
41
+ *
42
+ * A function rather than three more conditions inline: the read callback it is
43
+ * called from is a long pre-existing async body already near the complexity
44
+ * threshold, and this branch is one coherent unit.
45
+ */
46
+ function reportReadFailure(read: ProjectFileReadFailure, projectId: string): string | null {
47
+ trackStudioEvent("sdk_session_unavailable", {
48
+ stage: "read",
49
+ reason: read.reason,
50
+ ...(read.reason === "http_error" ? { status: read.status } : {}),
51
+ });
52
+ if (read.reason !== "http_error") return null;
53
+ return read.status === 404 ? projectId : null;
54
+ }
55
+
28
56
  /**
29
57
  * Read a project file's content (optional read — a missing file is not an
30
58
  * error). Replaces the removed SDK http adapter's `read()` — the only thing
@@ -96,6 +124,19 @@ export interface SdkSessionHandle {
96
124
  * side of that path is covered by usePersistentEditHistory.test.ts.
97
125
  */
98
126
  forceReload: () => void;
127
+ /**
128
+ * Set when this server answered the composition read with a 404 for the
129
+ * project it was asked to open. In the CLI-embedded host — the only host
130
+ * that reports telemetry at all (`telemetry/policy.ts` suppresses Vite dev)
131
+ * — that does not mean the project is gone. It means this Studio is serving
132
+ * a *different* one; the project is untouched on disk. Either way every edit
133
+ * in this tab fails, and until now it failed silently.
134
+ *
135
+ * `null` for every other failure: a 500 or a dropped request says nothing
136
+ * about which project the server serves, and `absent_or_empty` /
137
+ * `missing_content` say nothing about the project at all.
138
+ */
139
+ unreachableProject: string | null;
99
140
  }
100
141
 
101
142
  interface SdkSessionOwner {
@@ -173,6 +214,7 @@ export function useSdkSession(
173
214
  const [reloadToken, setReloadToken] = useState(0);
174
215
  const reloadTokenRef = useRef(reloadToken);
175
216
  reloadTokenRef.current = reloadToken;
217
+ const [unreachableProject, setUnreachableProject] = useState<string | null>(null);
176
218
 
177
219
  useEffect(
178
220
  () =>
@@ -195,6 +237,7 @@ export function useSdkSession(
195
237
  if (previous) disposeSdkSession(previous.session);
196
238
 
197
239
  if (!projectId || !activeCompPath) {
240
+ setUnreachableProject(null);
198
241
  return () => {
199
242
  cancelled = true;
200
243
  };
@@ -211,17 +254,10 @@ export function useSdkSession(
211
254
  .then(async (read) => {
212
255
  if (cancelled) return;
213
256
  if (!read.ok) {
214
- // No SDK session follows, so EVERY cutover chokepoint below takes the
215
- // server path and emits nothing — the shadow never runs either. This
216
- // is the only place a missing session can originate, so a broken read
217
- // would otherwise be a silent, total SDK bypass.
218
- trackStudioEvent("sdk_session_unavailable", {
219
- stage: "read",
220
- reason: read.reason,
221
- ...(read.reason === "http_error" ? { status: read.status } : {}),
222
- });
257
+ setUnreachableProject(reportReadFailure(read, projectId));
223
258
  return;
224
259
  }
260
+ setUnreachableProject(null);
225
261
  const content = read.content;
226
262
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
227
263
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
@@ -316,5 +352,5 @@ export function useSdkSession(
316
352
  ownedSession.reloadToken === reloadToken
317
353
  ? ownedSession.session
318
354
  : null;
319
- return { session, publish, forceReload };
355
+ return { session, publish, forceReload, unreachableProject };
320
356
  }