Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ jobs:
- run: corepack enable
- run: corepack prepare pnpm@10.17.1 --activate
- 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 verify:packed-manifests

Expand All @@ -148,7 +148,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run lint

# `fallow audit` runs dead-code + complexity + duplication analysis scoped to
Expand Down Expand Up @@ -182,7 +182,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- name: Run fallow audit
id: audit
# `bun install` above made `bunx fallow` resolve from node_modules, so
Expand Down Expand Up @@ -238,7 +238,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run format:check

typecheck:
Expand All @@ -256,7 +256,7 @@ jobs:
with:
node-version: 22
- 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 --filter '*' typecheck

Expand All @@ -283,7 +283,7 @@ jobs:
with:
node-version: 22
- 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 --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build
Expand Down Expand Up @@ -314,7 +314,7 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ffmpeg
- 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 --cwd packages/core build
- run: bun run --filter @hyperframes/engine build
Expand Down Expand Up @@ -422,7 +422,7 @@ jobs:
node-version: 22
- name: Install dependencies
if: runner.os != 'Windows'
run: bun install --frozen-lockfile --ignore-scripts
run: bash scripts/ci/install-workspace-dependencies.sh --ignore-scripts
- name: Install dependencies
if: runner.os == 'Windows'
run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted
Expand All @@ -437,7 +437,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- 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
# imports resolve via the "node" export condition (dist) under vitest.
- run: bun run --filter '@hyperframes/parsers' build
Expand All @@ -459,7 +459,7 @@ jobs:
with:
node-version: 22
- 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
# subpaths. Build their dist exports before Vitest resolves them.
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
Expand All @@ -480,7 +480,7 @@ jobs:
with:
node-version: 22
- 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
# resolve @hyperframes/core and @hyperframes/studio-server via the "node"
# export condition (dist).
Expand Down Expand Up @@ -523,7 +523,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
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
# resolves the workspace packages through their "node" export condition.
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
Expand Down Expand Up @@ -634,7 +634,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run build

# Pack the CLI as a tarball (simulates what `npm publish` produces)
Expand Down Expand Up @@ -711,7 +711,7 @@ jobs:
sudo apt-get install -y ffmpeg
- uses: ./.github/actions/prepare-ffmpeg-bin
- name: Install dependencies
run: bun install --frozen-lockfile
run: bash scripts/ci/install-workspace-dependencies.sh
- name: Build monorepo
run: bun run build

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"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'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
Expand Down
28 changes: 14 additions & 14 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player";
import { StudioOverlays } from "./components/StudioOverlays";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
Expand All @@ -22,6 +23,7 @@ import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
Expand Down Expand Up @@ -56,25 +58,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext";
import { StudioSplash } from "./components/StudioSplash";
import { useServerConnection } from "./hooks/useServerConnection";
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
import {
normalizeStudioCompositionPath,
readStudioUrlStateFromWindow,
resolveMasterCompositionPath,
} from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const viewModeValue = useViewModeState();
useEffect(() => {
if (resolving || waitingForServer) return;
if (hasFiredSessionStart()) return;
markSessionStartFired();
trackStudioSessionStart({ has_project: projectId != null });
}, [projectId, resolving, waitingForServer]);
useStudioSessionStart(projectId, resolving, waitingForServer);
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
() => initialUrlStateRef.current.activeCompPath == null,
Expand Down Expand Up @@ -130,7 +126,6 @@ export function StudioApp() {
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
projectId,
activeCompPath,
domEditSaveTimestampRef,
masterCompPath,
);
useEffect(() => {
Expand All @@ -144,20 +139,24 @@ export function StudioApp() {
setActiveCompPathHydrated(true);
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
const previewPersistence = usePreviewPersistence({
projectId,
showToast,
readOptionalProjectFile: fileManager.readOptionalProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
previewIframeRef,
activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview: () => setRefreshKey((k) => k + 1),
});
const externalFileChanges = useStudioExternalFileChanges({
projectId,
activeCompPath,
masterCompPath,
fileManager,
previewPersistence,
pendingTimelineEditPathRef,
reloadPreview,
});
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 timelineEditing = useTimelineEditing({
projectId,
Expand Down Expand Up @@ -483,12 +482,13 @@ export function StudioApp() {
})();
}}
/>
{previewPersistence.domEditSaveQueuePaused && (
{previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
<SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused}
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
/>
)}
<ExternalFileConflictBanner coordinator={externalFileChanges} />
{viewModeValue.viewMode === "storyboard" && (
<StoryboardView
projectId={projectId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function DesignPanelPromoteProvider({
children: ReactNode;
}) {
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
const handle = useSdkSession(projectId, targetPath);
const rawPersist = useVariablesPersist({
...persistDeps,
sdkSession: handle.session,
Expand Down
26 changes: 26 additions & 0 deletions packages/studio/src/components/ExternalFileConflictBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,30 @@ describe("ExternalFileConflictBanner", () => {

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());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function ExternalFileConflictBanner({
Review or export Studio draft
</button>
)}
{failure && !failure.recovered && (
{failure && !failure.recovered && failure.studioContent != null && (
<button type="button" onClick={() => void coordinator.retry()} className="underline">
Retry save
</button>
Expand Down
17 changes: 17 additions & 0 deletions packages/studio/src/hooks/externalFileChangeOwnership.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
34 changes: 34 additions & 0 deletions packages/studio/src/hooks/externalFileReloadBus.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
18 changes: 18 additions & 0 deletions packages/studio/src/hooks/externalFileReloadBus.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
23 changes: 23 additions & 0 deletions packages/studio/src/hooks/usePreviewPersistence.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading