Resolve props assets on client (#1005)

Before we checked looked for all asset ids in props, loaded assets and
replaced ids with resolved assets. This is all on server. We faced a few
problems with it.

- requires to maintain two versions of types: stored in db and resolved
for client
- easy to break project by writing to db wrong version

Here I provide to sdk both props and assets and resolve on client with
almost same amount of code.

Also replaced store setter with context, see
https://github.com/webstudio-is/webstudio-designer/issues/213
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2023-02-13 12:42:02 +03:00
1 parent 525e6e384d
commit 10ea732ee2
13 files changed
+112 -159

No files matched your search

+8 -8
View File
@@ -9,7 +9,6 @@ import {
customComponentsMeta,
setParams,
type OnChangeChildren,
setPropsByInstanceIdStore,
} from "@webstudio-is/react-sdk";
import { publish } from "~/shared/pubsub";
import { registerContainers, useCanvasStore } from "~/shared/sync";
@@ -21,6 +20,7 @@ import { useTrackSelectedElement } from "./shared/use-track-selected-element";
import { WrapperComponentDev } from "./features/wrapper-component";
import {
propsIndexStore,
assetsStore,
rootInstanceContainer,
useBreakpoints,
useRootInstance,
@@ -45,6 +45,11 @@ import { useHoveredInstanceConnector } from "./hovered-instance-connector";
registerContainers();
const propsByInstanceIdStore = computed(
propsIndexStore,
(propsIndex) => propsIndex.propsByInstanceId
);
const useElementsTree = () => {
const [rootInstance] = useRootInstance();
const [breakpoints] = useBreakpoints();
@@ -76,6 +81,8 @@ const useElementsTree = () => {
return createElementsTree({
sandbox: true,
instance: rootInstance,
propsByInstanceIdStore,
assetsStore,
Component: WrapperComponentDev,
onChangeChildren,
});
@@ -101,11 +108,6 @@ type CanvasProps = {
data: CanvasData;
};
const propsByInstanceIdStore = computed(
propsIndexStore,
(propsIndex) => propsIndex.propsByInstanceId
);
export const Canvas = ({ data }: CanvasProps): JSX.Element | null => {
if (data.build === null) {
throw new Error("Build is null");
@@ -118,8 +120,6 @@ export const Canvas = ({ data }: CanvasProps): JSX.Element | null => {
useSetAssets(data.assets);
useSetBreakpoints(data.build.breakpoints);
useSetProps(data.tree.props);
// inject props store to sdk
setPropsByInstanceIdStore(propsByInstanceIdStore);
useSetStyles(data.build.styles);
useSetStyleSources(data.build.styleSources);
useSetStyleSourceSelections(data.tree.styleSourceSelections);
@@ -1,19 +1,23 @@
import { computed } from "nanostores";
import warnOnce from "warn-once";
import { useMemo } from "react";
import { useStore } from "@nanostores/react";
import type { Instance, Prop } from "@webstudio-is/project-build";
import { getComponentMetaProps } from "@webstudio-is/react-sdk";
import warnOnce from "warn-once";
import type { Asset } from "@webstudio-is/asset-uploader";
import {
Flex,
DeprecatedLabel,
Checkbox,
Radio,
RadioGroup,
Select,
Switch,
TextField,
} from "@webstudio-is/design-system";
import { FloatingPanel } from "~/designer/shared/floating-panel/";
import { assetsStore } from "~/shared/nano-states";
import { FloatingPanel } from "~/designer/shared/floating-panel";
import { ImageManager } from "~/designer/shared/image-manager";
import { Checkbox } from "@webstudio-is/design-system";
import { Asset } from "@webstudio-is/asset-uploader";
import type { UserPropValue } from "./use-props-logic";
import { type SetProperty } from "../style-panel/shared/use-style-data";
@@ -143,11 +147,20 @@ const BooleanControl = ({
);
type ImageControlProps = {
asset: Asset | null;
assetId: undefined | Asset["id"];
onChange: (asset: Asset) => void;
};
const ImageControl = ({ asset, onChange }: ImageControlProps) => {
const ImageControl = ({ assetId, onChange }: ImageControlProps) => {
const assetStore = useMemo(() => {
return computed(assetsStore, (assets) => {
if (assetId === undefined) {
return undefined;
}
return assets.get(assetId);
});
}, [assetId]);
const asset = useStore(assetStore);
return (
<FloatingPanel
title="Images"
@@ -216,15 +229,15 @@ export function Control({
}
if (component === "Image" && userProp.name === "src") {
const asset = userProp.type === "asset" ? userProp.value : null;
const assetId = userProp.type === "asset" ? userProp.value : undefined;
return (
<ImageControl
asset={asset}
onChange={(value) => {
onChangePropValue({ type: "asset", value });
assetId={assetId}
onChange={(asset) => {
onChangePropValue({ type: "asset", value: asset.id });
const { meta } = value;
const { meta } = asset;
if ("width" in meta && "height" in meta) {
// @todo: change on own type, pass width/hight separately
const aspectRatio = meta.width / meta.height;
@@ -243,7 +256,7 @@ export function Control({
if (userProp.type === "asset") {
throw new Error(
`userProp with id "${userProp.value.id}" has asset but no control exists to process it`
`userProp with id "${userProp.value}" has asset but no control exists to process it`
);
}
@@ -1,15 +1,16 @@
import { json } from "@remix-run/node";
import { db } from "@webstudio-is/project/server";
import { addGlobalRules, getStyleRules } from "@webstudio-is/project";
import { loadCanvasData } from "~/shared/db";
import { createCssEngine } from "@webstudio-is/css-engine";
import {
getComponentMeta,
getComponentNames,
idAttribute,
} from "@webstudio-is/react-sdk";
import type { BuildParams } from "../router-utils";
import type { AppContext } from "@webstudio-is/trpc-interface/server";
import type { Asset } from "@webstudio-is/asset-uploader";
import { loadCanvasData } from "~/shared/db";
import type { BuildParams } from "../router-utils";
export const generateCssText = async (
buildParams: BuildParams,
@@ -37,7 +38,10 @@ export const generateCssText = async (
const engine = createCssEngine({ name: "ssr" });
addGlobalRules(engine, canvasData);
const assets = new Map<Asset["id"], Asset>(
canvasData.assets.map((asset) => [asset.id, asset])
);
addGlobalRules(engine, { assets });
for (const breakpoint of canvasData.build?.breakpoints ?? []) {
engine.addMediaRule(breakpoint.id, breakpoint);
@@ -208,14 +208,15 @@ export const assetContainersStore = atom<
>([]);
export const assetsStore = computed(assetContainersStore, (assetContainers) => {
const assets: Asset[] = [];
const assets = new Map<Asset["id"], Asset>();
for (const assetContainer of assetContainers) {
if (assetContainer.status === "uploaded") {
assets.push(assetContainer.asset);
assets.set(assetContainer.asset.id, assetContainer.asset);
}
}
return assets;
});
export const assetsIndex = computed;
export const useSetAssets = (assets: Asset[]) => {
useSyncInitializeOnce(() => {
+5 -22
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { Asset } from "@webstudio-is/asset-uploader";
const PropId = z.string();
@@ -10,7 +9,7 @@ const baseProp = {
required: z.optional(z.boolean()),
};
const SharedProp = z.union([
export const Prop = z.union([
z.object({
...baseProp,
type: z.literal("number"),
@@ -26,35 +25,19 @@ const SharedProp = z.union([
type: z.literal("boolean"),
value: z.boolean(),
}),
]);
export const StoredProp = z.union([
SharedProp,
z.object({
...baseProp,
type: z.literal("asset"),
// asset.id is stored in database
value: z.string(),
}),
]);
export type StoredProp = z.infer<typeof StoredProp>;
export const StoredProps = z.array(StoredProp);
export type StoredProps = z.infer<typeof StoredProps>;
export const Prop = z.union([
SharedProp,
z.object({
...baseProp,
type: z.literal("asset"),
value: Asset,
}),
]);
export type Prop = z.infer<typeof Prop>;
export const PropsList = z.array(Prop);
export type PropsList = z.infer<typeof PropsList>;
export const Props = z.map(PropId, Prop);
export type Props = z.infer<typeof Props>;
+8 -74
View File
@@ -1,5 +1,4 @@
import warnOnce from "warn-once";
import { type Tree, StoredProps, Props } from "@webstudio-is/project-build";
import { type Tree, Props, PropsList } from "@webstudio-is/project-build";
import { applyPatches, type Patch } from "immer";
import { prisma } from "@webstudio-is/prisma-client";
import type { Project } from "../shared/schema";
@@ -7,77 +6,15 @@ import {
authorizeProject,
type AppContext,
} from "@webstudio-is/trpc-interface/server";
import { loadByIds } from "@webstudio-is/asset-uploader/server";
export const parseProps = async (
props: { propsString: string; projectId: Project["id"] },
context: AppContext
) => {
const storedProps = StoredProps.parse(JSON.parse(props.propsString));
// find all asset ids in all props
const assetIds: string[] = [];
for (const prop of storedProps) {
if (prop.type === "asset") {
assetIds.push(prop.value);
}
}
// load all assets
const assets = await loadByIds(
{ ids: assetIds, projectId: props.projectId },
context
);
const assetsMap = new Map(assets.map((asset) => [asset.id, asset]));
const convertedProps: Props = new Map();
for (const prop of storedProps) {
if (prop.type === "asset") {
const assetId = prop.value;
const asset = assetsMap.get(assetId);
if (asset) {
convertedProps.set(prop.id, {
id: prop.id,
instanceId: prop.instanceId,
name: prop.name,
required: prop.required,
type: prop.type,
value: asset,
});
continue;
}
warnOnce(true, `Asset with assetId "${assetId}" not found`);
continue;
}
convertedProps.set(prop.id, prop);
}
return convertedProps;
export const parseProps = (propsString: string): Props => {
const propsList = PropsList.parse(JSON.parse(propsString));
return new Map(propsList.map((prop) => [prop.id, prop]));
};
export const serializeProps = (props: Props) => {
const storedProps: StoredProps = [];
for (const prop of props.values()) {
if (prop.type === "asset") {
storedProps.push({
id: prop.id,
instanceId: prop.instanceId,
name: prop.name,
required: prop.required,
type: prop.type,
value: prop.value.id,
});
continue;
}
storedProps.push(prop);
}
return JSON.stringify(storedProps);
const propsList: PropsList = Array.from(props.values());
return JSON.stringify(propsList);
};
export const patch = async (
@@ -102,11 +39,8 @@ export const patch = async (
if (tree === null) {
return;
}
const props = await parseProps(
{ propsString: tree.props, projectId },
context
);
const patchedProps = applyPatches(props, patches);
const props = parseProps(tree.props);
const patchedProps = Props.parse(applyPatches(props, patches));
await prisma.tree.update({
data: {
+2 -5
View File
@@ -126,7 +126,7 @@ const denormalizeTree = (instances: z.infer<typeof Instances>) => {
export const loadById = async (
{ projectId, treeId }: { projectId: Project["id"]; treeId: Tree["id"] },
context: AppContext,
_context: AppContext,
client: Prisma.TransactionClient = prisma
): Promise<Tree | null> => {
const tree = await client.tree.findUnique({
@@ -141,10 +141,7 @@ export const loadById = async (
const instances = Instances.parse(JSON.parse(tree.instances));
const root = Instance.parse(denormalizeTree(instances));
const props = await parseProps(
{ propsString: tree.props, projectId },
context
);
const props = parseProps(tree.props);
const styleSourceSelections = parseStyleSourceSelections(
tree.styleSelections
@@ -8,16 +8,20 @@ import {
export const addGlobalRules = (
engine: CssEngine,
{ assets = [] }: { assets?: Array<Asset> }
{ assets }: { assets: Map<Asset["id"], Asset> }
) => {
// @todo we need to figure out all global resets while keeping
// the engine aware of all of them.
// Ideally, the user is somehow aware and in control of the reset
engine.addPlaintextRule("html {margin: 0; height: 100%}");
const fontAssets = assets.filter((asset) =>
FONT_FORMATS.has(asset.format as FontFormat)
) as Array<FontAsset>;
const fontAssets: Array<FontAsset> = [];
for (const asset of assets.values()) {
if (FONT_FORMATS.has(asset.format as FontFormat)) {
fontAssets.push(asset as FontAsset);
}
}
const fontFaces = getFontFaces(fontAssets);
for (const fontFace of fontFaces) {
engine.addFontFaceRule(fontFace);
+11
View File
@@ -0,0 +1,11 @@
import { type ReadableAtom, atom } from "nanostores";
import { createContext } from "react";
import type { Assets, PropsByInstanceId } from "./props";
export const ReactSdkContext = createContext<{
propsByInstanceIdStore: ReadableAtom<PropsByInstanceId>;
assetsStore: ReadableAtom<Assets>;
}>({
propsByInstanceIdStore: atom(new Map()),
assetsStore: atom(new Map()),
});
-1
View File
@@ -8,4 +8,3 @@ export {
customComponentsMeta,
} from "./app/custom-components";
export type { MetaProps, WsComponentMeta } from "./components/component-type";
export { setPropsByInstanceIdStore } from "./props";
+18 -22
View File
@@ -1,22 +1,13 @@
import { useMemo } from "react";
import { atom, computed, type ReadableAtom } from "nanostores";
import { useContext, useMemo } from "react";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import type { Instance, Prop, Props } from "@webstudio-is/project-build";
import type { Asset } from "@webstudio-is/asset-uploader";
import { ReactSdkContext } from "./context";
type PropsByInstanceId = Map<Instance["id"], Prop[]>;
export type PropsByInstanceId = Map<Instance["id"], Prop[]>;
type PropsByInstanceIdStore = ReadableAtom<PropsByInstanceId>;
let propsByInstanceIdStore: PropsByInstanceIdStore = atom(new Map());
export const setPropsByInstanceIdStore = (store: PropsByInstanceIdStore) => {
propsByInstanceIdStore = store;
};
export const getPropsByInstanceIdStore = () => {
return propsByInstanceIdStore;
};
export type Assets = Map<Asset["id"], Asset>;
export const getPropsByInstanceId = (props: Props) => {
const propsByInstanceId: PropsByInstanceId = new Map();
@@ -34,6 +25,7 @@ export const getPropsByInstanceId = (props: Props) => {
// this utility is be used only for preview with static props
// so there is no need to use computed to optimize rerenders
export const useInstanceProps = (instanceId: Instance["id"]) => {
const { propsByInstanceIdStore } = useContext(ReactSdkContext);
const propsByInstanceId = useStore(propsByInstanceIdStore);
const instanceProps = propsByInstanceId.get(instanceId);
const instancePropsObject: Record<string, number | string | boolean> = {};
@@ -50,20 +42,24 @@ export const useInstanceProps = (instanceId: Instance["id"]) => {
// this utility is be used for image component in both designer and preview
// so need to optimize rerenders with computed
export const usePropAsset = (instanceId: Instance["id"], name: string) => {
const { propsByInstanceIdStore, assetsStore } = useContext(ReactSdkContext);
const assetStore = useMemo(() => {
return computed(propsByInstanceIdStore, (propsByInstanceId) => {
const instanceProps = propsByInstanceId.get(instanceId);
let asset: undefined | Asset = undefined;
if (instanceProps) {
return computed(
[propsByInstanceIdStore, assetsStore],
(propsByInstanceId, assets) => {
const instanceProps = propsByInstanceId.get(instanceId);
if (instanceProps === undefined) {
return undefined;
}
for (const prop of instanceProps) {
if (prop.type === "asset" && prop.name === name) {
asset = prop.value;
const assetId = prop.value;
return assets.get(assetId);
}
}
}
return asset;
});
}, [instanceId, name]);
);
}, [propsByInstanceIdStore, assetsStore, instanceId, name]);
const asset = useStore(assetStore);
return asset;
};
@@ -1,6 +1,9 @@
import { type ComponentProps, Fragment } from "react";
import { ReadableAtom } from "nanostores";
import { Scripts, ScrollRestoration } from "@remix-run/react";
import type { Instance } from "@webstudio-is/project-build";
import { ReactSdkContext } from "../context";
import { Assets, PropsByInstanceId } from "../props";
import { WrapperComponent } from "./wrapper-component";
import { SessionStoragePolyfill } from "./session-storage-polyfill";
@@ -25,11 +28,15 @@ export type OnChangeChildren = (change: {
export const createElementsTree = ({
sandbox,
instance,
propsByInstanceIdStore,
assetsStore,
Component,
onChangeChildren,
}: {
sandbox?: boolean;
instance: Instance;
propsByInstanceIdStore: ReadableAtom<PropsByInstanceId>;
assetsStore: ReadableAtom<Assets>;
Component: (props: ComponentProps<typeof WrapperComponent>) => JSX.Element;
onChangeChildren?: OnChangeChildren;
}) => {
@@ -50,7 +57,11 @@ export const createElementsTree = ({
</Fragment>,
],
});
return body;
return (
<ReactSdkContext.Provider value={{ propsByInstanceIdStore, assetsStore }}>
{body}
</ReactSdkContext.Provider>
);
};
const createInstanceChildrenElements = ({
+5 -5
View File
@@ -7,7 +7,7 @@ import { WrapperComponent } from "./wrapper-component";
import { registerComponents } from "../components";
import { customComponents as defaultCustomComponents } from "../app/custom-components";
import { setParams, type Params } from "../app/params";
import { getPropsByInstanceId, setPropsByInstanceIdStore } from "../props";
import { getPropsByInstanceId } from "../props";
export type Data = {
tree: Tree | null;
@@ -30,16 +30,16 @@ export const InstanceRoot = ({
throw new Error("Tree is null");
}
setPropsByInstanceIdStore(
atom(getPropsByInstanceId(new Map(data.tree.props)))
);
setParams(data.params ?? null);
registerComponents(customComponents);
return createElementsTree({
instance: data.tree.root,
propsByInstanceIdStore: atom(
getPropsByInstanceId(new Map(data.tree.props))
),
assetsStore: atom(new Map(data.assets.map((asset) => [asset.id, asset]))),
Component: Component ?? WrapperComponent,
});
};