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:
Vance Ingalls
2026-07-14 16:28:33 -07:00
parent e820e18092
commit 07cfc4c191
2 changed files with 168 additions and 161 deletions
+10 -161
View File
@@ -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")),
);
}