mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 23:57:17 +09:00
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
449 lines
14 KiB
TypeScript
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();
|
|
};
|
|
}, []);
|
|
};
|