mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(studio): reconcile external edits before reload (#2993)
* fix(studio): reconcile external edits before reload * fix(ci): retry transient workspace installs Make external reload retry behavior honest and isolate reload listeners. Remove the dead SDK timestamp parameter.
This commit is contained in:
+14
-14
@@ -129,7 +129,7 @@ jobs:
|
|||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: corepack prepare pnpm@10.17.1 --activate
|
- run: corepack prepare pnpm@10.17.1 --activate
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- run: bun run build
|
- run: bun run build
|
||||||
- run: bun run verify:packed-manifests
|
- run: bun run verify:packed-manifests
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- run: bun run lint
|
- run: bun run lint
|
||||||
|
|
||||||
# `fallow audit` runs dead-code + complexity + duplication analysis scoped to
|
# `fallow audit` runs dead-code + complexity + duplication analysis scoped to
|
||||||
@@ -182,7 +182,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- name: Run fallow audit
|
- name: Run fallow audit
|
||||||
id: audit
|
id: audit
|
||||||
# `bun install` above made `bunx fallow` resolve from node_modules, so
|
# `bun install` above made `bunx fallow` resolve from node_modules, so
|
||||||
@@ -238,7 +238,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- run: bun run format:check
|
- run: bun run format:check
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- run: bun run build
|
- run: bun run build
|
||||||
- run: bun run --filter '*' typecheck
|
- run: bun run --filter '*' typecheck
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- run: bun run test:scripts
|
- run: bun run test:scripts
|
||||||
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||||
- run: bun run --cwd packages/core build
|
- run: bun run --cwd packages/core build
|
||||||
@@ -314,7 +314,7 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends ffmpeg
|
sudo apt-get install -y --no-install-recommends ffmpeg
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||||
- run: bun run --cwd packages/core build
|
- run: bun run --cwd packages/core build
|
||||||
- run: bun run --filter @hyperframes/engine build
|
- run: bun run --filter @hyperframes/engine build
|
||||||
@@ -422,7 +422,7 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: runner.os != 'Windows'
|
if: runner.os != 'Windows'
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
run: bash scripts/ci/install-workspace-dependencies.sh --ignore-scripts
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted
|
run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted
|
||||||
@@ -437,7 +437,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
# Build workspace deps so the sdk's @hyperframes/parsers + core subpath
|
# Build workspace deps so the sdk's @hyperframes/parsers + core subpath
|
||||||
# imports resolve via the "node" export condition (dist) under vitest.
|
# imports resolve via the "node" export condition (dist) under vitest.
|
||||||
- run: bun run --filter '@hyperframes/parsers' build
|
- run: bun run --filter '@hyperframes/parsers' build
|
||||||
@@ -459,7 +459,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
# Runtime coverage now imports core modules that consume workspace
|
# Runtime coverage now imports core modules that consume workspace
|
||||||
# subpaths. Build their dist exports before Vitest resolves them.
|
# subpaths. Build their dist exports before Vitest resolves them.
|
||||||
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||||
@@ -480,7 +480,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
# Build workspace deps so the studio vite.config.ts (loaded by Node) can
|
# Build workspace deps so the studio vite.config.ts (loaded by Node) can
|
||||||
# resolve @hyperframes/core and @hyperframes/studio-server via the "node"
|
# resolve @hyperframes/core and @hyperframes/studio-server via the "node"
|
||||||
# export condition (dist).
|
# export condition (dist).
|
||||||
@@ -523,7 +523,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
# Same reason as studio-load-smoke: vite.config.ts is loaded by Node and
|
# Same reason as studio-load-smoke: vite.config.ts is loaded by Node and
|
||||||
# resolves the workspace packages through their "node" export condition.
|
# resolves the workspace packages through their "node" export condition.
|
||||||
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||||
@@ -634,7 +634,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- run: bun install --frozen-lockfile
|
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- run: bun run build
|
- run: bun run build
|
||||||
|
|
||||||
# Pack the CLI as a tarball (simulates what `npm publish` produces)
|
# Pack the CLI as a tarball (simulates what `npm publish` produces)
|
||||||
@@ -711,7 +711,7 @@ jobs:
|
|||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bash scripts/ci/install-workspace-dependencies.sh
|
||||||
- name: Build monorepo
|
- name: Build monorepo
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||||
"format:check": "oxfmt --check .",
|
"format:check": "oxfmt --check .",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
|
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
|
||||||
"test:skills": "node --test 'skills/**/*.test.mjs'",
|
"test:skills": "node --test 'skills/**/*.test.mjs'",
|
||||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||||
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
|
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
|
||||||
|
|||||||
+14
-14
@@ -4,6 +4,7 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|||||||
import { usePlayerStore } from "./player";
|
import { usePlayerStore } from "./player";
|
||||||
import { StudioOverlays } from "./components/StudioOverlays";
|
import { StudioOverlays } from "./components/StudioOverlays";
|
||||||
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
||||||
|
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
|
||||||
import { useCaptionStore } from "./captions/store";
|
import { useCaptionStore } from "./captions/store";
|
||||||
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
||||||
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
||||||
@@ -22,6 +23,7 @@ import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|||||||
import { useDomEditSession } from "./hooks/useDomEditSession";
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
||||||
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
||||||
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
|
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
|
||||||
|
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
|
||||||
import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
||||||
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
||||||
import { useClipboard } from "./hooks/useClipboard";
|
import { useClipboard } from "./hooks/useClipboard";
|
||||||
@@ -56,25 +58,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
|
|||||||
import { DomEditProvider } from "./contexts/DomEditContext";
|
import { DomEditProvider } from "./contexts/DomEditContext";
|
||||||
import { StudioSplash } from "./components/StudioSplash";
|
import { StudioSplash } from "./components/StudioSplash";
|
||||||
import { useServerConnection } from "./hooks/useServerConnection";
|
import { useServerConnection } from "./hooks/useServerConnection";
|
||||||
|
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
|
||||||
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
|
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
|
||||||
import {
|
import {
|
||||||
normalizeStudioCompositionPath,
|
normalizeStudioCompositionPath,
|
||||||
readStudioUrlStateFromWindow,
|
readStudioUrlStateFromWindow,
|
||||||
resolveMasterCompositionPath,
|
resolveMasterCompositionPath,
|
||||||
} from "./utils/studioUrlState";
|
} from "./utils/studioUrlState";
|
||||||
import { trackStudioSessionStart } from "./telemetry/events";
|
|
||||||
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
export function StudioApp() {
|
export function StudioApp() {
|
||||||
const { projectId, resolving, waitingForServer } = useServerConnection();
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
||||||
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
||||||
const viewModeValue = useViewModeState();
|
const viewModeValue = useViewModeState();
|
||||||
useEffect(() => {
|
useStudioSessionStart(projectId, resolving, waitingForServer);
|
||||||
if (resolving || waitingForServer) return;
|
|
||||||
if (hasFiredSessionStart()) return;
|
|
||||||
markSessionStartFired();
|
|
||||||
trackStudioSessionStart({ has_project: projectId != null });
|
|
||||||
}, [projectId, resolving, waitingForServer]);
|
|
||||||
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
||||||
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
||||||
() => initialUrlStateRef.current.activeCompPath == null,
|
() => initialUrlStateRef.current.activeCompPath == null,
|
||||||
@@ -130,7 +126,6 @@ export function StudioApp() {
|
|||||||
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
|
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
|
||||||
projectId,
|
projectId,
|
||||||
activeCompPath,
|
activeCompPath,
|
||||||
domEditSaveTimestampRef,
|
|
||||||
masterCompPath,
|
masterCompPath,
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -144,20 +139,24 @@ export function StudioApp() {
|
|||||||
setActiveCompPathHydrated(true);
|
setActiveCompPathHydrated(true);
|
||||||
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
|
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
|
||||||
const previewPersistence = usePreviewPersistence({
|
const previewPersistence = usePreviewPersistence({
|
||||||
projectId,
|
|
||||||
showToast,
|
showToast,
|
||||||
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
||||||
writeProjectFile: fileManager.writeProjectFile,
|
writeProjectFile: fileManager.writeProjectFile,
|
||||||
recordEdit: editHistory.recordEdit,
|
recordEdit: editHistory.recordEdit,
|
||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
activeCompPathRef,
|
activeCompPathRef,
|
||||||
domEditSaveTimestampRef,
|
|
||||||
reloadPreview: () => setRefreshKey((k) => k + 1),
|
reloadPreview: () => setRefreshKey((k) => k + 1),
|
||||||
|
});
|
||||||
|
const externalFileChanges = useStudioExternalFileChanges({
|
||||||
|
projectId,
|
||||||
|
activeCompPath,
|
||||||
|
masterCompPath,
|
||||||
|
fileManager,
|
||||||
|
previewPersistence,
|
||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
|
reloadPreview,
|
||||||
});
|
});
|
||||||
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
||||||
// Stable identity — what the ref indirection is for. An inline arrow re-created
|
|
||||||
// the memoized timeline handlers (it is in their deps) on every render.
|
|
||||||
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
|
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
|
||||||
const timelineEditing = useTimelineEditing({
|
const timelineEditing = useTimelineEditing({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -483,12 +482,13 @@ export function StudioApp() {
|
|||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{previewPersistence.domEditSaveQueuePaused && (
|
{previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
|
||||||
<SaveQueuePausedBanner
|
<SaveQueuePausedBanner
|
||||||
message={previewPersistence.domEditSaveQueuePaused}
|
message={previewPersistence.domEditSaveQueuePaused}
|
||||||
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
|
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ExternalFileConflictBanner coordinator={externalFileChanges} />
|
||||||
{viewModeValue.viewMode === "storyboard" && (
|
{viewModeValue.viewMode === "storyboard" && (
|
||||||
<StoryboardView
|
<StoryboardView
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function DesignPanelPromoteProvider({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
|
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
|
||||||
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
|
const handle = useSdkSession(projectId, targetPath);
|
||||||
const rawPersist = useVariablesPersist({
|
const rawPersist = useVariablesPersist({
|
||||||
...persistDeps,
|
...persistDeps,
|
||||||
sdkSession: handle.session,
|
sdkSession: handle.session,
|
||||||
|
|||||||
@@ -113,4 +113,30 @@ describe("ExternalFileConflictBanner", () => {
|
|||||||
|
|
||||||
await act(async () => root.unmount());
|
await act(async () => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not offer retry when a failed DOM edit has no recoverable source candidate", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
||||||
|
blocked: {
|
||||||
|
status: "failed",
|
||||||
|
generation: 1,
|
||||||
|
path: "index.html",
|
||||||
|
error: new Error("offline"),
|
||||||
|
payload: { path: "index.html", version: "v2", content: "external" },
|
||||||
|
studioContent: null,
|
||||||
|
recovered: false,
|
||||||
|
},
|
||||||
|
retry: vi.fn(async () => undefined),
|
||||||
|
useExternalFile: vi.fn(async () => undefined),
|
||||||
|
keepStudioFile: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
||||||
|
expect(document.body.textContent).not.toContain("Retry save");
|
||||||
|
expect(document.body.textContent).toContain("Discard Studio edits and reload file");
|
||||||
|
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function ExternalFileConflictBanner({
|
|||||||
Review or export Studio draft
|
Review or export Studio draft
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{failure && !failure.recovered && (
|
{failure && !failure.recovered && failure.studioContent != null && (
|
||||||
<button type="button" onClick={() => void coordinator.retry()} className="underline">
|
<button type="button" onClick={() => void coordinator.retry()} className="underline">
|
||||||
Retry save
|
Retry save
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("external file-change subscription ownership", () => {
|
||||||
|
it("has one subscriber instead of independent Preview and SDK listeners", () => {
|
||||||
|
const preview = readFileSync(new URL("./usePreviewPersistence.ts", import.meta.url), "utf8");
|
||||||
|
const sdk = readFileSync(new URL("./useSdkSession.ts", import.meta.url), "utf8");
|
||||||
|
const coordinator = readFileSync(
|
||||||
|
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preview).not.toContain('hot.on("hf:file-change"');
|
||||||
|
expect(sdk).not.toContain('hot.on("hf:file-change"');
|
||||||
|
expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { addExternalFileReloadListener, notifyExternalFileReload } from "./externalFileReloadBus";
|
||||||
|
|
||||||
|
describe("external file reload bus", () => {
|
||||||
|
it("lets the sole watcher reload every SDK owner of the changed path", () => {
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
const removeFirst = addExternalFileReloadListener(first);
|
||||||
|
const removeSecond = addExternalFileReloadListener(second);
|
||||||
|
|
||||||
|
notifyExternalFileReload("scenes/card.html");
|
||||||
|
expect(first).toHaveBeenCalledWith("scenes/card.html");
|
||||||
|
expect(second).toHaveBeenCalledWith("scenes/card.html");
|
||||||
|
|
||||||
|
removeFirst();
|
||||||
|
removeSecond();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates a broken listener so later SDK owners still reload", () => {
|
||||||
|
const broken = vi.fn(() => {
|
||||||
|
throw new Error("stale owner");
|
||||||
|
});
|
||||||
|
const healthy = vi.fn();
|
||||||
|
const removeBroken = addExternalFileReloadListener(broken);
|
||||||
|
const removeHealthy = addExternalFileReloadListener(healthy);
|
||||||
|
|
||||||
|
expect(() => notifyExternalFileReload("scenes/card.html")).not.toThrow();
|
||||||
|
expect(broken).toHaveBeenCalledWith("scenes/card.html");
|
||||||
|
expect(healthy).toHaveBeenCalledWith("scenes/card.html");
|
||||||
|
|
||||||
|
removeBroken();
|
||||||
|
removeHealthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
type ExternalFileReloadListener = (path: string) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<ExternalFileReloadListener>();
|
||||||
|
|
||||||
|
export function addExternalFileReloadListener(listener: ExternalFileReloadListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyExternalFileReload(path: string): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
try {
|
||||||
|
listener(path);
|
||||||
|
} catch {
|
||||||
|
// A stale SDK owner must not prevent sibling owners from reloading.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||||
|
import { drainStudioSaveQueues } from "./usePreviewPersistence";
|
||||||
|
|
||||||
|
describe("drainStudioSaveQueues", () => {
|
||||||
|
it("does not erase a pending-field conflict with a clean DOM queue", async () => {
|
||||||
|
const conflict = new StudioFileConflictError({
|
||||||
|
filePath: "index.html",
|
||||||
|
currentVersion: "v2",
|
||||||
|
currentContent: "external",
|
||||||
|
attemptedContent: "studio",
|
||||||
|
});
|
||||||
|
const waitForDomQueue = vi.fn(async () => ({ status: "clean" as const }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
drainStudioSaveQueues(
|
||||||
|
async () => ({ status: "conflict" as const, error: conflict }),
|
||||||
|
waitForDomQueue,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ status: "conflict", error: conflict });
|
||||||
|
expect(waitForDomQueue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
|
|||||||
import {
|
import {
|
||||||
installStudioManualEditSeekReapply,
|
installStudioManualEditSeekReapply,
|
||||||
reapplyPositionEditsAfterSeek,
|
reapplyPositionEditsAfterSeek,
|
||||||
readStudioFileChangePath,
|
|
||||||
} from "../components/editor/manualEdits";
|
} from "../components/editor/manualEdits";
|
||||||
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
|
||||||
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
|
import {
|
||||||
|
flushStudioPendingEdits,
|
||||||
|
type StudioPendingEditsDrainResult,
|
||||||
|
} from "../utils/studioPendingEdits";
|
||||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||||
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
|
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
|
||||||
import { usePlayerStore } from "../player";
|
import { usePlayerStore } from "../player";
|
||||||
@@ -29,19 +31,12 @@ interface RecordEditInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UsePreviewPersistenceParams {
|
interface UsePreviewPersistenceParams {
|
||||||
projectId: string | null;
|
|
||||||
showToast: (message: string, tone?: "error" | "info") => void;
|
showToast: (message: string, tone?: "error" | "info") => void;
|
||||||
readOptionalProjectFile: (path: string) => Promise<string>;
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
||||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||||
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
||||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||||
activeCompPathRef: React.MutableRefObject<string | null>;
|
activeCompPathRef: React.MutableRefObject<string | null>;
|
||||||
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
|
|
||||||
* Used to suppress file-change echoes so we don't reload after our own saves. */
|
|
||||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
||||||
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
|
|
||||||
* events for these paths are always suppressed since the preview is already up-to-date. */
|
|
||||||
pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
|
|
||||||
/** Called to reload the preview after undo/redo or external file changes. */
|
/** Called to reload the preview after undo/redo or external file changes. */
|
||||||
reloadPreview: () => void;
|
reloadPreview: () => void;
|
||||||
}
|
}
|
||||||
@@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldReloadForStudioFileChange(
|
export async function drainStudioSaveQueues(
|
||||||
payload: unknown,
|
flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
|
||||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>> | undefined,
|
waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
|
||||||
domEditSaveTimestampRef: React.MutableRefObject<number>,
|
): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
|
||||||
): boolean {
|
const pending = await flushPendingFields();
|
||||||
const changedPath = readStudioFileChangePath(payload);
|
if (pending.status !== "clean") return pending;
|
||||||
if (!changedPath) return false;
|
return waitForDomQueue();
|
||||||
const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
|
|
||||||
if (pendingTimelinePaths?.has(changedPath)) {
|
|
||||||
pendingTimelinePaths.delete(changedPath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Date.now() - domEditSaveTimestampRef.current >= 4000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
|
|||||||
// ── Hook ──
|
// ── Hook ──
|
||||||
|
|
||||||
export function usePreviewPersistence({
|
export function usePreviewPersistence({
|
||||||
projectId,
|
|
||||||
showToast,
|
showToast,
|
||||||
readOptionalProjectFile: _readOptionalProjectFile,
|
readOptionalProjectFile: _readOptionalProjectFile,
|
||||||
writeProjectFile: _writeProjectFile,
|
writeProjectFile: _writeProjectFile,
|
||||||
recordEdit: _recordEdit,
|
recordEdit: _recordEdit,
|
||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
activeCompPathRef,
|
activeCompPathRef,
|
||||||
domEditSaveTimestampRef,
|
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
pendingTimelineEditPathRef,
|
|
||||||
}: UsePreviewPersistenceParams) {
|
}: UsePreviewPersistenceParams) {
|
||||||
void _recordEdit;
|
void _recordEdit;
|
||||||
|
|
||||||
@@ -152,22 +138,23 @@ export function usePreviewPersistence({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a ref to the latest projectId so async save callbacks always read the
|
|
||||||
// current value, even when the callback was captured in a stale closure.
|
|
||||||
const projectIdRef = useRef(projectId);
|
|
||||||
projectIdRef.current = projectId;
|
|
||||||
|
|
||||||
// ── Queue / drain helpers ──
|
// ── Queue / drain helpers ──
|
||||||
|
|
||||||
const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
|
const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
|
||||||
return domEditSaveQueueRef.current?.enqueue(save) ?? save();
|
return domEditSaveQueueRef.current?.enqueue(save) ?? save();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const waitForPendingDomEditSaves = useCallback(async () => {
|
const drainPendingDomEditSaves = useCallback(async () => {
|
||||||
await flushStudioPendingEdits();
|
return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
|
||||||
await domEditSaveQueueRef.current?.waitForIdle();
|
return (await domEditSaveQueueRef.current?.waitForIdle()) ?? { status: "clean" as const };
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const waitForPendingDomEditSaves = useCallback(async (): Promise<void> => {
|
||||||
|
const result = await drainPendingDomEditSaves();
|
||||||
|
if (result.status !== "clean") throw result.error;
|
||||||
|
}, [drainPendingDomEditSaves]);
|
||||||
|
|
||||||
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
||||||
domEditSaveQueueRef.current?.reset();
|
domEditSaveQueueRef.current?.reset();
|
||||||
setDomEditSaveQueuePaused(null);
|
setDomEditSaveQueuePaused(null);
|
||||||
@@ -235,35 +222,12 @@ export function usePreviewPersistence({
|
|||||||
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
|
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Listen for external file changes (HMR / SSE) ──
|
|
||||||
useMountEffect(() => {
|
|
||||||
const handler = (payload?: unknown) => {
|
|
||||||
if (
|
|
||||||
shouldReloadForStudioFileChange(
|
|
||||||
payload,
|
|
||||||
pendingTimelineEditPathRef,
|
|
||||||
domEditSaveTimestampRef,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// fallow-ignore-next-line code-duplication
|
|
||||||
reloadPreview();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.on("hf:file-change", handler);
|
|
||||||
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
||||||
}
|
|
||||||
// SSE fallback for embedded studio server
|
|
||||||
const es = new EventSource("/api/events");
|
|
||||||
es.addEventListener("file-change", handler);
|
|
||||||
return () => es.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domTextCommitVersionRef,
|
domTextCommitVersionRef,
|
||||||
domEditSaveQueueRef,
|
domEditSaveQueueRef,
|
||||||
applyStudioManualEditsToPreviewRef,
|
applyStudioManualEditsToPreviewRef,
|
||||||
queueDomEditSave,
|
queueDomEditSave,
|
||||||
|
drainPendingDomEditSaves,
|
||||||
waitForPendingDomEditSaves,
|
waitForPendingDomEditSaves,
|
||||||
domEditSaveQueuePaused,
|
domEditSaveQueuePaused,
|
||||||
resetDomEditSaveQueueBreaker,
|
resetDomEditSaveQueueBreaker,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { MutableRefObject } from "react";
|
|
||||||
import { openComposition } from "@hyperframes/sdk";
|
import { openComposition } from "@hyperframes/sdk";
|
||||||
import type { Composition } from "@hyperframes/sdk";
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
||||||
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
||||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||||
import type { PublishSdkSession } from "../utils/sdkCutover";
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
||||||
|
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a project file's content, or undefined on a non-2xx (optional read).
|
* Read a project file's content, or undefined on a non-2xx (optional read).
|
||||||
@@ -48,22 +48,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
|||||||
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
||||||
* the open effect below.
|
* the open effect below.
|
||||||
*/
|
*/
|
||||||
// Reload-suppression baseline: a file-change within this window of our own SDK
|
|
||||||
// cutover write is a CANDIDATE echo, but the decision is content-identity based
|
|
||||||
// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
|
|
||||||
// still reloads (its reverted bytes were never registered as a self-write). The
|
|
||||||
// window only bounds how long a registered self-write stays suppressible.
|
|
||||||
const SELF_WRITE_SUPPRESS_MS = 2000;
|
|
||||||
|
|
||||||
/** Best-effort read of the changed file's content from a file-change payload. */
|
|
||||||
function readFileChangeContent(payload: unknown): string | null {
|
|
||||||
if (!payload || typeof payload !== "object") return null;
|
|
||||||
const record = payload as Record<string, unknown>;
|
|
||||||
if (typeof record.content === "string") return record.content;
|
|
||||||
if ("data" in record) return readFileChangeContent(record.data);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether a file-change for the active composition should reload the SDK
|
* Decide whether a file-change for the active composition should reload the SDK
|
||||||
* session. `content` is the new on-disk bytes (from the payload or a re-read);
|
* session. `content` is the new on-disk bytes (from the payload or a re-read);
|
||||||
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
|
|||||||
export function useSdkSession(
|
export function useSdkSession(
|
||||||
projectId: string | null,
|
projectId: string | null,
|
||||||
activeCompPath: string | null,
|
activeCompPath: string | null,
|
||||||
domEditSaveTimestampRef?: MutableRefObject<number>,
|
|
||||||
): SdkSessionHandle {
|
): SdkSessionHandle {
|
||||||
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
||||||
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
||||||
@@ -171,40 +154,13 @@ export function useSdkSession(
|
|||||||
const reloadTokenRef = useRef(reloadToken);
|
const reloadTokenRef = useRef(reloadToken);
|
||||||
reloadTokenRef.current = reloadToken;
|
reloadTokenRef.current = reloadToken;
|
||||||
|
|
||||||
// ── Re-open on external change to the active composition ──
|
useEffect(
|
||||||
useEffect(() => {
|
() =>
|
||||||
if (!activeCompPath) return;
|
addExternalFileReloadListener((changedPath) => {
|
||||||
const compPath = activeCompPath;
|
if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
|
||||||
const readProjectId = projectId ?? null;
|
}),
|
||||||
const handler = (payload?: unknown) => {
|
[],
|
||||||
if (!shouldReloadSdkSession(payload, compPath)) return;
|
);
|
||||||
const withinWindow =
|
|
||||||
!!domEditSaveTimestampRef &&
|
|
||||||
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
|
|
||||||
const decide = (content: string | null) => {
|
|
||||||
if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
|
|
||||||
};
|
|
||||||
const payloadContent = readFileChangeContent(payload);
|
|
||||||
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
|
|
||||||
// (an undo's reverted bytes won't match a registered self-write → reload).
|
|
||||||
if (payloadContent != null || readProjectId == null) {
|
|
||||||
decide(payloadContent);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
readProjectFileOptional(readProjectId, compPath)
|
|
||||||
.then((c) => decide(c ?? null))
|
|
||||||
.catch(() => decide(null));
|
|
||||||
};
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.on("hf:file-change", handler);
|
|
||||||
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
||||||
}
|
|
||||||
// SSE fallback for the embedded studio server.
|
|
||||||
const es = new EventSource("/api/events");
|
|
||||||
es.addEventListener("file-change", handler);
|
|
||||||
return () => es.close();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [activeCompPath, projectId]);
|
|
||||||
|
|
||||||
// ── Open / re-open the session ──
|
// ── Open / re-open the session ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback, type MutableRefObject } from "react";
|
||||||
|
import {
|
||||||
|
deleteExternalConflictSnapshot,
|
||||||
|
loadExternalConflictSnapshot,
|
||||||
|
persistExternalConflictSnapshot,
|
||||||
|
persistExternalFailureSnapshot,
|
||||||
|
} from "../utils/externalConflictStorage";
|
||||||
|
import { notifyExternalFileReload } from "./externalFileReloadBus";
|
||||||
|
import { useExternalFileChangeCoordinator } from "./useExternalFileChangeCoordinator";
|
||||||
|
import type { useFileManager } from "./useFileManager";
|
||||||
|
import type { usePreviewPersistence } from "./usePreviewPersistence";
|
||||||
|
|
||||||
|
type FileManager = Pick<
|
||||||
|
ReturnType<typeof useFileManager>,
|
||||||
|
| "editingFile"
|
||||||
|
| "flushPendingSourceSave"
|
||||||
|
| "discardPendingSourceSave"
|
||||||
|
| "getPendingSourceCandidate"
|
||||||
|
| "overwriteExternalConflict"
|
||||||
|
| "readProjectFile"
|
||||||
|
| "updateEditingFileContent"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type PreviewPersistence = Pick<
|
||||||
|
ReturnType<typeof usePreviewPersistence>,
|
||||||
|
"drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface UseStudioExternalFileChangesOptions {
|
||||||
|
projectId: string | null;
|
||||||
|
activeCompPath: string | null;
|
||||||
|
masterCompPath: string | null;
|
||||||
|
fileManager: FileManager;
|
||||||
|
previewPersistence: PreviewPersistence;
|
||||||
|
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
||||||
|
reloadPreview: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
|
||||||
|
export function useStudioExternalFileChanges({
|
||||||
|
projectId,
|
||||||
|
activeCompPath,
|
||||||
|
masterCompPath,
|
||||||
|
fileManager,
|
||||||
|
previewPersistence,
|
||||||
|
pendingTimelineEditPathRef,
|
||||||
|
reloadPreview,
|
||||||
|
}: UseStudioExternalFileChangesOptions) {
|
||||||
|
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
|
||||||
|
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
|
||||||
|
const drainPendingChanges = useCallback(async () => {
|
||||||
|
const source = await flushPendingSourceSave();
|
||||||
|
if (source.status !== "clean") return source;
|
||||||
|
return drainPendingDomEditSaves();
|
||||||
|
}, [drainPendingDomEditSaves, flushPendingSourceSave]);
|
||||||
|
|
||||||
|
const discardPendingChanges = useCallback(() => {
|
||||||
|
discardPendingSourceSave();
|
||||||
|
resetDomEditSaveQueueBreaker();
|
||||||
|
}, [discardPendingSourceSave, resetDomEditSaveQueueBreaker]);
|
||||||
|
|
||||||
|
return useExternalFileChangeCoordinator({
|
||||||
|
projectId,
|
||||||
|
activeCompPath,
|
||||||
|
recoveryFilePath: fileManager.editingFile?.path ?? activeCompPath ?? masterCompPath,
|
||||||
|
pendingTimelineEditPathRef,
|
||||||
|
drainPendingChanges,
|
||||||
|
getPendingCandidate: fileManager.getPendingSourceCandidate,
|
||||||
|
discardPendingChanges,
|
||||||
|
reloadPreview,
|
||||||
|
reloadSdkSession: notifyExternalFileReload,
|
||||||
|
persistConflictSnapshot: persistExternalConflictSnapshot,
|
||||||
|
persistFailureSnapshot: persistExternalFailureSnapshot,
|
||||||
|
loadConflictSnapshot: loadExternalConflictSnapshot,
|
||||||
|
deleteConflictSnapshot: deleteExternalConflictSnapshot,
|
||||||
|
overwriteConflict: fileManager.overwriteExternalConflict,
|
||||||
|
readProjectFile: fileManager.readProjectFile,
|
||||||
|
onUseExternalFile: fileManager.updateEditingFileContent,
|
||||||
|
resetSaveQueues: resetDomEditSaveQueueBreaker,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type MutableRefObject } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSdkSession } from "./useSdkSession";
|
import { useSdkSession } from "./useSdkSession";
|
||||||
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
||||||
|
|
||||||
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|||||||
export function useStudioSdkSessions(
|
export function useStudioSdkSessions(
|
||||||
projectId: string | null,
|
projectId: string | null,
|
||||||
activeCompPath: string | null,
|
activeCompPath: string | null,
|
||||||
domEditSaveTimestampRef: MutableRefObject<number>,
|
|
||||||
masterCompPath: string | null,
|
masterCompPath: string | null,
|
||||||
) {
|
) {
|
||||||
// On the master view (no explicit comp) the schema panels target the project's
|
// On the master view (no explicit comp) the schema panels target the project's
|
||||||
// resolved main composition — the first `.html` in the tree, not a hardcoded
|
// resolved main composition — the first `.html` in the tree, not a hardcoded
|
||||||
// "index.html" that may not exist. `null` when the project has no composition
|
// "index.html" that may not exist. `null` when the project has no composition
|
||||||
// yet, which correctly leaves the session (and the panels) empty.
|
// yet, which correctly leaves the session (and the panels) empty.
|
||||||
const sdkHandle = useSdkSession(
|
const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
|
||||||
projectId,
|
|
||||||
activeCompPath ?? masterCompPath,
|
|
||||||
domEditSaveTimestampRef,
|
|
||||||
);
|
|
||||||
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePreviewVariablesStore.getState().setValues(null);
|
usePreviewVariablesStore.getState().setValues(null);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { hasFiredSessionStart, markSessionStartFired } from "../telemetry/config";
|
||||||
|
import { trackStudioSessionStart } from "../telemetry/events";
|
||||||
|
|
||||||
|
export function useStudioSessionStart(
|
||||||
|
projectId: string | null,
|
||||||
|
resolving: boolean,
|
||||||
|
waitingForServer: boolean,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolving || waitingForServer || hasFiredSessionStart()) return;
|
||||||
|
markSessionStartFired();
|
||||||
|
trackStudioSessionStart({ has_project: projectId != null });
|
||||||
|
}, [projectId, resolving, waitingForServer]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
max_attempts=3
|
||||||
|
retry_delay_seconds="${HF_BUN_INSTALL_RETRY_DELAY_SECONDS:-5}"
|
||||||
|
|
||||||
|
for ((attempt = 1; attempt <= max_attempts; attempt += 1)); do
|
||||||
|
if bun install --frozen-lockfile "$@"; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
install_status=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((attempt == max_attempts)); then
|
||||||
|
echo "::error::Bun dependency install failed after ${max_attempts} attempts."
|
||||||
|
exit "$install_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
next_attempt=$((attempt + 1))
|
||||||
|
echo "::warning::Bun dependency install failed; retrying dependency install (attempt ${next_attempt}/${max_attempts})."
|
||||||
|
sleep "$((retry_delay_seconds * attempt))"
|
||||||
|
done
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const installScript = new URL("./ci/install-workspace-dependencies.sh", import.meta.url);
|
||||||
|
|
||||||
|
function runInstall({ succeedOnAttempt }) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "hyperframes-bun-install-test-"));
|
||||||
|
const binDir = join(root, "bin");
|
||||||
|
const countFile = join(root, "attempts");
|
||||||
|
const argsFile = join(root, "args");
|
||||||
|
|
||||||
|
try {
|
||||||
|
spawnSync("mkdir", ["-p", binDir], { stdio: "pipe" });
|
||||||
|
const bunStub = join(binDir, "bun");
|
||||||
|
writeFileSync(
|
||||||
|
bunStub,
|
||||||
|
`#!/usr/bin/env bash
|
||||||
|
set -u
|
||||||
|
count=0
|
||||||
|
if [[ -f "$COUNT_FILE" ]]; then count="$(cat "$COUNT_FILE")"; fi
|
||||||
|
count=$((count + 1))
|
||||||
|
printf '%s' "$count" > "$COUNT_FILE"
|
||||||
|
printf '%s\\n' "$*" >> "$ARGS_FILE"
|
||||||
|
if (( count < SUCCEED_ON_ATTEMPT )); then exit 23; fi
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
chmodSync(bunStub, 0o755);
|
||||||
|
|
||||||
|
const result = spawnSync("bash", [installScript.pathname, "--ignore-scripts"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ARGS_FILE: argsFile,
|
||||||
|
COUNT_FILE: countFile,
|
||||||
|
HF_BUN_INSTALL_RETRY_DELAY_SECONDS: "0",
|
||||||
|
PATH: `${binDir}:${process.env.PATH}`,
|
||||||
|
SUCCEED_ON_ATTEMPT: String(succeedOnAttempt),
|
||||||
|
},
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
args: existsSync(argsFile) ? readFileSync(argsFile, "utf8").trim().split("\n") : [],
|
||||||
|
attempts: existsSync(countFile) ? Number(readFileSync(countFile, "utf8")) : 0,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("retries a transient Bun install failure with the same frozen-lockfile arguments", () => {
|
||||||
|
const result = runInstall({ succeedOnAttempt: 2 });
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||||
|
assert.equal(result.attempts, 2);
|
||||||
|
assert.deepEqual(result.args, [
|
||||||
|
"install --frozen-lockfile --ignore-scripts",
|
||||||
|
"install --frozen-lockfile --ignore-scripts",
|
||||||
|
]);
|
||||||
|
assert.match(result.stdout, /retrying dependency install \(attempt 2\/3\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops after three failed Bun install attempts", () => {
|
||||||
|
const result = runInstall({ succeedOnAttempt: 4 });
|
||||||
|
|
||||||
|
assert.equal(result.status, 23, `${result.stdout}\n${result.stderr}`);
|
||||||
|
assert.equal(result.attempts, 3);
|
||||||
|
assert.match(result.stdout, /failed after 3 attempts/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user