mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): shrink useDomEditCommits.ts under the file-size gate
CI's file-size check (which diffs against origin/main, not per-commit like the local lefthook gate) flagged useDomEditCommits.ts at 602 lines. Extracted the standalone atomic-patch-batch helpers (formatUnsafeFieldList, getErrorDetail, readErrorResponseBody, formatPatchRejectionMessage, patchElementBatches, batchesAreInlineStyleOnly, AtomicElementPatchConvergenceError) into useDomEditCommitsHelpers.ts — none of them close over hook state, so this is a pure move. useDomEditCommits.ts is now 451 lines. Typecheck/oxlint/oxfmt clean; useDomEditCommits.test.tsx (28 tests) and the full studio suite unaffected.
This commit is contained in:
@@ -12,11 +12,7 @@ import {
|
|||||||
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
||||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
import type {
|
import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
|
||||||
CommitDomEditPatchBatches,
|
|
||||||
DomEditPatchBatch,
|
|
||||||
PersistDomEditOperations,
|
|
||||||
} from "./domEditCommitTypes";
|
|
||||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||||
import {
|
import {
|
||||||
DomEditPersistUnsafeValueError,
|
DomEditPersistUnsafeValueError,
|
||||||
@@ -27,30 +23,15 @@ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
|||||||
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
||||||
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
||||||
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||||
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
import {
|
||||||
|
AtomicElementPatchConvergenceError,
|
||||||
// ── Helpers ──
|
batchesAreInlineStyleOnly,
|
||||||
|
formatPatchRejectionMessage,
|
||||||
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
formatUnsafeFieldList,
|
||||||
return fields.map((field) => field.path).join(", ");
|
getErrorDetail,
|
||||||
}
|
patchElementBatches,
|
||||||
|
readErrorResponseBody,
|
||||||
function getErrorDetail(error: unknown): string {
|
} from "./useDomEditCommitsHelpers";
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readErrorResponseBody(
|
|
||||||
response: Response,
|
|
||||||
): Promise<{ error?: string; fields?: string[] } | null> {
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.includes("application/json")) return null;
|
|
||||||
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
|
||||||
if (!body?.error) return "Couldn't save edit";
|
|
||||||
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecordEditInput {
|
interface RecordEditInput {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -60,138 +41,6 @@ interface RecordEditInput {
|
|||||||
files: Record<string, { before: string; after: string }>;
|
files: Record<string, { before: string; after: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
|
||||||
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
|
||||||
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Surface server-reported unmatched patches. The server atomically refuses the
|
|
||||||
* whole multi-file gesture; the caller uses `durable: false` to roll back and
|
|
||||||
* reload, so report the refusal without turning it into a second failure.
|
|
||||||
*/
|
|
||||||
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
|
||||||
const unmatchedIds = batch.patches
|
|
||||||
.filter((_, index) => matched[index] === false)
|
|
||||||
.map(describeBatchPatchTarget);
|
|
||||||
if (unmatchedIds.length === 0) return;
|
|
||||||
console.warn(
|
|
||||||
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
|
||||||
`${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
|
|
||||||
unmatchedIds.join(", "),
|
|
||||||
);
|
|
||||||
trackStudioSaveFailure({
|
|
||||||
source: "dom_edit",
|
|
||||||
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
|
||||||
filePath: batch.sourceFile,
|
|
||||||
mutationType: "z-reorder-unmatched",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AtomicElementPatchFile {
|
|
||||||
sourceFile: string;
|
|
||||||
changed: boolean;
|
|
||||||
matched?: boolean[];
|
|
||||||
before: string;
|
|
||||||
after: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AtomicElementPatchConvergenceError extends Error {
|
|
||||||
constructor(message: string, options?: { cause?: unknown }) {
|
|
||||||
super(message, options);
|
|
||||||
this.name = "AtomicElementPatchConvergenceError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the atomic response contract in one guard so callers do not compose validity.
|
|
||||||
// fallow-ignore-next-line complexity
|
|
||||||
function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"sourceFile" in value &&
|
|
||||||
typeof value.sourceFile === "string" &&
|
|
||||||
"changed" in value &&
|
|
||||||
typeof value.changed === "boolean" &&
|
|
||||||
(!("matched" in value) ||
|
|
||||||
(Array.isArray(value.matched) &&
|
|
||||||
value.matched.every((matched) => typeof matched === "boolean"))) &&
|
|
||||||
"before" in value &&
|
|
||||||
typeof value.before === "string" &&
|
|
||||||
"after" in value &&
|
|
||||||
typeof value.after === "string" &&
|
|
||||||
value.changed === (value.before !== value.after)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is the single client owner for dispatching and validating the aggregate
|
|
||||||
// atomic endpoint. Splitting validation from the request would weaken that wire contract.
|
|
||||||
// fallow-ignore-next-line complexity
|
|
||||||
async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
|
|
||||||
const body = JSON.stringify({ batches });
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const rejection = await readErrorResponseBody(response);
|
|
||||||
throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
|
|
||||||
}
|
|
||||||
const result: unknown = await response.json().catch(() => null);
|
|
||||||
if (
|
|
||||||
typeof result !== "object" ||
|
|
||||||
result === null ||
|
|
||||||
!("durable" in result) ||
|
|
||||||
typeof result.durable !== "boolean" ||
|
|
||||||
!("files" in result) ||
|
|
||||||
!Array.isArray(result.files) ||
|
|
||||||
result.files.length !== batches.length ||
|
|
||||||
!result.files.every(isAtomicElementPatchFile) ||
|
|
||||||
(!result.durable && result.files.some((file) => file.changed))
|
|
||||||
) {
|
|
||||||
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
|
||||||
}
|
|
||||||
const files = result.files.map((file, index) => {
|
|
||||||
const batch = batches[index];
|
|
||||||
const matched = file.matched ?? [];
|
|
||||||
if (
|
|
||||||
!batch ||
|
|
||||||
file.sourceFile !== batch.sourceFile ||
|
|
||||||
(matched.length !== 0 && matched.length !== batch.patches.length)
|
|
||||||
) {
|
|
||||||
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
|
||||||
}
|
|
||||||
reportUnmatchedBatchPatches(batch, matched);
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
matched,
|
|
||||||
allMatched: matched.length === batch.patches.length && matched.every(Boolean),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { durable: result.durable, files };
|
|
||||||
} catch (error) {
|
|
||||||
throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A batch is reload-skippable only when it is style-only: every operation is an
|
|
||||||
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
|
||||||
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
|
||||||
* doesn't already show. Any other op type (attribute / text-content / …) can
|
|
||||||
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
|
||||||
*/
|
|
||||||
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
|
||||||
return batches.every((batch) =>
|
|
||||||
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseDomEditCommitsParams {
|
export interface UseDomEditCommitsParams {
|
||||||
activeCompPath: string | null;
|
activeCompPath: string | null;
|
||||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
||||||
|
import type { DomEditPatchBatch } from "./domEditCommitTypes";
|
||||||
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
||||||
|
|
||||||
|
export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
||||||
|
return fields.map((field) => field.path).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorDetail(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readErrorResponseBody(
|
||||||
|
response: Response,
|
||||||
|
): Promise<{ error?: string; fields?: string[] } | null> {
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("application/json")) return null;
|
||||||
|
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPatchRejectionMessage(
|
||||||
|
body: { error?: string; fields?: string[] } | null,
|
||||||
|
): string {
|
||||||
|
if (!body?.error) return "Couldn't save edit";
|
||||||
|
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
||||||
|
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
||||||
|
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface server-reported unmatched patches. The server atomically refuses the
|
||||||
|
* whole multi-file gesture; the caller uses `durable: false` to roll back and
|
||||||
|
* reload, so report the refusal without turning it into a second failure.
|
||||||
|
*/
|
||||||
|
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
||||||
|
const unmatchedIds = batch.patches
|
||||||
|
.filter((_, index) => matched[index] === false)
|
||||||
|
.map(describeBatchPatchTarget);
|
||||||
|
if (unmatchedIds.length === 0) return;
|
||||||
|
console.warn(
|
||||||
|
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
||||||
|
`${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
|
||||||
|
unmatchedIds.join(", "),
|
||||||
|
);
|
||||||
|
trackStudioSaveFailure({
|
||||||
|
source: "dom_edit",
|
||||||
|
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
||||||
|
filePath: batch.sourceFile,
|
||||||
|
mutationType: "z-reorder-unmatched",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AtomicElementPatchFile {
|
||||||
|
sourceFile: string;
|
||||||
|
changed: boolean;
|
||||||
|
matched?: boolean[];
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AtomicElementPatchConvergenceError extends Error {
|
||||||
|
constructor(message: string, options?: { cause?: unknown }) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = "AtomicElementPatchConvergenceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the atomic response contract in one guard so callers do not compose validity.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"sourceFile" in value &&
|
||||||
|
typeof value.sourceFile === "string" &&
|
||||||
|
"changed" in value &&
|
||||||
|
typeof value.changed === "boolean" &&
|
||||||
|
(!("matched" in value) ||
|
||||||
|
(Array.isArray(value.matched) &&
|
||||||
|
value.matched.every((matched) => typeof matched === "boolean"))) &&
|
||||||
|
"before" in value &&
|
||||||
|
typeof value.before === "string" &&
|
||||||
|
"after" in value &&
|
||||||
|
typeof value.after === "string" &&
|
||||||
|
value.changed === (value.before !== value.after)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the single client owner for dispatching and validating the aggregate
|
||||||
|
// atomic endpoint. Splitting validation from the request would weaken that wire contract.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
|
||||||
|
const body = JSON.stringify({ batches });
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const rejection = await readErrorResponseBody(response);
|
||||||
|
throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
|
||||||
|
}
|
||||||
|
const result: unknown = await response.json().catch(() => null);
|
||||||
|
if (
|
||||||
|
typeof result !== "object" ||
|
||||||
|
result === null ||
|
||||||
|
!("durable" in result) ||
|
||||||
|
typeof result.durable !== "boolean" ||
|
||||||
|
!("files" in result) ||
|
||||||
|
!Array.isArray(result.files) ||
|
||||||
|
result.files.length !== batches.length ||
|
||||||
|
!result.files.every(isAtomicElementPatchFile) ||
|
||||||
|
(!result.durable && result.files.some((file) => file.changed))
|
||||||
|
) {
|
||||||
|
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
||||||
|
}
|
||||||
|
const files = result.files.map((file, index) => {
|
||||||
|
const batch = batches[index];
|
||||||
|
const matched = file.matched ?? [];
|
||||||
|
if (
|
||||||
|
!batch ||
|
||||||
|
file.sourceFile !== batch.sourceFile ||
|
||||||
|
(matched.length !== 0 && matched.length !== batch.patches.length)
|
||||||
|
) {
|
||||||
|
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
||||||
|
}
|
||||||
|
reportUnmatchedBatchPatches(batch, matched);
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
matched,
|
||||||
|
allMatched: matched.length === batch.patches.length && matched.every(Boolean),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { durable: result.durable, files };
|
||||||
|
} catch (error) {
|
||||||
|
throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A batch is reload-skippable only when it is style-only: every operation is an
|
||||||
|
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
||||||
|
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
||||||
|
* doesn't already show. Any other op type (attribute / text-content / …) can
|
||||||
|
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
||||||
|
*/
|
||||||
|
export function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
||||||
|
return batches.every((batch) =>
|
||||||
|
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user