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

449 lines
14 KiB
TypeScript

import { Store, type Change } from "immerhin";
import { enableMapSet, setAutoFreeze } from "immer";
import { useEffect } from "react";
import { batched } from "nanostores";
import { createId } from "@webstudio-is/sdk";
import { $project } from "./data-stores";
import {
$selectedPageHash,
$selectedInstanceSizes,
$authTokenPermissions,
$toastErrors,
$selectedStyleSources,
$selectedStyleState,
$dataSourceVariables,
$dragAndDropState,
$selectedInstanceStates,
$canvasIframeState,
$uploadingFilesDataStore,
$memoryProps,
$detectedFontsWeights,
$builderMode,
$selectedBreakpointId,
$textEditingInstanceSelector,
$textEditorContextMenu,
$textEditorContextMenuCommand,
$isResizingCanvas,
$collaborativeInstanceRect,
$collaborativeInstanceSelector,
$blockChildOutline,
$textToolbar,
$gridCellData,
$registeredComponentMetas,
$registeredTemplates,
$modifierKeys,
$instanceContextMenu,
$selectedInstanceRenderState,
$hoveredInstanceSelector,
$selectedInstanceOutline,
$selectedInstanceOutlines,
$hoveredInstanceOutline,
} from "~/shared/nano-states";
import {
$marketplaceProduct,
$projectSettings,
} from "~/shared/sync/data-stores";
import { $ephemeralStyles } from "~/canvas/stores";
import {
ImmerhinSyncObject,
NanostoresSyncObject,
SyncClient,
SyncObjectPool,
} from "../sync-client";
import type {
RevertedTransaction,
SyncEmitter,
Transaction,
} from "@webstudio-is/sync-client";
import { $canvasScrollbarSize } from "~/builder/shared/nano-states";
import {
$pages,
$instances,
$props,
$dataSources,
$breakpoints,
$styles,
$styleSources,
$styleSourceSelections,
$assets,
$assetFolders,
$resources,
} from "~/shared/sync/data-stores";
import { $pointerPosition } from "../awareness";
import { $temporaryInstances } from "../nano-states";
import {
$allSelectedInstanceSelectors,
$instanceSelectionUpdate,
$selectedInstanceSelector,
selectInstance,
selectInstances,
} from "../nano-states/instance-selection";
import { $selectedPageId } from "../nano-states/pages";
import { areSelectorListsEqual } from "../instance-utils/selection";
import { $systemDataByPage } from "../system";
import { $resourcesCache, $resourcesState } from "../resources";
import {
areInstanceSelectorsEqual,
type InstanceSelector,
} from "@webstudio-is/project-build/runtime";
import { $externalContentRoots } from "../external-content-mutations";
import { $externalContentHistory } from "../external-content-history";
enableMapSet();
// safari structuredClone fix
setAutoFreeze(false);
export const clientSyncStore = new Store();
export const serverSyncStore = new Store();
export const externalContentSyncStore = new Store();
const serverSyncRevertListeners = new Set<
(changes: readonly Change[]) => void
>();
export const subscribeServerSyncReverts = (
listener: (changes: readonly Change[]) => void
) => {
serverSyncRevertListeners.add(listener);
return () => serverSyncRevertListeners.delete(listener);
};
const serverSyncStores = {
pages: $pages,
breakpoints: $breakpoints,
instances: $instances,
styles: $styles,
styleSources: $styleSources,
styleSourceSelections: $styleSourceSelections,
props: $props,
dataSources: $dataSources,
resources: $resources,
assets: $assets,
assetFolders: $assetFolders,
projectSettings: $projectSettings,
marketplaceProduct: $marketplaceProduct,
} as const;
type ServerSyncStoreName = keyof typeof serverSyncStores;
type ServerSyncStoreValue = {
[Name in ServerSyncStoreName]: ReturnType<
(typeof serverSyncStores)[Name]["get"]
>;
}[ServerSyncStoreName];
export type ServerSyncState = Map<ServerSyncStoreName, ServerSyncStoreValue>;
export const serverSyncStoreNames = Object.keys(
serverSyncStores
) as ReadonlyArray<ServerSyncStoreName>;
export const registerContainers = () => {
// synchronize patches
for (const name of serverSyncStoreNames) {
serverSyncStore.register<ServerSyncStoreValue>(
name,
serverSyncStores[name]
);
externalContentSyncStore.register<ServerSyncStoreValue>(
name,
serverSyncStores[name]
);
}
};
type SelectedPageAndInstance = {
selectedPageId: string | undefined;
selectedInstanceSelector: InstanceSelector | undefined;
allSelectedInstanceSelectors: InstanceSelector[];
};
const isInstanceSelector = (value: unknown): value is InstanceSelector =>
Array.isArray(value) && value.every((id) => typeof id === "string");
const isInstanceSelectorList = (value: unknown): value is InstanceSelector[] =>
Array.isArray(value) && value.every(isInstanceSelector);
const areSelectedPageAndInstanceEqual = (
left: SelectedPageAndInstance,
right: SelectedPageAndInstance
) =>
left.selectedPageId === right.selectedPageId &&
(left.selectedInstanceSelector === undefined
? right.selectedInstanceSelector === undefined
: right.selectedInstanceSelector !== undefined &&
areInstanceSelectorsEqual(
left.selectedInstanceSelector,
right.selectedInstanceSelector
)) &&
areSelectorListsEqual(
left.allSelectedInstanceSelectors,
right.allSelectedInstanceSelectors
);
const $selectedPageAndInstance = batched(
[
$selectedPageId,
$selectedInstanceSelector,
$allSelectedInstanceSelectors,
$instanceSelectionUpdate,
],
(
selectedPageId,
selectedInstanceSelector,
allSelectedInstanceSelectors,
selectionUpdate
) => ({
state: {
selectedPageId,
selectedInstanceSelector,
allSelectedInstanceSelectors,
} satisfies SelectedPageAndInstance,
selectionUpdate,
})
);
class SelectedPageAndInstanceSyncObject {
name = "selectedPageAndInstance";
private operation: "local" | "state" | "add" = "local";
private stateToIgnore: SelectedPageAndInstance | undefined;
private lastSelectionRevision = $instanceSelectionUpdate.get().revision;
private lastSelectedPageId = $selectedPageId.get();
getState() {
return $selectedPageAndInstance.get().state;
}
setState(state: unknown) {
this.applyRemoteState(state, "state");
}
applyTransaction(transaction: Transaction) {
this.applyRemoteState(transaction.payload, "add");
}
revertTransaction(_transaction: RevertedTransaction) {
// @todo store the list of transactions
}
subscribe(
sendTransaction: (transaction: Transaction) => void,
signal: AbortSignal
) {
this.lastSelectionRevision = $instanceSelectionUpdate.get().revision;
this.lastSelectedPageId = $selectedPageId.get();
const unsubscribe = $selectedPageAndInstance.listen(
({ state, selectionUpdate }) => {
const isDerivedPruning =
selectionUpdate.revision !== this.lastSelectionRevision &&
selectionUpdate.origin === "instance-pruning";
const didSelectedPageChange =
state.selectedPageId !== this.lastSelectedPageId;
this.lastSelectionRevision = selectionUpdate.revision;
this.lastSelectedPageId = state.selectedPageId;
if (this.stateToIgnore !== undefined) {
const shouldIgnore = areSelectedPageAndInstanceEqual(
state,
this.stateToIgnore
);
this.stateToIgnore = undefined;
if (shouldIgnore) {
return;
}
}
if (
this.operation !== "local" ||
(isDerivedPruning && didSelectedPageChange === false)
) {
return;
}
sendTransaction({
id: createId("nano"),
object: this.name,
payload: state,
});
}
);
signal.addEventListener("abort", unsubscribe);
}
private applyRemoteState(state: unknown, operation: "state" | "add") {
this.operation = operation;
try {
const appliedState = this.setSelectedPageAndInstance(
operation === "add" ? structuredClone(state) : state
);
if (appliedState !== undefined) {
this.stateToIgnore = appliedState;
}
} catch (error) {
console.error(error);
} finally {
this.operation = "local";
}
}
private setSelectedPageAndInstance(
state: unknown
): SelectedPageAndInstance | undefined {
if (typeof state !== "object" || state === null) {
return;
}
const {
selectedPageId,
selectedInstanceSelector,
allSelectedInstanceSelectors,
} = state as Partial<SelectedPageAndInstance>;
if (selectedPageId !== undefined && typeof selectedPageId !== "string") {
return;
}
if (
selectedInstanceSelector !== undefined &&
isInstanceSelector(selectedInstanceSelector) === false
) {
return;
}
if (
allSelectedInstanceSelectors !== undefined &&
isInstanceSelectorList(allSelectedInstanceSelectors) === false
) {
return;
}
$selectedPageId.set(selectedPageId);
if (allSelectedInstanceSelectors !== undefined) {
selectInstances(allSelectedInstanceSelectors);
} else {
selectInstance(selectedInstanceSelector);
}
return {
selectedPageId: $selectedPageId.get(),
selectedInstanceSelector: $selectedInstanceSelector.get(),
allSelectedInstanceSelectors: $allSelectedInstanceSelectors.get(),
};
}
}
export const __testing__ = {
SelectedPageAndInstanceSyncObject,
};
export const createObjectPool = () => {
return new SyncObjectPool([
new ImmerhinSyncObject("server", serverSyncStore, {
onRevert: (changes) => {
for (const listener of serverSyncRevertListeners) {
listener(changes);
}
},
}),
new ImmerhinSyncObject("externalContent", externalContentSyncStore),
new ImmerhinSyncObject("client", clientSyncStore),
new SelectedPageAndInstanceSyncObject(),
new NanostoresSyncObject("pointerPosition", $pointerPosition),
new NanostoresSyncObject("temporaryInstances", $temporaryInstances),
new NanostoresSyncObject("project", $project),
new NanostoresSyncObject("dataSourceVariables", $dataSourceVariables),
new NanostoresSyncObject("resourcesCache", $resourcesCache),
new NanostoresSyncObject("resourcesState", $resourcesState),
new NanostoresSyncObject("selectedPageHash", $selectedPageHash),
new NanostoresSyncObject("selectedInstanceSizes", $selectedInstanceSizes),
new NanostoresSyncObject(
"selectedInstanceRenderState",
$selectedInstanceRenderState
),
new NanostoresSyncObject(
"hoveredInstanceSelector",
$hoveredInstanceSelector
),
new NanostoresSyncObject(
"selectedInstanceOutline",
$selectedInstanceOutline
),
new NanostoresSyncObject(
"selectedInstanceOutlines",
$selectedInstanceOutlines
),
new NanostoresSyncObject("hoveredInstanceOutline", $hoveredInstanceOutline),
new NanostoresSyncObject("builderMode", $builderMode),
new NanostoresSyncObject("authTokenPermissions", $authTokenPermissions),
new NanostoresSyncObject("toastErrors", $toastErrors),
new NanostoresSyncObject("selectedStyleSources", $selectedStyleSources),
new NanostoresSyncObject("selectedStyleState", $selectedStyleState),
new NanostoresSyncObject("dragAndDropState", $dragAndDropState),
new NanostoresSyncObject("ephemeralStyles", $ephemeralStyles),
new NanostoresSyncObject("selectedInstanceStates", $selectedInstanceStates),
new NanostoresSyncObject("canvasIframeState", $canvasIframeState),
new NanostoresSyncObject(
"uploadingFilesDataStore",
$uploadingFilesDataStore
),
new NanostoresSyncObject("memoryProps", $memoryProps),
new NanostoresSyncObject("detectedFontsWeights", $detectedFontsWeights),
new NanostoresSyncObject("selectedBreakpointId", $selectedBreakpointId),
new NanostoresSyncObject(
"textEditingInstanceSelector",
$textEditingInstanceSelector
),
new NanostoresSyncObject("textEditorContextMenu", $textEditorContextMenu),
new NanostoresSyncObject(
"textEditorContextMenuCommand",
$textEditorContextMenuCommand
),
new NanostoresSyncObject("isResizingCanvas", $isResizingCanvas),
new NanostoresSyncObject("textToolbar", $textToolbar),
new NanostoresSyncObject("blockChildOutline", $blockChildOutline),
new NanostoresSyncObject("instanceContextMenu", $instanceContextMenu),
new NanostoresSyncObject("modifierKeys", $modifierKeys),
new NanostoresSyncObject("gridCellData", $gridCellData),
new NanostoresSyncObject(
"collaborativeInstanceSelector",
$collaborativeInstanceSelector
),
new NanostoresSyncObject(
"collaborativeInstanceRect",
$collaborativeInstanceRect
),
new NanostoresSyncObject(
"registeredComponentMetas",
$registeredComponentMetas
),
new NanostoresSyncObject("registeredTemplates", $registeredTemplates),
new NanostoresSyncObject("canvasScrollbarWidth", $canvasScrollbarSize),
new NanostoresSyncObject("systemDataByPage", $systemDataByPage),
new NanostoresSyncObject("externalContentRoots", $externalContentRoots),
new NanostoresSyncObject("externalContentHistory", $externalContentHistory),
]);
};
declare global {
interface Window {
__webstudioSharedSyncEmitter__: SyncEmitter | undefined;
}
}
/**
* prevent syncEmitter interception from embedded scripts on canvas
* i.e., `globalThis.syncEmitter = () => console.log('INTERCEPTED');`,
*/
const sharedSyncEmitter =
typeof window === "undefined"
? undefined
: window.__webstudioSharedSyncEmitter__;
if (typeof window !== "undefined") {
delete window.__webstudioSharedSyncEmitter__;
}
export const useCanvasStore = () => {
useEffect(() => {
const canvasClient = new SyncClient({
role: "follower",
object: createObjectPool(),
emitter: sharedSyncEmitter,
});
const controller = new AbortController();
canvasClient.connect({ signal: controller.signal });
return () => {
controller.abort();
};
}, []);
};