Files
Oleg Isonen b7a11e4bc2 feat: Delay canvas scripts until resources render (#6380)
HTML Embed scripts in canvas and preview now wait until the complete
resource queue settles. Their markup remains rendered while loading;
only executable scripts are delayed, so scripts can read the committed
resource-backed DOM without requiring a custom event.

## Implementation

- Add an internal `$resourcesState` atom with `pending` and `settled`
states, driven by the complete resource queue.
- Synchronize the atom from the Builder to the canvas iframe.
- At the Builder component-render boundary, pass only HTML Embed the
private prop `$ws$executeScripts={isSafeMode !== true && resourcesState
=== "settled"}`.
- In the SDK HTML Embed component, default `$ws$executeScripts` to
`true`, keep markup inert while it is false, and execute scripts after
React commits the transition to true.
- Treat failed requests as settled so scripts cannot wait forever.
- Keep the private prop out of generated component arguments and
metadata through the existing `$`-prefixed prop convention.
- Keep published rendering unchanged because published sites do not pass
the private Builder prop.
- Keep the existing standard `DOMContentLoaded` / `load` compatibility
and sequential script queue.
- Remove the canvas wrapper and component-registry override.

## Todo

- [x] Track collective resource readiness in the Builder.
- [x] Synchronize readiness into the canvas.
- [x] Delay HTML Embed scripts in both canvas and preview.
- [x] Combine safe mode and resource readiness into one private
execution prop.
- [x] Preserve rendered HTML while scripts are delayed.
- [x] Preserve pages with no resources and failed-resource behavior.
- [x] Keep the private prop out of user-facing component metadata.
- [x] Add resource-state, sync, Builder-prop, and browser regression
tests.
- [x] Review documentation impact; no user-facing documentation changes
are needed.
- [x] Review fixture impact; no fixture inputs or generated outputs are
affected.

## Verification

- SDK HTML Embed browser tests — 44 passed
- Builder private-prop tests — 15 passed
- Resource tests — 28 passed
- Sync tests — 14 passed
- React SDK, SDK components, and Builder typechecks — passed
- Focused Oxlint and formatting — passed

Closes #6379
2026-09-14 15:53:26 +02:00

403 lines
11 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from "vitest";
import type { Instance } from "@webstudio-is/sdk";
import {
$allSelectedInstanceSelectors,
$selectedInstanceOutlines,
$selectedInstanceSelector,
$selectedPageId,
selectInstance,
selectInstances,
} from "../nano-states";
import { __testing__, createObjectPool } from "./sync-stores";
import { $instances } from "./data-stores";
import { $resourcesState } from "../resources";
const { SelectedPageAndInstanceSyncObject } = __testing__;
const subscriptionControllers: AbortController[] = [];
const waitForBatchedStore = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const setTestInstances = (...instanceIds: string[]) => {
const instances = new Map<string, Instance>([
[
"body",
{
type: "instance",
id: "body",
component: "Body",
children: instanceIds.map((value) => ({ type: "id", value })),
},
],
]);
for (const id of instanceIds) {
instances.set(id, {
type: "instance",
id,
component: "Box",
children: [],
});
}
$instances.set(instances);
};
const subscribeSelectionSync = async () => {
const syncObject = new SelectedPageAndInstanceSyncObject();
const sendTransaction = vi.fn();
const controller = new AbortController();
subscriptionControllers.push(controller);
syncObject.subscribe(sendTransaction, controller.signal);
await waitForBatchedStore();
sendTransaction.mockClear();
return { sendTransaction, syncObject };
};
afterEach(() => {
for (const controller of subscriptionControllers.splice(0)) {
controller.abort();
}
$selectedPageId.set(undefined);
selectInstance(undefined);
$allSelectedInstanceSelectors.set([]);
$selectedInstanceOutlines.set([]);
$instances.set(new Map());
$resourcesState.set("pending");
});
describe("SelectedPageAndInstanceSyncObject", () => {
test("does not publish selection pruned by an instance update", async () => {
setTestInstances("box");
selectInstance(["box", "body"]);
const { sendTransaction } = await subscribeSelectionSync();
setTestInstances();
await waitForBatchedStore();
expect($allSelectedInstanceSelectors.get()).toEqual([]);
expect(sendTransaction).not.toHaveBeenCalled();
$selectedPageId.set("next-page");
await waitForBatchedStore();
expect(sendTransaction).toHaveBeenCalledWith({
id: expect.any(String),
object: "selectedPageAndInstance",
payload: {
selectedPageId: "next-page",
selectedInstanceSelector: undefined,
allSelectedInstanceSelectors: [],
},
});
});
test("publishes a page change batched with instance pruning", async () => {
setTestInstances("box");
selectInstance(["box", "body"]);
const { sendTransaction } = await subscribeSelectionSync();
setTestInstances();
$selectedPageId.set("next-page");
await waitForBatchedStore();
expect(sendTransaction).toHaveBeenCalledWith({
id: expect.any(String),
object: "selectedPageAndInstance",
payload: {
selectedPageId: "next-page",
selectedInstanceSelector: undefined,
allSelectedInstanceSelectors: [],
},
});
});
test("publishes an explicit fallback selected after instance pruning", async () => {
setTestInstances("box", "heading");
selectInstance(["box", "body"]);
const { sendTransaction } = await subscribeSelectionSync();
setTestInstances("heading");
selectInstance(["heading", "body"]);
await waitForBatchedStore();
expect(sendTransaction).toHaveBeenCalledTimes(1);
expect(sendTransaction).toHaveBeenCalledWith({
id: expect.any(String),
object: "selectedPageAndInstance",
payload: {
selectedPageId: undefined,
selectedInstanceSelector: ["heading", "body"],
allSelectedInstanceSelectors: [["heading", "body"]],
},
});
});
test("sends page and instance selector as one final transaction", async () => {
const { sendTransaction } = await subscribeSelectionSync();
$selectedPageId.set("page-a");
selectInstance(["body-a"]);
await waitForBatchedStore();
expect(sendTransaction).toHaveBeenCalledTimes(1);
expect(sendTransaction).toHaveBeenCalledWith({
id: expect.any(String),
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-a",
selectedInstanceSelector: ["body-a"],
allSelectedInstanceSelectors: [["body-a"]],
},
});
});
test("applies page and instance selector without echoing transaction", async () => {
const { sendTransaction, syncObject } = await subscribeSelectionSync();
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-b",
selectedInstanceSelector: ["body-b"],
allSelectedInstanceSelectors: [["body-b"]],
},
});
expect($selectedPageId.get()).toBe("page-b");
expect($selectedInstanceSelector.get()).toEqual(["body-b"]);
await waitForBatchedStore();
expect(sendTransaction).not.toHaveBeenCalled();
});
test("remote no-op does not suppress the next local transaction", async () => {
$selectedPageId.set("page-a");
selectInstance(["body-a"]);
const { sendTransaction, syncObject } = await subscribeSelectionSync();
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-a",
selectedInstanceSelector: ["body-a"],
allSelectedInstanceSelectors: [["body-a"]],
},
});
selectInstance(["body-b"]);
await waitForBatchedStore();
expect(sendTransaction).toHaveBeenCalledTimes(1);
expect(sendTransaction).toHaveBeenCalledWith({
id: expect.any(String),
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-a",
selectedInstanceSelector: ["body-b"],
allSelectedInstanceSelectors: [["body-b"]],
},
});
});
test("applies undefined instance selector as cleared selection", async () => {
const syncObject = new SelectedPageAndInstanceSyncObject();
$selectedPageId.set("page-before");
selectInstance(["body-before"]);
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-after",
selectedInstanceSelector: undefined,
allSelectedInstanceSelectors: [],
},
});
expect($selectedPageId.get()).toBe("page-after");
expect($selectedInstanceSelector.get()).toBeUndefined();
expect($allSelectedInstanceSelectors.get()).toEqual([]);
});
test("sends undefined instance selector when multiple instances are selected", async () => {
const { sendTransaction } = await subscribeSelectionSync();
$selectedPageId.set("page-a");
selectInstances([
["box-a", "body-a"],
["heading-a", "body-a"],
]);
await waitForBatchedStore();
expect(sendTransaction).toHaveBeenCalledTimes(1);
expect(sendTransaction).toHaveBeenCalledWith({
id: expect.any(String),
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-a",
selectedInstanceSelector: undefined,
allSelectedInstanceSelectors: [
["box-a", "body-a"],
["heading-a", "body-a"],
],
},
});
});
test("applies multi-selection without needing a particular selected instance", () => {
const syncObject = new SelectedPageAndInstanceSyncObject();
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-b",
selectedInstanceSelector: undefined,
allSelectedInstanceSelectors: [
["box-b", "body-b"],
["heading-b", "body-b"],
],
},
});
expect($selectedPageId.get()).toBe("page-b");
expect($selectedInstanceSelector.get()).toBeUndefined();
expect($allSelectedInstanceSelectors.get()).toEqual([
["box-b", "body-b"],
["heading-b", "body-b"],
]);
});
test("malformed remote payload does not suppress next local transaction", async () => {
const { sendTransaction, syncObject } = await subscribeSelectionSync();
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: null,
});
$selectedPageId.set("page-c");
selectInstance(["body-c"]);
await waitForBatchedStore();
expect(sendTransaction).toHaveBeenCalledTimes(1);
expect(sendTransaction).toHaveBeenCalledWith({
id: expect.any(String),
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-c",
selectedInstanceSelector: ["body-c"],
allSelectedInstanceSelectors: [["body-c"]],
},
});
});
test("malformed remote payload does not echo a pending valid update", async () => {
const { sendTransaction, syncObject } = await subscribeSelectionSync();
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-a",
selectedInstanceSelector: ["body-a"],
allSelectedInstanceSelectors: [["body-a"]],
},
});
syncObject.applyTransaction({
id: "malformed-selection",
object: "selectedPageAndInstance",
payload: null,
});
await waitForBatchedStore();
expect(sendTransaction).not.toHaveBeenCalled();
});
test("malformed selection fields do not update stores", () => {
const syncObject = new SelectedPageAndInstanceSyncObject();
$selectedPageId.set("page-before");
selectInstance(["body-before"]);
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: 10,
selectedInstanceSelector: ["body-after"],
},
});
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-after",
selectedInstanceSelector: [10],
},
});
syncObject.applyTransaction({
id: "selection",
object: "selectedPageAndInstance",
payload: {
selectedPageId: "page-after",
selectedInstanceSelector: undefined,
allSelectedInstanceSelectors: [["box-after"], [10]],
},
});
expect($selectedPageId.get()).toBe("page-before");
expect($selectedInstanceSelector.get()).toEqual(["body-before"]);
});
});
describe("createObjectPool", () => {
test("syncs resource readiness to the canvas", () => {
const objectPool = createObjectPool();
objectPool.applyTransaction({
id: "resources-loaded",
object: "resourcesState",
payload: "settled",
});
expect($resourcesState.get()).toBe("settled");
});
test("syncs selected instance outlines for builder canvas overlay", () => {
const objectPool = createObjectPool();
objectPool.applyTransaction({
id: "selected-outlines",
object: "selectedInstanceOutlines",
payload: [
{
selector: ["box", "body"],
instanceId: "box",
rect: { left: 10, top: 20, width: 100, height: 50 },
},
{
selector: ["heading", "body"],
instanceId: "heading",
rect: { left: 30, top: 40, width: 120, height: 60 },
},
],
});
expect(
$selectedInstanceOutlines.get().map((outline) => outline.selector)
).toEqual([
["box", "body"],
["heading", "body"],
]);
});
});