mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
## Summary Prevent page, template, folder, and marketplace page imports from silently replacing local declarations on the shared global root. When incoming root styles differ at the same property, breakpoint, and state, Builder now asks whether to preserve the project values or use the incoming values. Cancellation happens before the runtime mutation. ## Technical choices - Compare CSS values by their serialized meaning so equivalent keyword and unparsed values do not create false conflicts. - Plan breakpoint remapping with the same insertion path used by fragment paste, including ID collisions and breakpoint limits. - Keep named-token conflict resolution separate because token conflicts support suffixing and merging, while a shared root-local declaration has only an existing or incoming value. - Default the root-style dialog to **Keep existing** because global root styles affect every page, and show the breakpoint plus current and incoming values for each conflict. - Re-read current project data after the asynchronous token dialog before detecting root conflicts. - Leave no-conflict resolution undefined so the runtime performs a final conflict check instead of treating it as an explicit **Use incoming** choice. - Resolve root conflicts at the page-copy boundary before using the generic fragment/style insertion path. - Reuse one root-conflict UI resolver for clipboard paste and marketplace imports, one page-transfer traversal helper in the UI and runtime, and one presentational shell for token and root-style conflict dialogs. - Require an explicit `rootStyleConflictResolution` for conflicting `pageTransfer.insert` and `pages.copy` runtime operations so non-UI callers cannot overwrite global values accidentally. - Document that `ours` keeps target-project values and `theirs` uses incoming values in both generated runtime contracts. ## Todo - [x] Detect conflicting root-local declarations by property, breakpoint, and state - [x] Add keep-existing, use-incoming, and cancel behavior - [x] Cover page, template, nested-folder, marketplace, and direct runtime transfer paths - [x] Preserve non-conflicting incoming root declarations with either resolution - [x] Avoid false conflicts for equivalent CSS values and colliding breakpoint IDs - [x] Revalidate root styles after asynchronous token resolution - [x] Preserve existing named-token conflict behavior - [x] Remove duplicated transfer traversal, conflict resolution mapping, and dialog layout - [x] Regenerate the runtime operation contract - [x] Verify regression tests fail when their corresponding behavior is disabled - [x] Run targeted tests, typechecks, formatting, and lint ## Verification - `pnpm --filter @webstudio-is/project-build test -- src/runtime/style-copy.test.ts src/runtime/fragment.test.tsx src/runtime/page-copy.test.tsx` — 80 passed - `pnpm --filter @webstudio-is/builder test -- app/shared/copy-paste/plugin-page.test.ts app/shared/root-style-conflict-dialog.test.tsx app/shared/token-conflict-dialog.test.tsx` — 17 passed - Retrospective fail/pass audit covered semantic CSS equality, breakpoint ID collisions, both runtime guards, both resolution directions, folder/template paths, conflict detail rendering, and stale target data after token resolution - `pnpm --filter @webstudio-is/project-build typecheck` — passed - `pnpm --filter @webstudio-is/builder typecheck` — passed - `pnpm lint` — passed - Runtime contract regeneration was repeated and produced identical output - Fixture pipeline not run locally: no fixture inputs or outputs are affected; GitHub fixture checks passed on the previous revision - Agent evaluations not run: repository guidance requires separate explicit approval before running them ## Compatibility and limitations The new runtime inputs are optional for non-conflicting imports. Existing imports continue unchanged; only imports that would overwrite a conflicting shared root-local declaration require an explicit resolution. No migrations or persistence changes are required. Closes #5970
123 lines
3.1 KiB
TypeScript
123 lines
3.1 KiB
TypeScript
import { createRecursiveProxy } from "@trpc/server/shared";
|
|
import invariant from "tiny-invariant";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import { uploadAssets } from "~/builder/shared/assets/upload-assets";
|
|
import { showTokenConflictDialog } from "./token-conflict-dialog";
|
|
import { showRootStyleConflictDialog } from "./root-style-conflict-dialog";
|
|
import { showDesignTokenImportDialog } from "./design-token-import-dialog";
|
|
|
|
const apiWindowNamespace = "__webstudio__$__builderApi";
|
|
|
|
type ToastHandler = (message: string) => void;
|
|
|
|
const isSafeMode = (() => {
|
|
if (typeof window === "undefined") {
|
|
return false;
|
|
}
|
|
return new URLSearchParams(window.location.search).get("safemode") === "true";
|
|
})();
|
|
|
|
const _builderApi = {
|
|
isInitialized: () => true,
|
|
isSafeMode: () => isSafeMode,
|
|
toast: {
|
|
info: toast.info as ToastHandler,
|
|
warn: toast.warn as ToastHandler,
|
|
error: toast.error as ToastHandler,
|
|
success: toast.success as ToastHandler,
|
|
},
|
|
uploadImages: async (srcs: string[]) => {
|
|
const urlToIds = await uploadAssets(
|
|
"image",
|
|
srcs.map((src) => new URL(src))
|
|
);
|
|
|
|
return new Map([...urlToIds.entries()].map(([url, id]) => [url.href, id]));
|
|
},
|
|
showDesignTokenImportDialog,
|
|
showTokenConflictDialog,
|
|
showRootStyleConflictDialog,
|
|
};
|
|
|
|
declare global {
|
|
interface Window {
|
|
[apiWindowNamespace]: typeof _builderApi;
|
|
}
|
|
}
|
|
|
|
const isInTop = () => {
|
|
try {
|
|
return window.self === window.top;
|
|
} catch {
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const getTopApi = () => {
|
|
if (isInTop()) {
|
|
// Inside the iframe, use the local window.api
|
|
return _builderApi;
|
|
} else {
|
|
// Find first iframe with the API
|
|
invariant(window.top);
|
|
return window.top[apiWindowNamespace];
|
|
}
|
|
};
|
|
|
|
const isKeyOf = <T>(key: unknown, obj: T): key is keyof T => {
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
return key in obj;
|
|
};
|
|
|
|
/**
|
|
* Forwards the call from the builder to the iframe, invoking the original API in the iframe.
|
|
*/
|
|
export const builderApi = createRecursiveProxy((options) => {
|
|
const api = getTopApi();
|
|
|
|
if (api == null) {
|
|
if (
|
|
options.path.join(".") ===
|
|
("isInitialized" satisfies keyof typeof _builderApi)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
console.warn(
|
|
`API not found in the iframe, skipping ${options.path.join(".")} call, iframe probably not loaded yet`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
let currentMethod = api as unknown;
|
|
|
|
for (const key of options.path) {
|
|
invariant(
|
|
isKeyOf(key, currentMethod),
|
|
`API method ${options.path.join(".")} not found`
|
|
);
|
|
invariant(typeof currentMethod === "object");
|
|
invariant(currentMethod != null);
|
|
|
|
currentMethod = currentMethod[key];
|
|
}
|
|
|
|
invariant(
|
|
typeof currentMethod === "function",
|
|
`API method ${options.path.join(".")} is not a function`
|
|
);
|
|
|
|
return currentMethod.call(null, ...options.args);
|
|
}) as typeof _builderApi;
|
|
|
|
/**
|
|
* Initializes the builder API in the window. Must be called in the builder context.
|
|
*/
|
|
export const initBuilderApi = () => {
|
|
if (isInTop()) {
|
|
window[apiWindowNamespace] = _builderApi;
|
|
}
|
|
return () => {};
|
|
};
|