Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/stores/studioStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
registerStudioPageScripts,
unregisterStudioPageScripts,
} from "@/data/studioPageScripts"
import { setCustomComponentFilePaths } from "@/utils/components"
import { registerCustomComponentPaths } from "@/utils/components"
import type { CustomVueComponentMeta } from "@/types/vue"

import type { StudioApp } from "@/types/Studio/StudioApp"
Expand Down Expand Up @@ -443,7 +443,7 @@ const useStudioStore = defineStore("store", () => {
// custom components
async function setCustomComponents() {
await loadCustomVueComponents()
setCustomComponentFilePaths(customVueComponents.value)
await registerCustomComponentPaths(customVueComponents.value)
}

async function loadCustomVueComponents() {
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/utils/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import LucideCode from "~icons/lucide/code"

import { generateId, isObjectEmpty, kebabToCamelCase, numberToPx } from "./helpers";
import { copyObject, getBlockCopy, getComponentBlock } from "@/utils/serializer"
import { componentHasDefaultSlot, getComponentSlots } from "@/utils/components"

import type { StyleValue, FrappeUIComponents } from "@/types"
import type { ComponentEvent } from "@/types/ComponentEvent"
Expand Down Expand Up @@ -72,6 +73,8 @@ class Block implements BlockOptions {
}
if (options.isCustomVueComponent) {
this.isCustomVueComponent = options.isCustomVueComponent
// Warm the slot cache so canHaveChildren() knows this component's default slot
void getComponentSlots(this.componentName, true)
}

// get component props
Expand Down Expand Up @@ -219,10 +222,21 @@ class Block implements BlockOptions {
}

canHaveChildren() {
if (this.isRoot() || this.isContainer() || this.hasComponentSlots() || this.hasChildren()) return true
if (
this.isRoot() ||
this.isContainer() ||
this.hasComponentSlots() ||
this.hasChildren() ||
this.hasDefaultSlot()
)
return true
return false
}

hasDefaultSlot() {
return componentHasDefaultSlot(this.componentName)
}

isRoot() {
return this.componentId === "root" || this.originalElement === "body";
}
Expand Down
71 changes: 45 additions & 26 deletions frontend/src/utils/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { VueProp, VuePropType } from "@/types/vue"

import * as jsonTypes from "@/json_types"
import { isObjectEmpty } from "@/utils/helpers"
import { ConcreteComponent } from "vue"
import { ConcreteComponent, reactive } from "vue"
import type { CustomVueComponentMeta } from "@/types/vue"

interface ComponentTypes {
Expand Down Expand Up @@ -219,11 +219,13 @@ const frameworkUIComponentPaths: Record<string, string> = {
const templateCache = new Map<string, string>()

const customComponentFilePaths = new Map<string, string>()
function setCustomComponentFilePaths(components: CustomVueComponentMeta[]) {

async function registerCustomComponentPaths(components: CustomVueComponentMeta[]) {
customComponentFilePaths.clear()
for (const comp of components) {
customComponentFilePaths.set(comp.component_name, comp.file_path)
}
await Promise.all(components.map((comp) => getComponentSlots(comp.component_name, true)))
}

function getComponentTemplate(componentName: string): string {
Expand Down Expand Up @@ -281,8 +283,11 @@ async function fetchCustomComponentTemplate(componentName: string): Promise<stri
if (!filePath) return ""

try {
// Use Vite's ?raw import to get unprocessed file content as a string
const module = await import(/* @vite-ignore */ `${filePath}?raw`)
// Use Vite's ?raw import to get unprocessed file content as a string. In dev, a unique
// query busts the browser's ES-module cache so a re-fetch after invalidateComponentCache
// (HMR content edit) gets the new source instead of the stale cached module.
const cacheBust = import.meta.env.DEV ? `&t=${Date.now()}` : ""
const module = await import(/* @vite-ignore */ `${filePath}?raw${cacheBust}`)
const rawSource = module.default || ""
if (rawSource) {
templateCache.set(componentName, rawSource)
Expand All @@ -296,35 +301,42 @@ async function fetchCustomComponentTemplate(componentName: string): Promise<stri

function parseSlotsFromTemplate(template: string) {
const slotRegex = /<slot\s*(?:name=["']([^"']*)?["'])?(?:\s*\/>|\s*>(.*?)<\/slot>)?/gi
const slots = []
const slots = new Map<string, { name: string; type: "named" | "default" }>()
let match

while ((match = slotRegex.exec(template)) !== null) {
// Named slot with name attribute
const namedSlot = match[1]
// Default/unnamed slot or slot content
const defaultSlotContent = match[2]

if (namedSlot) {
slots.push({
name: namedSlot,
type: "named",
hasDefaultContent: !!defaultSlotContent,
})
} else if (defaultSlotContent || match[0].includes("<slot")) {
slots.push({
name: "default",
type: "default",
hasDefaultContent: !!defaultSlotContent,
})
const name = match[1] || "default"
if (!slots.has(name)) {
slots.set(name, { name, type: match[1] ? "named" : "default" })
}
}
return slots
return [...slots.values()]
}

const slotsCache = reactive(new Map<string, ReturnType<typeof parseSlotsFromTemplate>>())

async function getComponentSlots(componentName: string, isCustomVueComponent?: boolean) {
const template = isCustomVueComponent ? await fetchCustomComponentTemplate(componentName) : getComponentTemplate(componentName)
return parseSlotsFromTemplate(template)
const cached = slotsCache.get(componentName)
if (cached) return cached
const template = isCustomVueComponent
? await fetchCustomComponentTemplate(componentName)
: getComponentTemplate(componentName)
const slots = parseSlotsFromTemplate(template)
if (template) slotsCache.set(componentName, slots)
return slots
}

function componentHasDefaultSlot(componentName: string): boolean {
if (!slotsCache.has(componentName)) {
const template = getComponentTemplate(componentName)
if (template) slotsCache.set(componentName, parseSlotsFromTemplate(template))
}
return (slotsCache.get(componentName) ?? []).some((slot) => slot.type === "default")
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function invalidateComponentCache(componentName: string) {
templateCache.delete(componentName)
slotsCache.delete(componentName)
}

function resolveProperty(
Expand Down Expand Up @@ -369,4 +381,11 @@ function resolveProperty(
return { type: type as string, inputType, options }
}

export { getComponentProps, getComponentTemplate, getComponentSlots, setCustomComponentFilePaths }
export {
getComponentProps,
getComponentTemplate,
getComponentSlots,
componentHasDefaultSlot,
invalidateComponentCache,
registerCustomComponentPaths,
}
15 changes: 15 additions & 0 deletions frontend/src/utils/useLiveEditor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { inject, onMounted, onBeforeUnmount } from "vue"

import { fetchApp } from "@/utils/helpers"
import { invalidateComponentCache, getComponentSlots } from "@/utils/components"
import useStudioStore from "@/stores/studioStore"
import useComponentStore from "@/stores/componentStore"

Expand Down Expand Up @@ -40,13 +41,27 @@ export function useLiveEditor() {
// dev; content edits hot-reload on their own). Reload the app's component set.
const onCustomComponentsChanged = () => store.loadCustomVueComponents()

// 3. A custom .vue component's content changed on disk. Vite hot-reloads its rendered module,
// but our template/slot caches keep the stale copy — so a newly added/removed <slot> wouldn't
// affect droppability. Drop the caches for the edited component and re-warm its slots.
const onCustomComponentFileChanged = ({ path }: { path: string }) => {
const component = store.customVueComponents.find(
(comp) => `${comp.studio_app}/${comp.studio_file_path}` === path,
)
if (!component) return
invalidateComponentCache(component.component_name)
void getComponentSlots(component.component_name, true)
}

onMounted(() => {
socket?.on("studio_doc_update", onDiskSync)
import.meta.hot?.on("studio:custom-components-changed", onCustomComponentsChanged)
import.meta.hot?.on("studio:file-changed", onCustomComponentFileChanged)
})
onBeforeUnmount(() => {
socket?.off("studio_doc_update", onDiskSync)
import.meta.hot?.off("studio:custom-components-changed", onCustomComponentsChanged)
import.meta.hot?.off("studio:file-changed", onCustomComponentFileChanged)
})
}

Expand Down
Loading