UNPKG

@tapcart/app-studio-utils

Version:

A utility library for Tapcart's App Studio

171 lines 8.56 kB
"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