@tapcart/app-studio-utils
Version:
A utility library for Tapcart's App Studio
171 lines • 8.56 kB
JavaScript
"use client";
import { jsx as _jsx } from "react/jsx-runtime";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { generateElement } from "../elements";
// Safe useTransition hook that falls back for older React versions
const useSafeTransition = () => {
// Check if useTransition exists at module level
const hasUseTransition = "useTransition" in React && typeof React.useTransition === "function";
if (hasUseTransition) {
// eslint-disable-next-line react-hooks/rules-of-hooks
return React.useTransition();
}
// Fallback for older React versions
return [false, (fn) => fn()];
};
const appStudioComponentImportRegex = /import\s+{\s*([^}]+)\s*}\s+from\s+["']@tapcart\/app-studio-components["']/;
const AppStudioComponentsContext = React.createContext({
appStudioComponents: {},
setComponentScope: () => { },
isReady: false,
});
export const AppStudioComponentsProvider = ({ children, componentInputs, scopeInput, }) => {
// Component scope should be set once and then not updated again unless the page state is changed.
// We expose a function for updating the scope manually if needed. (Ex: Server scope vs client scope and we need client scope here)
const [componentScope, setComponentScope] = useState(null);
const [scopeOverrides, setScopeOverrides] = useState({});
const [isPending, startTransition] = useSafeTransition();
const scopeHasChanges = useCallback((oldScope, newScope) => {
// Remove page state from comparison
// Page state is static except for the search params - search params are delivered through useSearchParams hook
// Do not need to regenerate components if only search params have changed. Also provides better user experience
// Copy objects first to prevent unintended mutations of the original scope objects
const scope1 = Object.assign({}, oldScope);
const scope2 = Object.assign({}, newScope);
delete scope1.pageState;
delete scope2.pageState;
// JSON.stringify will remove undefined values and functions
// Compare the number of keys to determine if the scope has changed
if (Object.keys(scope1).length !== Object.keys(scope2).length) {
return true;
}
return JSON.stringify(scope1) !== JSON.stringify(scope2);
}, []);
useEffect(() => {
const currentScope = componentScope || scopeInput;
if (!currentScope)
return;
let newScope = Object.assign({}, currentScope);
// Remove block specific properties from scope overrides as this is not a block
delete newScope.blockConfig;
delete newScope.blockId;
// TODO: Implement manifest options
newScope.componentConfig = {};
// Check if the scope has changed
if (!scopeHasChanges(scopeOverrides, newScope))
return;
startTransition(() => {
setScopeOverrides(newScope);
});
}, [componentScope, scopeInput]);
// Responsible for instantiating a component and its dependencies
const instantiateComponent = useCallback((component, componentCache) => {
// Components can reference each other. When raw `code` is present, scan
// it for ES module imports. When it has been stripped (production RSC
// path), scan the transpiled CJS output instead: Sucrase binds the
// require result to a local variable and accesses named exports as
// properties, so we find the binding name then collect every property
// access on it.
let resolvedDependencies;
if (component.code) {
const importMatch = component.code.match(appStudioComponentImportRegex);
if (importMatch === null || importMatch === void 0 ? void 0 : importMatch[1]) {
resolvedDependencies = importMatch[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
else {
const requireMatch = component.transpiledCode.match(/var\s+(\w+)\s*=\s*require\(["']@tapcart\/app-studio-components["']\)/);
if (requireMatch === null || requireMatch === void 0 ? void 0 : requireMatch[1]) {
const varName = requireMatch[1];
const usageRegex = new RegExp(`\\b${varName}\\.(\\w+)`, "g");
const deps = new Set();
let m;
while ((m = usageRegex.exec(component.transpiledCode)) !== null) {
deps.add(m[1]);
}
if (deps.size > 0)
resolvedDependencies = Array.from(deps);
}
}
if (resolvedDependencies === null || resolvedDependencies === void 0 ? void 0 : resolvedDependencies.length) {
// For each dependency, create that component and store it in the cache
for (const dependency of resolvedDependencies) {
if (!dependency || componentCache[dependency])
continue;
// Find dependency in componentInputs
const dependencyComponent = componentInputs[dependency];
if (!dependencyComponent) {
console.warn(`App studio component "${dependency}" not found in componentInputs`);
continue;
}
const dependencyComponentInstance = instantiateComponent(dependencyComponent, componentCache);
if (!dependencyComponentInstance)
continue;
componentCache[dependency] = dependencyComponentInstance;
}
}
// With dependencies loaded, instantiate the component
const AppStudioComponent = generateElement({
code: component.transpiledCode,
scopeOverrides,
appStudioComponents: componentCache,
});
if (!AppStudioComponent) {
console.error(`Failed to instantiate app studio component ${component.key}`);
return null;
}
// Return function that renders the component. Do this so we can inject our scope as well as block related props.
const Component = (props) => (_jsx(AppStudioComponent, Object.assign({}, scopeOverrides, props)));
Component.displayName = "AppStudioComponent";
return Component;
},
// componentInputs MUST be a dependency: instantiateComponent reads it
// when resolving nested @tapcart/app-studio-components imports. Omitting
// it captured a stale (often empty) componentInputs, so on first paint
// after the components fetch resolved, dependency lookups (e.g. a block
// importing ProductCard) failed with "not found in componentInputs" and
// the component rendered `undefined`, crashing its ErrorBoundary.
[scopeOverrides, componentInputs]);
// Full output of instantiated components
const appStudioComponents = useMemo(() => {
if (isPending)
return {};
let componentCache = {};
// Turn app studio component inputs into a map of component name to component function
const components = Object.keys(componentInputs).reduce((acc, key) => {
if (!componentInputs[key])
return acc;
const component = instantiateComponent(componentInputs[key], componentCache);
if (!component)
return acc;
acc[key] = component;
return acc;
}, {});
// Use Proxy to return () => null for components that exist but shouldn't render due to missing scope overrides
return new Proxy(components, {
get(target, prop) {
// If useActions is not present in scope overrides, return null for any component
if (!scopeOverrides.useActions) {
return () => null;
}
return target[prop];
},
});
}, [scopeOverrides, componentInputs]);
return (_jsx(AppStudioComponentsContext.Provider, { value: {
appStudioComponents,
setComponentScope,
isReady: !isPending,
}, children: children }));
};
export const useAppStudioComponents = () => {
const context = React.useContext(AppStudioComponentsContext);
if (context === undefined) {
throw new Error("useAppStudioComponents must be used within a AppStudioComponentsProvider");
}
return context;
};
//# sourceMappingURL=index.js.map