Files
Oleg Isonen 3d0a0657aa feat: Support async resource dependencies (#6030)
## Summary

Make `Resource<Document>` the lazy document-resolution abstraction
shared by Content Engine graphs, generated sites, and Builder previews.
Expressions use one async computation interface: referenced resource
documents resolve first, independent requests run concurrently, and
unused resources stay unloaded.

Ref #6029
Ref #6031

## Implementation checklist

- [x] Define one resource contract for synchronous or asynchronous
document resolution.
- [x] Resolve explicit roots lazily with memoization, bounded
concurrency, cancellation, and structured errors.
- [x] Preserve cycle, duplicate, and missing-resource diagnostics.
- [x] Extract resource dependencies with the existing expression AST.
- [x] Discover consumed resource roots from the current page tree and
page metadata.
- [x] Generate a lazy request graph for published page data, including
independent requests.
- [x] Resolve dependency documents before evaluating dependent request
expressions.
- [x] Integrate dependency-first loading with Builder preview caching
and invalidation.
- [x] Expose safe Resource bindings in the editor and reject
self/indirect cycles.
- [x] Adapt REST, GraphQL, Assets, date, and sitemap requests through
the same Resource contract.
- [x] Cover laziness, chains, shared dependencies, concurrency,
invalidation, cancellation, failures, and cycles with fail/pass tests.
- [x] Review documentation impact and document dependent Resources in
the Data variables guide.
- [x] Track collection-item and component-parameter-dependent resources
as follow-up #6031.
- [x] Regenerate and review affected fixtures.
- [x] Resolve action-resource request dependencies when an action is
invoked.
- [x] Reproduce and fix cached form submissions and multi-root
dependency-cycle hangs.
- [x] Document uncached form actions and regenerate affected fixtures.
- [x] Preserve single and multi-selected content until a cut
successfully writes to the clipboard.
- [x] Preserve boolean expression values in the search-exclusion
checkbox.
- [x] Cancel cut deletion if project data changes while preparing or
writing the clipboard; cover content/property edits and project switches
for single and multi-selection, and document the retry behavior.

- [x] Validate pending cuts after the final await, immediately before
deletion, and reproduce the queued-update race for single and
multi-selection.

## Technical direction

The persisted, revisioned `DocumentGraph` remains the Content Engine
artifact. `Resource<Document>` is the execution abstraction for
obtaining a document; its resolver evaluates only the dependency closure
reachable from requested roots. Static and remote documents therefore
differ by Resource implementation, not by expression value type.

Generated pages always emit a `ResourceRequestGraph`. Each node owns its
output name, dependencies, and request factory. Roots come from resource
variables actually referenced by the current page's metadata, props,
actions, or expression children. Unused and off-page resources remain
unresolved. The loader still accepts legacy request maps for
compatibility.

Builder preview uses the same page-root discovery as published
generation. Its existing cache remains the lifecycle boundary: cached
dependency documents unlock dependent requests, while invalidation
removes downstream requests until their inputs resolve again. The
separate canvas frame recomputes its local plan from the synchronized
cache so rendered expressions receive those documents.

Action requests and their dependencies resolve when a form is submitted.
Each submission sends a new action request, bypassing Webstudio's
resource cache. Dependency requests still use their configured cache
lifetime.

## Review simplifications

- Consolidated page resource-root discovery in the SDK and reused it in
Builder.
- Scoped root and action discovery to the current page tree.
- Removed the redundant immediate-resource helper; `Resource.resolve`
already accepts `Document | Promise<Document>`.
- Moved output names onto graph nodes and removed the duplicate
graph-level map.
- Removed unused planner output and corrected transitive cycle discovery
to include only Resource data sources.
- Regenerated fixtures so independent resources also use lazy graph
execution.
- Reused one abort-aware concurrency limiter for document sessions and
Resource resolution.
- Collapsed duplicate generator action scans and removed redundant
generated-resource bookkeeping.
- Unified legacy request maps and request graphs through one resolution
path.
- Shared the 20-request concurrency limit between SDK, Builder preview,
and the server batch boundary.
- Preserved dependency failure paths and original causes, including the
first cancellation reason.
- Removed invalid-request logging that could expose Resource headers or
request bodies.
- Made editor cycle detection alias-safe by comparing underlying
Resource IDs.
- Memoized page settings values to prevent repeated async validation
renders.

## Verification

Latest local verification for `bbca6395e3`:

- Both queued-update regression cases failed before the fix and passed
afterward. Validation now runs synchronously immediately before
deletion, after the final await, in both cut paths.
- Passed: 255 copy/paste and command tests, Builder typecheck, lint,
package boundaries, formatting, and diff checks.
- Reviewed documentation: the existing copy/paste safety description
remains accurate. Fixture inputs and outputs are unaffected; no fixture
regeneration was needed. Agent evaluations were not run because separate
approval is required.
- CI for the new commit is pending.

Previous local verification for `ffbc75b152`:

- Six regression cases failed before the fix and passed afterward:
content edits, property edits, and project switches during a pending
cut, with both single and multiple selections.
- Passed: 253 copy/paste and command tests, Builder typecheck, lint,
package boundaries, source formatting, and diff checks.
- An unchanged content-block test file initially failed to import in the
browser. Its isolated rerun and the full 253-test rerun passed without
code changes.
- The guard conservatively treats any project-data change as a reason to
keep the clipboard copy without deleting content. It shows a retry
message. Copy/paste documentation now describes this behavior.
- Fixture inputs and outputs are unaffected; fixtures were not
regenerated. Agent evaluations were not run because separate approval is
required. CI for the new commit is pending.

Previous local verification for `3eb580697c`:

- Reproduced both regressions with failing tests before fixing them:
premature deletion during cut and false search exclusion appearing
checked.
- Passed: 203 copy/paste and page-settings tests, Builder typecheck,
lint, package boundaries, formatting, and diff checks.
- Cut coverage includes keyboard and programmatic entry points, single
and multiple selections, delayed successful writes, rejected writes, and
disabled copying.
- Reviewed documentation impact: these fixes restore existing behavior;
no documentation update is needed. Only Builder UI and clipboard files
changed; fixture inputs and outputs are unaffected, so fixtures were not
regenerated for this commit.
- CI status for this older commit was recorded as pending at handoff.
Agent evaluations were not run; they require separate approval.

Previous local verification for `858b348ad`, rebased onto `cf606ca4c`:

- Regression tests failed before each fix and passed afterward: repeated
submissions in both generated route templates, plus three
multi-root/branching cycle cases.
- Passed: 590 SDK tests, 1,129 CLI tests, 72 focused Builder tests,
SDK/CLI/Builder typechecks, lint, package boundaries, and fixture
link/sync/build.
- Initial concurrent runs hit timeouts. The complete CLI suite passed
with one worker; the focused Builder suites passed without file
parallelism.
- Documentation and generated fixture changes reviewed. Agent
evaluations were not run; they require separate approval.
- CI for `858b348ad` passed: Main (including all six E2E shards), Visual
Regression, Lint PR, and Check submodules.

Earlier verification (before this update):

TDD fail/pass evidence includes resolver creation, AST dependency
discovery, published graph reachability, dependency-gated request
generation, Builder dependency-first planning, Resource editor cycle
filtering, page-tree root scoping, off-page action isolation, transitive
Resource-only cycle discovery, abort/timeout precedence, server batch
limits, and the rebased SSG fixture consuming its lazy Resource.

- `pnpm checks` passed after rebasing onto `ce84ddcf12`; it covered
workspace tests, typechecks, lint, package boundaries, generated
API/docs validation, and fixture link/sync/build.
- Earlier focused checks passed: Builder typecheck, 12
page-settings/pages unit tests, lint, formatting, and `git diff
--check`.
- Local generated-resource E2E passed for HTTP, GraphQL, and
current-date resources (3 tests).
- The page-actions and Marketplace page-settings persistence E2Es both
pass locally.
- CI for `6b8b08a70` passed: Main workflow (including all six Builder
E2E shards), Visual Regression, Lint PR, and Check submodules.
- Agent evaluations were not run because they require separate explicit
approval.

## Current behavior and limitations

- A failed dependency blocks its dependents and reports the resource
path and cause.
- One published resolution call is the memoization boundary; Builder
maps dependency readiness onto its versioned request-cache lifecycle.
- Scoped collection-item and component-parameter resources remain in
#6031.
- Builder skips cyclic dependency paths without blocking independent
resources.
2026-09-22 21:04:42 +02:00

1648 lines
44 KiB
TypeScript

import { afterAll, afterEach, expect, test, vi } from "vitest";
import { enableMapSet } from "immer";
import { createDefaultPages } from "@webstudio-is/project-build";
import { createEmptyWebstudioFragment } from "@webstudio-is/project-build/runtime";
import { coreMetas, ROOT_INSTANCE_ID, type Instance } from "@webstudio-is/sdk";
import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
import type { Project } from "@webstudio-is/project";
import { __testing__ as builderApiTesting } from "../builder-api";
import { registerContainers } from "../sync/sync-stores";
import {
$assets,
$breakpoints,
$dataSources,
$instances,
$pages,
$project,
$props,
$resources,
$styleSourceSelections,
$styleSources,
$styles,
} from "../sync/data-stores";
import {
$authTokenPermissions,
$selectedPageId,
$textEditingInstanceSelector,
$registeredComponentMetas,
selectInstance,
selectInstances,
} from "../nano-states";
import {
copyInstance,
copyPage,
cutInstance,
emitPaste,
initCopyPaste,
initCopyPasteForContentEditMode,
} from "./copy-paste";
import { insertFragmentWithBreakpointWarning } from "./fragment-utils";
enableMapSet();
registerContainers();
afterAll(builderApiTesting.useLocalApi());
$registeredComponentMetas.set(
new Map(Object.entries({ ...baseComponentMetas, ...coreMetas }))
);
const resetStores = () => {
$authTokenPermissions.set({
canClone: true,
canCopy: true,
canPublish: false,
});
$instances.set(new Map());
$project.set(undefined);
$props.set(new Map());
$dataSources.set(new Map());
$resources.set(new Map());
$breakpoints.set(new Map());
$styleSourceSelections.set(new Map());
$styleSources.set(new Map());
$styles.set(new Map());
$assets.set(new Map());
$textEditingInstanceSelector.set(undefined);
selectInstance(undefined);
};
const setupPage = () => {
const pages = createDefaultPages({
homePageId: "page-id",
rootInstanceId: "body-id",
});
$pages.set(pages);
$selectedPageId.set(pages.homePageId);
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [],
},
],
])
);
};
const setupRootCssVariable = (property: `--${string}`, value: string) => {
$styleSources.set(
new Map([["root-local", { id: "root-local", type: "local" }]])
);
$styleSourceSelections.set(
new Map([
[
ROOT_INSTANCE_ID,
{ instanceId: ROOT_INSTANCE_ID, values: ["root-local"] },
],
])
);
$styles.set(
new Map([
[
`root-local:base:${property}:`,
{
styleSourceId: "root-local",
breakpointId: "base",
property,
value: { type: "unparsed", value },
},
],
])
);
};
const disconnectClipboardData = new WeakMap<ClipboardEvent, () => void>();
const createClipboardEvent = (type: "copy" | "cut" | "paste") => {
const data = new Map<string, string>();
let connected = true;
const clipboardData = {
getData: (mimeType: string) =>
connected ? (data.get(mimeType) ?? "") : "",
setData: (mimeType: string, value: string) => {
if (connected) {
data.set(mimeType, value);
}
},
} as DataTransfer;
const event = new Event(type, {
bubbles: true,
cancelable: true,
}) as ClipboardEvent;
Object.defineProperty(event, "clipboardData", { value: clipboardData });
if (type === "paste" || type === "cut") {
disconnectClipboardData.set(event, () => {
connected = false;
});
}
return { clipboardData, event };
};
const dispatchClipboardEvent = (event: ClipboardEvent) => {
document.dispatchEvent(event);
disconnectClipboardData.get(event)?.();
};
class TestDataTransfer {
data = new Map<string, string>();
getData(mimeType: string) {
return this.data.get(mimeType) ?? "";
}
setData(mimeType: string, value: string) {
this.data.set(mimeType, value);
}
}
class FirefoxClipboardEvent extends Event {
clipboardData = new TestDataTransfer() as unknown as DataTransfer;
constructor(type: string, options: EventInit) {
super(type, options);
}
}
const setupToastInfo = () => {
return vi
.spyOn(builderApiTesting.api.toast, "info")
.mockImplementation(() => {});
};
const setupToastError = () => {
return vi
.spyOn(builderApiTesting.api.toast, "error")
.mockImplementation(() => {});
};
const setupToastSuccess = () => {
return vi
.spyOn(builderApiTesting.api.toast, "success")
.mockImplementation(() => {});
};
const waitForClipboardEvent = () =>
new Promise((resolve) => setTimeout(resolve, 20));
const pastePlainText = async (
value: unknown,
target: "design-token" | "css-variable" | "cancel" = "css-variable"
) => {
vi.spyOn(
builderApiTesting.api,
"showDesignTokenImportDialog"
).mockResolvedValue(target);
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData("text/plain", JSON.stringify(value));
dispatchClipboardEvent(event);
await waitForClipboardEvent();
abortController.abort();
return event;
};
afterEach(() => {
resetStores();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
test("does not copy page to clipboard when copy permission is disabled", async () => {
resetStores();
setupPage();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
clipboard: { writeText },
});
$authTokenPermissions.set({
canClone: true,
canCopy: false,
canPublish: false,
});
await copyPage("page-id");
expect(writeText).not.toHaveBeenCalled();
});
test("writes asynchronously prepared page data from a clipboard event", async () => {
resetStores();
setupPage();
$project.set({ id: "project" } as Project);
selectInstance(["body-id"]);
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
clipboard: { writeText },
});
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
const { event } = createClipboardEvent("copy");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"@webstudio/page/v0.1"')
);
abortController.abort();
});
test("does not copy instance to clipboard when no instance is selected", async () => {
resetStores();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
clipboard: { writeText },
});
await copyInstance();
expect(writeText).not.toHaveBeenCalled();
});
test("copies selected instance through clipboard event", async () => {
resetStores();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [{ type: "id", value: "box-id" }],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
])
);
selectInstance(["box-id", "body-id"]);
const { clipboardData, event } = createClipboardEvent("copy");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect(clipboardData.getData("text/plain")).toContain(
'"@webstudio/instance/v0.1"'
);
abortController.abort();
});
test("copies multi-selected instances through clipboard event", async () => {
resetStores();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [
{ type: "id", value: "box-id" },
{ type: "id", value: "heading-id" },
],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
[
"heading-id",
{
type: "instance",
id: "heading-id",
component: "Heading",
children: [],
},
],
])
);
selectInstances([
["box-id", "body-id"],
["heading-id", "body-id"],
]);
const { clipboardData, event } = createClipboardEvent("copy");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect(JSON.parse(clipboardData.getData("text/plain"))).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["box-id", "heading-id"],
},
});
abortController.abort();
});
test("content mode copies selected instances through clipboard event", async () => {
resetStores();
const abortController = new AbortController();
const toastInfo = setupToastInfo();
initCopyPasteForContentEditMode({ signal: abortController.signal });
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [{ type: "id", value: "box-id" }],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
])
);
selectInstance(["box-id", "body-id"]);
const { clipboardData, event } = createClipboardEvent("copy");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect(clipboardData.getData("text/plain")).toContain(
'"@webstudio/instance/v0.1"'
);
expect(toastInfo).not.toHaveBeenCalled();
abortController.abort();
});
test("content mode reports unsupported copy selection", async () => {
resetStores();
const abortController = new AbortController();
const toastInfo = setupToastInfo();
initCopyPasteForContentEditMode({ signal: abortController.signal });
const { event } = createClipboardEvent("copy");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(false);
expect(toastInfo).toHaveBeenCalledWith(
"This selection cannot be copied here."
);
abortController.abort();
});
test("content mode pastes instance clipboard data through clipboard event", async () => {
resetStores();
const abortController = new AbortController();
const toastInfo = setupToastInfo();
initCopyPasteForContentEditMode({ signal: abortController.signal });
$project.set({ id: "project-id" } as Project);
setupPage();
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [{ type: "id", value: "box-id" }],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
])
);
selectInstance(["box-id", "body-id"]);
const { clipboardData: copyData, event: copyEvent } =
createClipboardEvent("copy");
dispatchClipboardEvent(copyEvent);
await waitForClipboardEvent();
const clipboardText = copyData.getData("text/plain");
selectInstance(["body-id"]);
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData("text/plain", clipboardText);
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect($instances.get().get("body-id")?.children).toEqual([
{ type: "id", value: "box-id" },
{ type: "id", value: expect.any(String) },
]);
expect(toastInfo).not.toHaveBeenCalled();
abortController.abort();
});
test("pastes multi-selected instance clipboard data through clipboard event", async () => {
resetStores();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
$project.set({ id: "project-id" } as Project);
setupPage();
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [
{ type: "id", value: "source-1" },
{ type: "id", value: "source-2" },
{ type: "id", value: "target" },
],
},
],
[
"source-1",
{
type: "instance",
id: "source-1",
component: "Box",
children: [],
},
],
[
"source-2",
{
type: "instance",
id: "source-2",
component: "Heading",
children: [],
},
],
[
"target",
{
type: "instance",
id: "target",
component: "Box",
children: [],
},
],
])
);
selectInstances([
["source-1", "body-id"],
["source-2", "body-id"],
]);
const { clipboardData: copyData, event: copyEvent } =
createClipboardEvent("copy");
dispatchClipboardEvent(copyEvent);
await waitForClipboardEvent();
const clipboardText = copyData.getData("text/plain");
expect(JSON.parse(clipboardText)).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["source-1", "source-2"],
},
});
selectInstance(["target", "body-id"]);
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData("text/plain", clipboardText);
dispatchClipboardEvent(event);
await waitForClipboardEvent();
const targetChildren = $instances.get().get("target")?.children;
expect(targetChildren).toEqual([
{ type: "id", value: expect.any(String) },
{ type: "id", value: expect.any(String) },
]);
expect(targetChildren?.[0]?.value).not.toBe("source-1");
expect(targetChildren?.[1]?.value).not.toBe("source-2");
expect(
$instances.get().get(targetChildren?.[0]?.value ?? "")?.component
).toBe("Box");
expect(
$instances.get().get(targetChildren?.[1]?.value ?? "")?.component
).toBe("Heading");
abortController.abort();
});
test("does not paste malformed webstudio instance json as plain text", async () => {
resetStores();
const abortController = new AbortController();
const toastError = setupToastError();
initCopyPaste({ signal: abortController.signal });
$project.set({ id: "project-id" } as Project);
setupPage();
selectInstance(["body-id"]);
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData(
"text/plain",
`{"@webstudio/instance/v0.1":{"instanceSelector":["missing-id","body-id"]`
);
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect($instances.get().get("body-id")?.children).toEqual([]);
expect(toastError).toHaveBeenCalledWith(
"Could not paste Webstudio instance data. The clipboard data appears to be incomplete or invalid."
);
abortController.abort();
});
test("reports malformed Webflow json through generic paste", async () => {
resetStores();
const abortController = new AbortController();
const toastError = setupToastError();
initCopyPaste({ signal: abortController.signal });
setupPage();
selectInstance(["body-id"]);
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData(
"application/json",
JSON.stringify({ type: "@webflow/XscpData" })
);
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect($instances.get().get("body-id")?.children).toEqual([]);
expect(toastError).toHaveBeenCalledWith(expect.any(String));
abortController.abort();
});
test("imports pasted DTCG tokens through the runtime mutation pipeline", async () => {
resetStores();
setupPage();
const toastError = setupToastError();
const toastSuccess = setupToastSuccess();
const event = await pastePlainText({
color: {
$type: "color",
primary: { $value: "#123456" },
action: { $ref: "#/color/primary" },
},
});
expect(toastError).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
expect(
Array.from($styles.get().values()).map((style) => [
style.property,
style.value,
])
).toEqual(
expect.arrayContaining([
["--color-primary", { type: "unparsed", value: "#123456" }],
["--color-action", { type: "unparsed", value: "#123456" }],
])
);
expect(toastSuccess).toHaveBeenCalledWith("Imported 2 tokens.");
});
test("imports a pasted DTCG composite as one Webstudio design token", async () => {
resetStores();
setupPage();
const toastError = setupToastError();
const toastSuccess = setupToastSuccess();
const event = await pastePlainText(
{
typography: {
$type: "typography",
body: {
$value: {
fontFamily: ["Inter", "sans-serif"],
fontSize: { value: 16, unit: "px" },
fontWeight: 400,
letterSpacing: { value: 0, unit: "px" },
lineHeight: 1.5,
},
},
},
},
"design-token"
);
expect(toastError).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
const token = Array.from($styleSources.get().values()).find(
(source) => source.type === "token"
);
expect(token).toMatchObject({ name: "typography-body" });
expect(
Array.from($styles.get().values())
.filter((style) => style.styleSourceId === token?.id)
.map((style) => style.property)
).toEqual(
expect.arrayContaining([
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
])
);
expect(toastSuccess).toHaveBeenCalledWith("Imported 1 token.");
});
test("keeps ambiguous primitives as CSS variables when importing design tokens", async () => {
resetStores();
setupPage();
setupToastError();
await pastePlainText(
{
color: {
$type: "color",
primary: { $value: "#123456" },
},
spacing: {
$type: "dimension",
small: { $value: { value: 8, unit: "px" } },
},
},
"design-token"
);
expect(
Array.from($styleSources.get().values()).find(
(source) => source.type === "token"
)
).toMatchObject({ name: "color-primary" });
expect(
Array.from($styles.get().values()).find(
(style) => style.property === "--spacing-small"
)?.value
).toEqual({ type: "unparsed", value: "8px" });
});
test("cancels a pasted token import without changing project styles", async () => {
resetStores();
setupPage();
setupToastError();
const event = await pastePlainText(
{ color: { $type: "color", $value: "#123456" } },
"cancel"
);
expect(event.defaultPrevented).toBe(true);
expect($styleSources.get().size).toBe(0);
expect($styles.get().size).toBe(0);
});
test("resolves token conflicts for fragments through the shared paste helper", async () => {
resetStores();
setupPage();
setupToastError();
$project.set({ id: "project-id" } as Project);
$styleSources.set(
new Map([
[
"existing-token",
{ id: "existing-token", type: "token", name: "Primary" },
],
])
);
$styles.set(
new Map([
[
"existing-token:base:color:",
{
styleSourceId: "existing-token",
breakpointId: "base",
property: "color",
value: { type: "unparsed", value: "red" },
},
],
])
);
const fragment = createEmptyWebstudioFragment();
fragment.styleSources.push({
id: "incoming-token",
type: "token",
name: "Primary",
});
fragment.styles.push({
styleSourceId: "incoming-token",
breakpointId: "base",
property: "color",
value: { type: "unparsed", value: "blue" },
});
const conflictDialog = vi
.spyOn(builderApiTesting.api, "showTokenConflictDialog")
.mockResolvedValue("ours");
await insertFragmentWithBreakpointWarning(fragment);
expect(conflictDialog).toHaveBeenCalledWith([
expect.objectContaining({
tokenName: "Primary",
fragmentTokenId: "incoming-token",
}),
]);
expect(Array.from($styleSources.get().values())).toEqual([
{ id: "existing-token", type: "token", name: "Primary" },
]);
expect($styles.get().get("existing-token:base:color:")?.value).toEqual({
type: "unparsed",
value: "red",
});
});
test("does not insert a fragment when token conflict resolution is cancelled", async () => {
resetStores();
setupPage();
setupToastError();
$project.set({ id: "project-id" } as Project);
$styleSources.set(
new Map([
[
"existing-token",
{ id: "existing-token", type: "token", name: "Primary" },
],
])
);
const fragment = createEmptyWebstudioFragment();
fragment.styleSources.push({
id: "incoming-token",
type: "token",
name: "Primary",
});
fragment.styles.push({
styleSourceId: "incoming-token",
breakpointId: "base",
property: "color",
value: { type: "unparsed", value: "blue" },
});
vi.spyOn(builderApiTesting.api, "showTokenConflictDialog").mockResolvedValue(
"cancel"
);
const inserted = await insertFragmentWithBreakpointWarning(fragment);
expect(inserted).toBe(false);
expect(Array.from($styleSources.get().values())).toEqual([
{ id: "existing-token", type: "token", name: "Primary" },
]);
});
test.each([
["ours", "old", undefined],
["theirs", "old", "#123456"],
["merge", "#123456", undefined],
] as const)(
"resolves pasted design token collisions with %s",
async (resolution, expectedValue, expectedRenamedValue) => {
resetStores();
setupPage();
setupRootCssVariable("--color", "old");
setupToastError();
setupToastSuccess();
const conflictDialog = vi
.spyOn(builderApiTesting.api, "showTokenConflictDialog")
.mockResolvedValue(resolution);
await pastePlainText({
color: { $type: "color", $value: "#123456" },
});
expect(conflictDialog).toHaveBeenCalledWith([
{
tokenName: "--color",
},
]);
expect($styles.get().get("root-local:base:--color:")?.value).toEqual({
type: "unparsed",
value: expectedValue,
});
expect($styles.get().get("root-local:base:--color-1:")?.value).toEqual(
expectedRenamedValue === undefined
? undefined
: { type: "unparsed", value: expectedRenamedValue }
);
}
);
test("silently reuses an identical pasted design value", async () => {
resetStores();
setupPage();
setupRootCssVariable("--color", "#123456");
setupToastError();
const toastSuccess = setupToastSuccess();
const conflictDialog = vi.spyOn(
builderApiTesting.api,
"showTokenConflictDialog"
);
await pastePlainText({
color: { $type: "color", $value: "#123456" },
});
expect(conflictDialog).not.toHaveBeenCalled();
expect($styles.get().size).toBe(1);
expect(toastSuccess).toHaveBeenCalledWith(
"Imported 0 tokens; skipped 1 existing."
);
});
test("imports the default mode from pasted Figma variables", async () => {
resetStores();
setupPage();
const toastError = setupToastError();
const toastSuccess = setupToastSuccess();
await pastePlainText({
meta: {
variableCollections: {
palette: {
defaultModeId: "dark",
modes: [
{ modeId: "light", name: "Light" },
{ modeId: "dark", name: "Dark" },
],
},
},
variables: {
primary: {
name: "Color/Primary",
resolvedType: "COLOR",
variableCollectionId: "palette",
valuesByMode: {
light: { r: 1, g: 1, b: 1 },
dark: { r: 0, g: 0, b: 0 },
},
},
},
},
});
expect(toastError).not.toHaveBeenCalled();
expect(
Array.from($styles.get().values()).find(
(style) => style.property === "--color-primary"
)?.value
).toEqual({ type: "unparsed", value: "rgb(0 0 0 / 1)" });
expect(toastSuccess).toHaveBeenCalledWith("Imported 1 token.");
});
test("imports pasted DTCG resolver documents and composite tokens", async () => {
resetStores();
setupPage();
const toastError = setupToastError();
const toastSuccess = setupToastSuccess();
const event = await pastePlainText({
version: "2025.10",
resolutionOrder: [
{
type: "set",
name: "Base",
sources: [
{
body: {
$type: "typography",
$value: {
fontFamily: "Inter",
fontSize: { value: 16, unit: "px" },
fontWeight: 500,
letterSpacing: { value: 0, unit: "px" },
lineHeight: 1.5,
},
},
},
],
},
],
});
expect(toastError).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
expect(
Array.from($styles.get().values()).map((style) => [
style.property,
style.value,
])
).toEqual(
expect.arrayContaining([
["--body-font-family", { type: "unparsed", value: '"Inter"' }],
["--body-font-size", { type: "unparsed", value: "16px" }],
["--body-font-weight", { type: "unparsed", value: "500" }],
["--body-letter-spacing", { type: "unparsed", value: "0px" }],
["--body-line-height", { type: "unparsed", value: "1.5" }],
])
);
expect(toastSuccess).toHaveBeenCalledWith("Imported 5 tokens.");
});
test("reports invalid pasted design token documents", async () => {
resetStores();
setupPage();
const toastError = setupToastError();
const event = await pastePlainText({
spacing: {
$type: "dimension",
$value: { value: 8, unit: "invalid" },
},
});
expect(event.defaultPrevented).toBe(true);
expect($styles.get()).toEqual(new Map());
expect(toastError).toHaveBeenCalledWith(
expect.stringContaining("invalid dimension unit invalid")
);
});
test("does not intercept native paste while editing text", async () => {
resetStores();
const abortController = new AbortController();
const toastError = setupToastError();
initCopyPaste({ signal: abortController.signal });
setupPage();
$textEditingInstanceSelector.set({
selector: ["text-id", "body-id"],
reason: "click",
mouseX: 0,
mouseY: 0,
});
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData(
"text/plain",
`{"@webstudio/instance/v0.1":{"instanceSelector":["missing-id","body-id"]`
);
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(false);
expect($instances.get().get("body-id")?.children).toEqual([]);
expect(toastError).not.toHaveBeenCalled();
abortController.abort();
});
test("cuts multi-selected instances through clipboard event", async () => {
resetStores();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [
{ type: "id", value: "box-id" },
{ type: "id", value: "heading-id" },
],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
[
"heading-id",
{
type: "instance",
id: "heading-id",
component: "Heading",
children: [],
},
],
])
);
selectInstances([
["box-id", "body-id"],
["heading-id", "body-id"],
]);
const { event } = createClipboardEvent("cut");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect(JSON.parse(writeText.mock.calls[0]?.[0] ?? "")).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["box-id", "heading-id"],
},
});
expect($instances.get().get("body-id")?.children).toEqual([]);
expect($instances.get().has("box-id")).toBe(false);
expect($instances.get().has("heading-id")).toBe(false);
abortController.abort();
});
test("content mode blocks cut through clipboard event", async () => {
resetStores();
const abortController = new AbortController();
const toastInfo = setupToastInfo();
initCopyPasteForContentEditMode({ signal: abortController.signal });
const { event } = createClipboardEvent("cut");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(false);
expect(toastInfo).toHaveBeenCalledWith(
"Cutting is allowed in design mode only."
);
abortController.abort();
});
test("content mode reports unsupported paste clipboard data", async () => {
resetStores();
const abortController = new AbortController();
const toastInfo = setupToastInfo();
initCopyPasteForContentEditMode({ signal: abortController.signal });
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData("text/plain", "plain text");
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect(toastInfo).toHaveBeenCalledWith(
"This clipboard data cannot be pasted here."
);
abortController.abort();
});
test("content mode reports malformed webstudio instance clipboard data", async () => {
resetStores();
const abortController = new AbortController();
const toastInfo = setupToastInfo();
const toastError = setupToastError();
initCopyPasteForContentEditMode({ signal: abortController.signal });
const { clipboardData, event } = createClipboardEvent("paste");
clipboardData.setData(
"text/plain",
`{"@webstudio/instance/v0.1":{"instanceSelector":["missing-id","body-id"]`
);
dispatchClipboardEvent(event);
await waitForClipboardEvent();
expect(event.defaultPrevented).toBe(true);
expect(toastError).toHaveBeenCalledWith(
"Could not paste Webstudio instance data. The clipboard data appears to be incomplete or invalid."
);
expect(toastInfo).not.toHaveBeenCalledWith(
"This clipboard data cannot be pasted here."
);
abortController.abort();
});
test("does not intercept native copy from inputs", () => {
resetStores();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [{ type: "id", value: "box-id" }],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
])
);
selectInstance(["box-id", "body-id"]);
const input = document.createElement("input");
document.body.appendChild(input);
const { clipboardData, event } = createClipboardEvent("copy");
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(clipboardData.getData("text/plain")).toBe("");
input.remove();
abortController.abort();
});
test("does not intercept native copy while editing canvas text", () => {
resetStores();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [{ type: "id", value: "text-id" }],
},
],
[
"text-id",
{
type: "instance",
id: "text-id",
component: "Text",
children: [],
},
],
])
);
selectInstance(["text-id", "body-id"]);
$textEditingInstanceSelector.set({
selector: ["text-id", "body-id"],
reason: "enter",
});
const { clipboardData, event } = createClipboardEvent("copy");
dispatchClipboardEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(clipboardData.getData("text/plain")).toBe("");
abortController.abort();
});
test("copies selected instance to clipboard", async () => {
resetStores();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
clipboard: { writeText },
});
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [{ type: "id", value: "box-id" }],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
])
);
selectInstance(["box-id", "body-id"]);
await copyInstance();
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"@webstudio/instance/v0.1"')
);
});
test("copies multi-selected instances to clipboard and emits paste for every root", async () => {
resetStores();
setupPage();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
let clipboardText = "";
vi.stubGlobal("navigator", {
...navigator,
clipboard: {
writeText: vi.fn(async (text: string) => {
clipboardText = text;
}),
readText: vi.fn(async () => clipboardText),
},
});
vi.stubGlobal("DataTransfer", TestDataTransfer);
vi.stubGlobal("ClipboardEvent", FirefoxClipboardEvent);
$project.set({ id: "project-id" } as Project);
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [
{ type: "id", value: "source-1" },
{ type: "id", value: "source-2" },
{ type: "id", value: "target" },
],
},
],
[
"source-1",
{
type: "instance",
id: "source-1",
component: "Box",
children: [],
},
],
[
"source-2",
{
type: "instance",
id: "source-2",
component: "Heading",
children: [],
},
],
[
"target",
{
type: "instance",
id: "target",
component: "Box",
children: [],
},
],
])
);
selectInstances([
["source-1", "body-id"],
["source-2", "body-id"],
]);
await copyInstance();
expect(JSON.parse(clipboardText)).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["source-1", "source-2"],
},
});
selectInstance(["target", "body-id"]);
await emitPaste();
expect($instances.get().get("target")?.children).toEqual([
{ type: "id", value: expect.any(String) },
{ type: "id", value: expect.any(String) },
]);
abortController.abort();
});
const setupEmitPasteProject = () => {
resetStores();
setupPage();
$project.set({ id: "project-id" } as Project);
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [
{ type: "id", value: "source" },
{ type: "id", value: "target" },
],
},
],
[
"source",
{ type: "instance", id: "source", component: "Box", children: [] },
],
[
"target",
{ type: "instance", id: "target", component: "Box", children: [] },
],
])
);
};
const setupEmitPasteClipboard = () => {
let clipboardText = "";
vi.stubGlobal("navigator", {
...navigator,
clipboard: {
writeText: vi.fn(async (text: string) => {
clipboardText = text;
}),
readText: vi.fn(async () => clipboardText),
},
});
vi.stubGlobal("DataTransfer", TestDataTransfer);
vi.stubGlobal("ClipboardEvent", FirefoxClipboardEvent);
};
test("content mode emits paste through the content mode handler", async () => {
setupEmitPasteProject();
setupEmitPasteClipboard();
const abortController = new AbortController();
initCopyPasteForContentEditMode({ signal: abortController.signal });
selectInstance(["source", "body-id"]);
await copyInstance();
selectInstance(["target", "body-id"]);
await emitPaste();
expect($instances.get().get("target")?.children).toEqual([
{ type: "id", value: expect.any(String) },
]);
abortController.abort();
});
test("content mode reports clipboard text it cannot paste through emit paste", async () => {
setupEmitPasteProject();
setupEmitPasteClipboard();
const toastInfo = setupToastInfo();
const abortController = new AbortController();
initCopyPasteForContentEditMode({ signal: abortController.signal });
selectInstance(["target", "body-id"]);
await emitPaste();
expect(toastInfo).toHaveBeenCalledWith(
"This clipboard data cannot be pasted here."
);
abortController.abort();
});
test("does not emit paste when copy permission is disabled", async () => {
setupEmitPasteProject();
setupEmitPasteClipboard();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
selectInstance(["source", "body-id"]);
await copyInstance();
$authTokenPermissions.set({
canClone: true,
canCopy: false,
canPublish: false,
});
selectInstance(["target", "body-id"]);
await emitPaste();
expect($instances.get().get("target")?.children).toEqual([]);
abortController.abort();
});
test("does not emit paste while editing canvas text", async () => {
setupEmitPasteProject();
setupEmitPasteClipboard();
const abortController = new AbortController();
initCopyPaste({ signal: abortController.signal });
selectInstance(["source", "body-id"]);
await copyInstance();
$textEditingInstanceSelector.set({
selector: ["target", "body-id"],
reason: "click",
mouseX: 0,
mouseY: 0,
});
selectInstance(["target", "body-id"]);
await emitPaste();
expect($instances.get().get("target")?.children).toEqual([]);
abortController.abort();
});
test("does not cut instance to clipboard when no instance is selected", async () => {
resetStores();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
clipboard: { writeText },
});
await cutInstance();
expect(writeText).not.toHaveBeenCalled();
});
test.each(["keyboard", "programmatic"] as const)(
"%s cut preserves selected instances until the clipboard write succeeds",
async (mode) => {
for (const multiple of [false, true]) {
for (const outcome of ["success", "failure", "disabled"] as const) {
resetStores();
setupPage();
const toastError = setupToastError();
const roots = multiple ? ["box-id", "heading-id"] : ["box-id"];
const instances = new Map<string, Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: roots.map((value) => ({ type: "id", value })),
},
],
...roots.map((id): [string, Instance] => [
id,
{ type: "instance", id, component: "Box", children: [] },
]),
]);
$instances.set(instances);
selectInstances(roots.map((id) => [id, "body-id"]));
let finishWrite = () => {};
const writeText = vi.fn(
() =>
new Promise<void>((resolve, reject) => {
finishWrite = () =>
outcome === "failure"
? reject(new Error("Clipboard write denied"))
: resolve();
})
);
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
const controller = new AbortController();
initCopyPaste({ signal: controller.signal });
if (outcome === "disabled") {
$authTokenPermissions.set({
...$authTokenPermissions.get(),
canCopy: false,
});
}
let operation: Promise<unknown> | undefined;
try {
if (mode === "keyboard") {
const { event } = createClipboardEvent("cut");
dispatchClipboardEvent(event);
} else {
operation = Promise.resolve(cutInstance()).catch(
(error: unknown) => error
);
}
await waitForClipboardEvent();
expect($instances.get()).toEqual(instances);
expect(writeText).toHaveBeenCalledTimes(
outcome === "disabled" ? 0 : 1
);
finishWrite();
await operation;
await waitForClipboardEvent();
if (outcome === "success") {
for (const id of roots) {
expect($instances.get().has(id)).toBe(false);
}
expect($instances.get().get("body-id")?.children).toEqual([]);
} else {
expect($instances.get()).toEqual(instances);
if (mode === "keyboard" && outcome === "failure") {
expect(toastError).toHaveBeenCalled();
}
}
} finally {
finishWrite();
await operation;
await waitForClipboardEvent();
controller.abort();
vi.restoreAllMocks();
}
}
}
}
);
test("cuts selected instance to clipboard and removes it", async () => {
resetStores();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
clipboard: { writeText },
});
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [{ type: "id", value: "box-id" }],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
])
);
selectInstance(["box-id", "body-id"]);
await cutInstance();
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"@webstudio/instance/v0.1"')
);
expect($instances.get().get("body-id")?.children).toEqual([]);
expect($instances.get().has("box-id")).toBe(false);
});
test("cuts multi-selected instances to clipboard and removes every root", async () => {
resetStores();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
clipboard: { writeText },
});
$instances.set(
new Map<Instance["id"], Instance>([
[
"body-id",
{
type: "instance",
id: "body-id",
component: "Body",
children: [
{ type: "id", value: "box-id" },
{ type: "id", value: "heading-id" },
],
},
],
[
"box-id",
{
type: "instance",
id: "box-id",
component: "Box",
children: [],
},
],
[
"heading-id",
{
type: "instance",
id: "heading-id",
component: "Heading",
children: [],
},
],
])
);
selectInstances([
["box-id", "body-id"],
["heading-id", "body-id"],
]);
await cutInstance();
expect(JSON.parse(writeText.mock.calls[0]?.[0] ?? "")).toMatchObject({
"@webstudio/instances/v0.1": {
rootInstanceIds: ["box-id", "heading-id"],
},
});
expect($instances.get().get("body-id")?.children).toEqual([]);
expect($instances.get().has("box-id")).toBe(false);
expect($instances.get().has("heading-id")).toBe(false);
});