From 96b62e24fdc983190bef5918e7816aa8945b5890 Mon Sep 17 00:00:00 2001 From: Domagoj Gojak Date: Fri, 17 Apr 2026 16:57:16 +0200 Subject: [PATCH 1/2] Improve pattern discovery and build reliability in watch mode --- src/app-builder/build.ts | 3 + src/app-builder/watchers.ts | 165 +++++++++++++++--- src/app-builder/watchers/utils.ts | 2 + src/app/components/NotFound.tsx | 24 +++ src/app/pages/[...slug]/index.tsx | 18 +- .../pages/system/pattern/[pattern]/index.tsx | 33 +++- src/cache/build-cache.ts | 17 ++ src/config/runtime.ts | 7 +- 8 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 src/app/components/NotFound.tsx diff --git a/src/app-builder/build.ts b/src/app-builder/build.ts index 964185cf..be596329 100644 --- a/src/app-builder/build.ts +++ b/src/app-builder/build.ts @@ -17,6 +17,7 @@ import { watchComponentDirectories, watchGlobalEntries, watchPages, + watchPatternDirectories, watchPublicDirectory, watchRuntimeComponents, watchRuntimeConfiguration, @@ -224,12 +225,14 @@ export const watchApp = async (handoff: Handoff): Promise => { runtimeComponentsWatcher: null, runtimeConfigurationWatcher: null, componentDirectoriesWatcher: null, + patternDirectoriesWatcher: null, }; watchPublicDirectory(handoff, wss, state, chokidarConfig); watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff)); watchRuntimeConfiguration(handoff, state); watchComponentDirectories(handoff, state, chokidarConfig); + watchPatternDirectories(handoff, state, chokidarConfig); watchGlobalEntries(handoff, state, chokidarConfig); watchPages(handoff, chokidarConfig); }; diff --git a/src/app-builder/watchers.ts b/src/app-builder/watchers.ts index 987af7f5..f5fe6338 100644 --- a/src/app-builder/watchers.ts +++ b/src/app-builder/watchers.ts @@ -2,6 +2,7 @@ import chokidar from 'chokidar'; import fs from 'fs-extra'; import path from 'path'; import Handoff from '..'; +import { buildPatterns } from '../pipeline/patterns'; import processComponents, { ComponentSegment } from '../transformers/preview/component/builder'; import { buildMainCss } from '../transformers/preview/component/css'; import { buildMainJS } from '../transformers/preview/component/javascript'; @@ -273,22 +274,49 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState) }; /** - * Watches the parent component directories from config.entries.components - * for new components being added. When a new config file (e.g. button.json) - * appears in a new subdirectory, reloads the runtime config and restarts - * the component/configuration watchers so the new component is picked up. + * Shared factory for watching a parent directory for newly created entity + * subdirectories (components or patterns). Handles the common scaffolding so + * that callers only describe what differs for their entity kind: + * + * - which config-entry paths to watch + * - how to read the current known ids after a reload + * - which WatcherState slot to use + * - a log label for the entity kind + * - the rebuild work to run once the runtime config is fresh */ -export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => { - if (state.componentDirectoriesWatcher) { - state.componentDirectoriesWatcher.close(); +const watchEntityDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions, options: { + /** Config paths to watch, e.g. handoff.config.entries?.components */ + getConfigPaths: (handoff: Handoff) => string[]; + /** Current known entity ids from runtime config, called again after reload to refresh the set */ + getKnownIds: (handoff: Handoff) => string[]; + /** Read the active FSWatcher from state */ + getWatcher: (state: WatcherState) => chokidar.FSWatcher | null; + /** Write the active FSWatcher back into state */ + setWatcher: (state: WatcherState, watcher: chokidar.FSWatcher) => void; + /** Prefix for the scheduleHandler key, e.g. 'newComponent' or 'newPattern' */ + scheduleKeyPrefix: string; + /** Human-readable label used in log messages, e.g. 'component' or 'pattern' */ + entityLabel: string; + /** + * Entity-specific rebuild work, called after reload + persistClientConfig + + * watcher re-registration. Receives the handoff instance (with fresh runtime + * config) and the directory name of the new entity. + */ + onDetected: (handoff: Handoff, dirName: string) => Promise; +}) => { + const { getConfigPaths, getKnownIds, getWatcher, setWatcher, scheduleKeyPrefix, entityLabel, onDetected } = options; + + const existingWatcher = getWatcher(state); + if (existingWatcher) { + existingWatcher.close(); } - const componentPaths = handoff.config.entries?.components ?? []; - if (componentPaths.length === 0) return; + const configPaths = getConfigPaths(handoff); + if (configPaths.length === 0) return; const dirsToWatch: string[] = []; - for (const componentPath of componentPaths) { - const resolved = path.resolve(handoff.workingPath, componentPath); + for (const configPath of configPaths) { + const resolved = path.resolve(handoff.workingPath, configPath); if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { dirsToWatch.push(resolved); } @@ -296,40 +324,127 @@ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, if (dirsToWatch.length === 0) return; - const knownComponents = new Set(Object.keys(handoff.runtimeConfig?.entries?.components ?? {})); + // Snapshot known ids at watch startup so we only react to genuinely new + // entities, not to file changes inside already-known directories. + const knownIds = new Set(getKnownIds(handoff)); - state.componentDirectoriesWatcher = chokidar.watch(dirsToWatch, { + const watcher = chokidar.watch(dirsToWatch, { ...chokidarConfig, depth: 1, }); + setWatcher(state, watcher); - state.componentDirectoriesWatcher.on('add', async (file) => { + watcher.on('add', async (file) => { const basename = path.basename(file); const dirName = path.basename(path.dirname(file)); - const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs'); - const isNewComponent = isConfigFile && basename.startsWith(dirName) && !knownComponents.has(dirName); + // Only react to the primary declaration file for the directory (e.g. + // button.json, button.js, or button.handoff.ts inside a button/ subdir). + // The basename.startsWith(dirName) guard below ensures .ts here only + // ever matches files named after their parent directory, not arbitrary + // TypeScript source files. + const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs') || basename.endsWith('.ts'); + const isNewEntity = isConfigFile && basename.startsWith(dirName) && !knownIds.has(dirName); - if (!isNewComponent) return; + if (!isNewEntity) return; - await scheduleHandler(state, `newComponent:${dirName}`, async () => { + await scheduleHandler(state, `${scheduleKeyPrefix}:${dirName}`, async () => { try { - Logger.warn(`New component detected: ${dirName}. Reloading configuration...`); + Logger.warn(`New ${entityLabel} detected: ${dirName}. Reloading configuration...`); + + // Snapshot ids before reload so we can tell whether the new entity + // was successfully registered afterwards. + const idsBefore = new Set(getKnownIds(handoff)); + handoff.reload(); - knownComponents.add(dirName); + knownIds.add(dirName); - for (const id of Object.keys(handoff.runtimeConfig?.entries?.components ?? {})) { - knownComponents.add(id); + // Refresh from the post-reload runtime config so any ids that were + // discovered alongside the new entity are also marked as known. + const idsAfter = getKnownIds(handoff); + for (const id of idsAfter) { + knownIds.add(id); } await persistClientConfig(handoff); + + // Re-register file watchers so the new config file and any runtime + // files it introduces are covered going forward. This must happen even + // when the file failed to parse so that watchRuntimeConfiguration can + // trigger a retry the moment the file is saved with valid content. watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff)); watchRuntimeConfiguration(handoff, state); - await processComponents(handoff, dirName); - await runAllFinalizers(handoff, { patternRebuildComponentIds: [dirName] }); + + // If no new ids appeared after reload the file was empty or invalid. + // Skip the build — watchRuntimeConfiguration will fire when it is saved. + const hasNewIds = idsAfter.some((id) => !idsBefore.has(id)); + if (!hasNewIds) { + Logger.warn(`${entityLabel} "${dirName}" config is empty or incomplete — build will run automatically once the file is saved.`); + return; + } + + await onDetected(handoff, dirName); } catch (e) { - Logger.error('Error processing new component:', e); + Logger.error(`Error processing new ${entityLabel}:`, e); } }); }); }; + +/** + * Watches the parent component directories from config.entries.components for + * new components being added. When a new config file appears in a new + * subdirectory, reloads the runtime config, restarts the component and + * configuration watchers, then builds the new component and any patterns that + * reference it. + */ +export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => { + watchEntityDirectories(handoff, state, chokidarConfig, { + getConfigPaths: (h) => h.config.entries?.components ?? [], + getKnownIds: (h) => Object.keys(h.runtimeConfig?.entries?.components ?? {}), + getWatcher: (s) => s.componentDirectoriesWatcher, + setWatcher: (s, w) => { s.componentDirectoriesWatcher = w; }, + scheduleKeyPrefix: 'newComponent', + entityLabel: 'component', + onDetected: async (handoff, dirName) => { + await processComponents(handoff, dirName); + await runAllFinalizers(handoff, { patternRebuildComponentIds: [dirName] }); + }, + }); +}; + +/** + * Watches the parent pattern directories from config.entries.patterns for new + * patterns being added. The rebuild sequence intentionally differs from + * watchComponentDirectories because the dependency arrow is reversed: a new + * pattern references existing components, so we rebuild only the preview + * segment of those components (to produce the __pattern_* HTML files that + * injectPatternPreviews registered) before composing the pattern itself. + */ +export const watchPatternDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => { + watchEntityDirectories(handoff, state, chokidarConfig, { + getConfigPaths: (h) => h.config.entries?.patterns ?? [], + getKnownIds: (h) => Object.keys(h.runtimeConfig?.entries?.patterns ?? {}), + getWatcher: (s) => s.patternDirectoriesWatcher, + setWatcher: (s, w) => { s.patternDirectoriesWatcher = w; }, + scheduleKeyPrefix: 'newPattern', + entityLabel: 'pattern', + onDetected: async (handoff, dirName) => { + // Rebuild only the previews segment for each component the new pattern + // references. Their JS/CSS/structure are unchanged — only the new + // synthetic preview HTML files need to be generated so that buildPatterns + // can assemble the pattern from them. + const newPattern = handoff.runtimeConfig?.entries?.patterns?.[dirName]; + if (newPattern?.components?.length) { + for (const ref of newPattern.components) { + await processComponents(handoff, ref.id, ComponentSegment.Previews); + } + } + + // Build only the new pattern directly rather than going through + // runAllFinalizers, which would rebuild all patterns unnecessarily. + const newPatternId = newPattern?.id ?? dirName; + await buildPatterns(handoff, { onlyPatternIds: new Set([newPatternId]) }); + }, + }); +}; diff --git a/src/app-builder/watchers/utils.ts b/src/app-builder/watchers/utils.ts index 7d21590e..5bc69976 100644 --- a/src/app-builder/watchers/utils.ts +++ b/src/app-builder/watchers/utils.ts @@ -7,6 +7,8 @@ export interface WatcherState { runtimeComponentsWatcher: chokidar.FSWatcher | null; runtimeConfigurationWatcher: chokidar.FSWatcher | null; componentDirectoriesWatcher: chokidar.FSWatcher | null; + /** Watches entries.patterns parent directories for newly created pattern subdirectories. */ + patternDirectoriesWatcher: chokidar.FSWatcher | null; } /** diff --git a/src/app/components/NotFound.tsx b/src/app/components/NotFound.tsx new file mode 100644 index 00000000..81f3e160 --- /dev/null +++ b/src/app/components/NotFound.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +interface NotFoundProps { + title?: string; + description?: React.ReactNode; + linkHref?: string; + linkLabel?: string; +} + +const NotFound: React.FC = () => { + return ( +
+
404
+

Oops! Page not found.

+

+ This page doesn’t exist or has been moved. +
+ Please check the URL or return to the homepage. +

+
+ ); +}; + +export default NotFound; diff --git a/src/app/pages/[...slug]/index.tsx b/src/app/pages/[...slug]/index.tsx index 95967318..30c56ad8 100644 --- a/src/app/pages/[...slug]/index.tsx +++ b/src/app/pages/[...slug]/index.tsx @@ -1,7 +1,7 @@ import { GetStaticProps } from 'next'; import Head from 'next/head'; -import Link from 'next/link'; import { useRef } from 'react'; +import NotFound from '../../components/NotFound'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; @@ -48,21 +48,7 @@ export default function DocCatchAllPage({ content, menu, metadata, current, conf 404 - Page Not Found -
-
404
-

Oops! Page not found.

-

- Sorry, the page you are looking for does not exist or has been moved. -
- Please check the URL or return to the homepage. -

- - Go Home - -
+ ); } diff --git a/src/app/pages/system/pattern/[pattern]/index.tsx b/src/app/pages/system/pattern/[pattern]/index.tsx index faa2f3e6..d2c4d95c 100644 --- a/src/app/pages/system/pattern/[pattern]/index.tsx +++ b/src/app/pages/system/pattern/[pattern]/index.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import Layout from '../../../../components/Layout/Main'; +import NotFound from '../../../../components/NotFound'; import { MarkdownComponents } from '../../../../components/Markdown/MarkdownComponents'; import PrevNextNav from '../../../../components/Navigation/PrevNextNav'; import HeadersType from '../../../../components/Typography/Headers'; @@ -130,6 +131,7 @@ export const getStaticProps = async (context: { params: IParams }) => { const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nextPattern }: PatternPageProps) => { const [pattern, setPattern] = useState(undefined); const [fetchError, setFetchError] = useState(undefined); + const [patternNotFound, setPatternNotFound] = useState(false); const [iframeHeight, setIframeHeight] = useState('400px'); const iframeRef = React.useRef(null); @@ -145,13 +147,20 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex const controller = new AbortController(); setPattern(undefined); setFetchError(undefined); + setPatternNotFound(false); fetch(`${normalizedBasePath}/api/pattern/${id}.json`, { signal: controller.signal }) .then((res) => { + if (res.status === 404) { + setPatternNotFound(true); + return null; + } if (!res.ok) throw new Error(`Failed to load pattern: ${res.status} ${res.statusText}`); return res.json(); }) - .then((data) => setPattern(data as PatternListObject)) + .then((data) => { + if (data) setPattern(data as PatternListObject); + }) .catch((err) => { if (err.name !== 'AbortError') { setFetchError(err instanceof Error ? err.message : 'Failed to load pattern data.'); @@ -167,7 +176,27 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex } }, []); - if (fetchError) return

{fetchError}

; + if (patternNotFound) { + return ( + +
+ +
+
+ ); + } + + if (fetchError) { + return ( + +
+

Something went wrong

+

{fetchError}

+
+
+ ); + } + if (!pattern) return

Loading...

; return ( diff --git a/src/cache/build-cache.ts b/src/cache/build-cache.ts index 0b80af06..9ec0fc0c 100644 --- a/src/cache/build-cache.ts +++ b/src/cache/build-cache.ts @@ -226,6 +226,23 @@ export function getComponentFilePaths(handoff: Handoff, componentId: string): { files.push(matchingConfigPath); } + // Include every pattern config file whose declaration references this component. + // When a pattern is added or modified, injectPatternPreviews injects new + // __pattern_* synthetic preview keys onto the referenced component, changing + // what processComponents must render. Without tracking these files here, the + // cache would incorrectly consider the component unchanged and skip the preview + // rebuild, leaving buildPatterns unable to find the required HTML fragments. + const runtimePatterns = handoff.runtimeConfig?.entries?.patterns ?? {}; + for (const configPath of configPaths) { + const entry = handoff.getConfigFileEntry(configPath); + if (entry?.kind !== 'pattern') continue; + const pattern = runtimePatterns[entry.entityId]; + if (!pattern) continue; + if (pattern.components?.some((ref) => ref.id === componentId)) { + files.push(configPath); + } + } + return { files, templateDir }; } diff --git a/src/config/runtime.ts b/src/config/runtime.ts index b9371891..46798c11 100644 --- a/src/config/runtime.ts +++ b/src/config/runtime.ts @@ -284,7 +284,12 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt fallbackId: patternBaseName, }); } catch (err) { - Logger.error(`Failed to read or parse pattern config: ${resolvedPatternConfigPath}`, err); + // Treat as a warning rather than an error: the file may be empty or + // mid-edit. It is already tracked in configFiles above, so + // watchRuntimeConfiguration will pick it up once the file is saved + // with valid content. + Logger.warn(`Pattern config skipped (incomplete or invalid) — will retry on next save: ${resolvedPatternConfigPath}`); + Logger.debug(`Pattern parse detail:`, err); continue; } From e47c28fce25d0e15acc6bda16697d908dc02d1b4 Mon Sep 17 00:00:00 2001 From: Domagoj Gojak Date: Sun, 17 May 2026 19:53:11 +0200 Subject: [PATCH 2/2] Fix pattern watcher rebuild edge cases by using discovered entity IDs after runtime config reloads instead of assuming folder names match IDs, and by rebuilding referenced component previews when pattern configs become valid after an incomplete save. --- src/app-builder/watchers.ts | 43 ++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/app-builder/watchers.ts b/src/app-builder/watchers.ts index f5fe6338..4b8e702e 100644 --- a/src/app-builder/watchers.ts +++ b/src/app-builder/watchers.ts @@ -210,6 +210,15 @@ export const watchRuntimeComponents = ( } }; +const rebuildPatternComponentPreviews = async (handoff: Handoff, patternId: string) => { + const pattern = handoff.runtimeConfig?.entries?.patterns?.[patternId]; + if (!pattern?.components?.length) return; + + for (const ref of pattern.components) { + await processComponents(handoff, ref.id, ComponentSegment.Previews); + } +}; + /** * Watches the runtime configuration for changes. * @@ -257,6 +266,8 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState) await processComponents(handoff); } else if (handle) { await handle.apply(handoff, entryAfter?.entityId); + } else if (entryAfter?.kind === 'pattern') { + await rebuildPatternComponentPreviews(handoff, entryAfter.entityId); } else { const effectiveId = entryAfter?.entityId ?? path.basename(path.dirname(changedFile)); await processComponents(handoff, effectiveId); @@ -300,9 +311,10 @@ const watchEntityDirectories = (handoff: Handoff, state: WatcherState, chokidarC /** * Entity-specific rebuild work, called after reload + persistClientConfig + * watcher re-registration. Receives the handoff instance (with fresh runtime - * config) and the directory name of the new entity. + * config), the triggering directory name, and actual entity ids discovered + * after reload. */ - onDetected: (handoff: Handoff, dirName: string) => Promise; + onDetected: (handoff: Handoff, context: { dirName: string; addedIds: string[] }) => Promise; }) => { const { getConfigPaths, getKnownIds, getWatcher, setWatcher, scheduleKeyPrefix, entityLabel, onDetected } = options; @@ -357,11 +369,11 @@ const watchEntityDirectories = (handoff: Handoff, state: WatcherState, chokidarC const idsBefore = new Set(getKnownIds(handoff)); handoff.reload(); - knownIds.add(dirName); // Refresh from the post-reload runtime config so any ids that were // discovered alongside the new entity are also marked as known. const idsAfter = getKnownIds(handoff); + const addedIds = idsAfter.filter((id) => !idsBefore.has(id)); for (const id of idsAfter) { knownIds.add(id); } @@ -377,13 +389,12 @@ const watchEntityDirectories = (handoff: Handoff, state: WatcherState, chokidarC // If no new ids appeared after reload the file was empty or invalid. // Skip the build — watchRuntimeConfiguration will fire when it is saved. - const hasNewIds = idsAfter.some((id) => !idsBefore.has(id)); - if (!hasNewIds) { + if (addedIds.length === 0) { Logger.warn(`${entityLabel} "${dirName}" config is empty or incomplete — build will run automatically once the file is saved.`); return; } - await onDetected(handoff, dirName); + await onDetected(handoff, { dirName, addedIds }); } catch (e) { Logger.error(`Error processing new ${entityLabel}:`, e); } @@ -406,9 +417,11 @@ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, setWatcher: (s, w) => { s.componentDirectoriesWatcher = w; }, scheduleKeyPrefix: 'newComponent', entityLabel: 'component', - onDetected: async (handoff, dirName) => { - await processComponents(handoff, dirName); - await runAllFinalizers(handoff, { patternRebuildComponentIds: [dirName] }); + onDetected: async (handoff, { addedIds }) => { + for (const componentId of addedIds) { + await processComponents(handoff, componentId); + await runAllFinalizers(handoff, { patternRebuildComponentIds: [componentId] }); + } }, }); }; @@ -429,22 +442,18 @@ export const watchPatternDirectories = (handoff: Handoff, state: WatcherState, c setWatcher: (s, w) => { s.patternDirectoriesWatcher = w; }, scheduleKeyPrefix: 'newPattern', entityLabel: 'pattern', - onDetected: async (handoff, dirName) => { + onDetected: async (handoff, { addedIds }) => { // Rebuild only the previews segment for each component the new pattern // references. Their JS/CSS/structure are unchanged — only the new // synthetic preview HTML files need to be generated so that buildPatterns // can assemble the pattern from them. - const newPattern = handoff.runtimeConfig?.entries?.patterns?.[dirName]; - if (newPattern?.components?.length) { - for (const ref of newPattern.components) { - await processComponents(handoff, ref.id, ComponentSegment.Previews); - } + for (const patternId of addedIds) { + await rebuildPatternComponentPreviews(handoff, patternId); } // Build only the new pattern directly rather than going through // runAllFinalizers, which would rebuild all patterns unnecessarily. - const newPatternId = newPattern?.id ?? dirName; - await buildPatterns(handoff, { onlyPatternIds: new Set([newPatternId]) }); + await buildPatterns(handoff, { onlyPatternIds: new Set(addedIds) }); }, }); };