/** * TemplateApp 클래스 * 템플릿 엔진의 모든 모듈을 통합하고 초기화하는 메인 애플리케이션 클래스 */ import React from 'react'; import { flushSync } from 'react-dom'; import { createRoot as createReactRoot } from 'react-dom/client'; import { Router } from './routing/Router'; import type { Route } from './routing/Router'; import { LayoutLoader, LayoutLoaderError } from './template-engine/LayoutLoader'; import type { InitActionDefinition, LayoutScript, ComputedSwitchDefinition } from './template-engine/LayoutLoader'; import { DataBindingEngine } from './template-engine/DataBindingEngine'; import { evaluateRenderCondition } from './template-engine/helpers/RenderHelpers'; import { ComponentRegistry } from './template-engine/ComponentRegistry'; import { DataSourceManager } from './template-engine/DataSourceManager'; import { initTemplateEngine, renderTemplate, destroyTemplate, getState, updateTemplateData } from './template-engine'; import { checkLayoutEditorMode } from './template-engine/layout-editor/hooks/useEditorMode'; import { ErrorDisplay } from './template-engine/ErrorDisplay'; import { toTemplateEngineError } from './template-engine/TemplateEngineError'; import { ErrorPageHandler } from './template-engine/ErrorPageHandler'; import { AuthManager, type AuthType } from './auth/AuthManager'; import { getApiClient } from './api/ApiClient'; import { transitionManager } from './template-engine/TransitionManager'; import { getErrorHandlingResolver } from './error'; import type { ErrorHandlingMap } from './types/ErrorHandling'; import { createLogger, Logger } from './utils/Logger'; import { webSocketManager } from './websocket/WebSocketManager'; import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig } from './modules'; import { SystemBannerManager } from './template-engine/SystemBannerManager'; /** * DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다. */ const logger = createLogger('TemplateApp'); /** * URLSearchParams를 배열 쿼리 파라미터를 지원하는 객체로 변환합니다. * * 일반 Object.fromEntries(params.entries())는 같은 키의 여러 값 중 마지막 값만 반환합니다. * 이 함수는 `key[]` 형태의 키를 배열로 올바르게 파싱합니다. * * @param params URLSearchParams 객체 * @returns 파싱된 쿼리 객체 (배열 키는 실제 배열로 변환) * * @example * // URL: ?sales_status[]=on_sale&sales_status[]=sold_out&page=1 * parseQueryParams(params) * // → { 'sales_status[]': ['on_sale', 'sold_out'], page: '1' } */ function parseQueryParams(params: URLSearchParams): Record { const result: Record = {}; // 모든 키를 순회 for (const key of params.keys()) { // 이미 처리된 키는 스킵 if (key in result) continue; // 해당 키의 모든 값을 가져옴 const values = params.getAll(key); if (values.length > 1) { // 여러 값이 있으면 배열로 저장 result[key] = values; } else if (values.length === 1) { // 단일 값 // key[]로 끝나면 배열로 저장 (URL에서 단일 배열 요소 전송 시) if (key.endsWith('[]')) { result[key] = values; } else { result[key] = values[0]; } } } return result; } /** * WebSocket(Reverb) 설정 인터페이스 */ export interface WebSocketConfig { /** Reverb 앱 키 */ appKey: string; /** WebSocket 호스트 (기본값: localhost) */ host?: string; /** WebSocket 포트 (기본값: 80) */ port?: number; /** 스키마 (http 또는 https, 기본값: https) */ scheme?: 'http' | 'https'; } export interface TemplateAppConfig { templateId: string; templateType: 'admin' | 'user'; locale: string; debug: boolean; /** WebSocket(Reverb) 설정 (선택적) */ websocket?: WebSocketConfig; /** * 언어 변경 API 엔드포인트 (선택적) * * 설정하지 않으면 템플릿 타입에 따라 기본값 사용: * - admin: { endpoint: '/api/admin/users/me/language', method: 'PATCH' } * - user: { endpoint: '/api/user/profile/update-language', method: 'POST' } */ localeApi?: { endpoint: string; method: 'POST' | 'PATCH' | 'PUT'; }; } /** * 업로드 설정 인터페이스 */ export interface UploadSettings { /** 최대 파일 크기 (MB) */ max_file_size: number; /** 허용 확장자 (예: 'jpg,jpeg,png') */ allowed_extensions: string; /** 허용 확장자 포맷 (예: '.jpg,.jpeg,.png') */ allowed_extensions_formatted: string; /** 이미지 최대 너비 */ image_max_width: number; /** 이미지 최대 높이 */ image_max_height: number; /** 이미지 품질 (1-100) */ image_quality: number; } /** * 일반 설정 인터페이스 */ export interface GeneralSettings { site_name: string; site_url: string; site_description: string; admin_email: string; language: string; timezone: string; } /** * SEO 설정 인터페이스 */ export interface SeoSettings { [key: string]: any; } /** * 보안 설정 인터페이스 */ export interface SecuritySettings { force_https: boolean; login_attempt_enabled: boolean; auth_token_lifetime: number; max_login_attempts: number; login_lockout_time: number; } /** * 고급 설정 인터페이스 */ export interface AdvancedSettings { cache_enabled: boolean; layout_cache_enabled: boolean; layout_cache_ttl: number; stats_cache_enabled: boolean; stats_cache_ttl: number; seo_cache_enabled: boolean; seo_cache_ttl: number; debug_mode: boolean; } /** * 프론트엔드 전역 설정 인터페이스 * * admin.blade.php에서 window.G7Config.settings로 주입되어 * TemplateApp에서 _global.settings로 로드됩니다. * * config/settings/defaults.json의 frontend_schema에 정의된 카테고리가 * 동적으로 포함됩니다. 아래는 기본 카테고리들이며, * 새로운 카테고리가 추가되어도 인덱스 시그니처로 수용 가능합니다. */ export interface FrontendSettings { general?: GeneralSettings; upload?: UploadSettings; seo?: SeoSettings; security?: SecuritySettings; advanced?: AdvancedSettings; /** 동적 카테고리 지원을 위한 인덱스 시그니처 */ [key: string]: Record | undefined; } /** * 전역 상태 인터페이스 */ export interface GlobalState { sidebarOpen: boolean; /** 전체 환경설정 (환경설정에서 관리) */ settings?: FrontendSettings; /** @deprecated uploadSettings 대신 settings.upload 사용을 권장합니다 */ uploadSettings?: UploadSettings; [key: string]: any; } /** * 서버에서 주입된 에러 정보 인터페이스 * * 미들웨어에서 의존성 미충족 등으로 에러 상태가 감지되면 * window.G7Error에 에러 정보를 주입합니다. */ export interface G7ErrorInfo { /** HTTP 에러 코드 (예: 503) */ code: number; /** 렌더링할 에러 레이아웃 경로 (예: 'errors/503') */ layout: string; /** 에러 관련 추가 데이터 (예: 미충족 의존성 목록) */ data?: any; } declare global { interface Window { __templateApp?: TemplateApp; /** 서버에서 주입된 에러 정보 (미들웨어에서 설정) */ G7Error?: G7ErrorInfo; } } export class TemplateApp { private router: Router | null = null; private layoutLoader: LayoutLoader | null = null; private errorPageHandler: ErrorPageHandler | null = null; private config: TemplateAppConfig; private static readonly LOCALE_STORAGE_KEY = 'g7_locale'; private static readonly CACHE_VERSION_STORAGE_KEY = 'g7_cache_version'; private globalState: GlobalState; private globalStateListeners: Set<(state: GlobalState) => void> = new Set(); /** 현재 진행 중인 라우트 변경 요청 ID (새 요청 시 이전 요청 무시용) */ private currentRouteChangeId: number = 0; /** 현재 레이아웃의 데이터 소스 정의 (if 조건으로 필터링된 결과 — refetch용) */ private currentDataSources: any[] = []; /** * 현재 레이아웃의 원본 데이터 소스 정의 (if 필터링 전 전체). * * replace:true navigate(탭 전환/필터 변경)로 진입하는 updateQueryParams 경로는 * 변경된 query 컨텍스트로 데이터소스 if 를 재평가해야 한다. currentDataSources(필터링된 * 스냅샷)만으로는 직전 진입 시점 조건에 고정되어, 탭 전환 시 다른 탭의 데이터소스가 * 잘못 선택되는 회귀가 발생한다. 원본을 보존해 재평가 가능하게 한다. */ private currentRawDataSources: any[] = []; /** 현재 라우트 파라미터 (refetch용) */ private currentRouteParams: Record = {}; /** 현재 쿼리 파라미터 (refetch용) */ private currentQueryParams: URLSearchParams = new URLSearchParams(); /** 현재 fetch된 데이터 (refetch 및 get용) */ private currentFetchedData: Record = {}; /** 현재 레이아웃 이름 (레이아웃 전환 감지용) */ private currentLayoutName: string = ''; /** 템플릿 레벨 에러 핸들링 설정 */ private templateErrorHandling: ErrorHandlingMap | null = null; /** 현재 WebSocket 구독 키 (라우트 변경 시 해제용) */ private currentWebSocketSubscriptions: string[] = []; /** 확장 기능 캐시 버전 (모듈/플러그인 활성화 시 갱신됨) */ private extensionCacheVersion: number = 0; /** 현재 레이아웃의 전역 헤더 규칙 (API 호출 시 자동 적용) */ private currentGlobalHeaders: any[] = []; /** 전환 오버레이 DOM 요소 @since engine-v1.23.0 */ private transitionOverlayEl: HTMLDivElement | null = null; /** 스켈레톤 오버레이 React 루트 @since engine-v1.24.0 */ private skeletonOverlayRoot: any = null; /** 스켈레톤 오버레이 컨테이너 DOM 요소 @since engine-v1.24.0 */ private skeletonOverlayContainer: HTMLDivElement | null = null; /** spinner 재생성용 상태 (renderTemplate 후 새 DOM에 재마운트) @since engine-v1.29.0 */ private _spinnerState: { target: string; fallbackTarget?: string; spinnerConfig?: { component?: string; text?: string }; resolvedText: string; } | null = null; /** * 모달 데이터 소스 레지스트리 * * 모달이 열릴 때 ModalDataSourceWrapper가 등록하고, * 닫힐 때 해제합니다. refetchDataSource에서 currentDataSources에 * 없는 데이터 소스를 여기서 검색합니다. * * @since 1.18.0 */ private modalDataSources: Map = new Map(); constructor(config: TemplateAppConfig) { // 전역 상태 초기화 this.globalState = { sidebarOpen: false, }; // window.G7Config에서 초기 설정값 로드 this.loadG7Config(); // 레거시 로케일 키 마이그레이션 this.migrateLocaleStorage(); // 로케일 우선순위: // 1. localStorage g7_locale 값 (사용자가 명시적으로 선택한 언어) // 2. 서버에서 전달한 로케일 (로그인 사용자의 users.language 값) // 3. 기본값 ('ko') // // SetLocale 미들웨어 우선순위: // - 로그인 사용자: users.language (최우선) // - 비로그인 사용자: Accept-Language 헤더 // - 기본값: config('app.locale') const savedLocale = this.loadLocaleFromStorage(); const finalLocale = savedLocale || config.locale || 'ko'; this.config = { ...config, locale: finalLocale, }; // localStorage 값이 없을 때만 서버 설정을 저장 // (사용자가 선택한 언어를 보존) if (!savedLocale && config.locale) { this.saveLocaleToStorage(config.locale); } } /** * window.G7Config에서 초기 설정값을 전역 상태에 로드합니다. * * admin.blade.php에서 주입된 G7Config.settings, plugins, modules를 * _global로 로드하여 프론트엔드 전역에서 사용할 수 있게 합니다. * * 하위 호환성을 위해 _global.uploadSettings도 함께 설정합니다. */ private loadG7Config(): void { if (typeof window !== 'undefined' && (window as any).G7Config) { const g7Config = (window as any).G7Config; // 전체 settings 로드 if (g7Config.settings) { this.globalState.settings = g7Config.settings; logger.log('Loaded settings from G7Config:', Object.keys(g7Config.settings)); // 하위 호환성: uploadSettings도 설정 (deprecated) if (g7Config.settings.upload) { this.globalState.uploadSettings = g7Config.settings.upload; } } // 플러그인 설정 로드 if (g7Config.plugins) { this.globalState.plugins = g7Config.plugins; logger.log('Loaded plugin settings from G7Config:', Object.keys(g7Config.plugins)); } // 모듈 설정 로드 if (g7Config.modules) { this.globalState.modules = g7Config.modules; logger.log('Loaded module settings from G7Config:', Object.keys(g7Config.modules)); } // 앱 config 로드 (config() 기반 설정값) if (g7Config.appConfig) { this.globalState.appConfig = g7Config.appConfig; logger.log('Loaded appConfig from G7Config:', Object.keys(g7Config.appConfig)); } } } /** * routes.json의 path/redirect 문자열 내 {{expression}}을 평가합니다. * 레이아웃 JSON과 동일한 표현식 문법을 지원합니다. * * 컨텍스트: { _global: this.globalState } (loadG7Config()으로 채워진 상태) */ private resolveRouteExpressions(routes: Route[]): Route[] { const bindingEngine = new DataBindingEngine(); const context = { _global: this.globalState }; return routes.map(route => { const updated = { ...route }; if (updated.path && updated.path.includes('{{')) { const originalPath = updated.path; updated.path = bindingEngine.resolveBindings(updated.path, context); // 이중 슬래시 정리 (빈 값 치환 시 //cart → /cart) updated.path = updated.path.replace(/\/\/+/g, '/') || '/'; // 안전장치: 치환 실패로 {{...}} 잔존 시 경고 + 표현식 제거 fallback if (updated.path.includes('{{')) { logger.warn('Route expression resolution failed, using fallback:', originalPath); updated.path = originalPath.replace(/\{\{[^}]+\}\}/g, '').replace(/\/\/+/g, '/') || '/'; } } if (updated.redirect && updated.redirect.includes('{{')) { const originalRedirect = updated.redirect; updated.redirect = bindingEngine.resolveBindings(updated.redirect, context); updated.redirect = updated.redirect.replace(/\/\/+/g, '/') || '/'; if (updated.redirect.includes('{{')) { logger.warn('Route redirect expression resolution failed:', originalRedirect); updated.redirect = originalRedirect.replace(/\{\{[^}]+\}\}/g, '').replace(/\/\/+/g, '/') || '/'; } } return updated; }); } /** * 레거시 로케일 키를 새 키로 마이그레이션 * * 기존 'locale', 'g7_template_locale' 키를 'g7_locale'로 통일합니다. */ private migrateLocaleStorage(): void { const legacyKeys = ['locale', 'g7_template_locale']; for (const legacyKey of legacyKeys) { try { const value = localStorage.getItem(legacyKey); if (value && value !== localStorage.getItem(TemplateApp.LOCALE_STORAGE_KEY)) { // 새 키로 복사 localStorage.setItem(TemplateApp.LOCALE_STORAGE_KEY, value); logger.log(`Migrated locale from '${legacyKey}' to '${TemplateApp.LOCALE_STORAGE_KEY}'`); } // 레거시 키 제거 localStorage.removeItem(legacyKey); } catch (error) { logger.warn(`Failed to migrate locale key '${legacyKey}':`, error); } } } /** * 애플리케이션 초기화 */ async init(): Promise { try { // 디버그 모드 설정 Logger.getInstance().setDebug(this.config.debug); logger.log('Initializing with config:', this.config); // 1. 템플릿 엔진 초기화, ComponentRegistry, routes.json, 사용자 정보를 병렬 로딩 const componentRegistry = ComponentRegistry.getInstance(); const authManager = AuthManager.getInstance(); // 저장된 캐시 버전 로드 (초기 API 호출에 사용) const storedCacheVersion = this.loadCacheVersionFromStorage() || 0; const [_, __, routesData, ___, templateConfig] = await Promise.all([ // 템플릿 엔진 초기화 (다국어 파일 병렬 로드) initTemplateEngine({ templateId: this.config.templateId, templateType: this.config.templateType, locale: this.config.locale, debug: this.config.debug, cacheVersion: storedCacheVersion, }), // ComponentRegistry 로딩 (components.json) componentRegistry.loadComponents( this.config.templateId, this.config.templateType ), // routes.json 로딩 (저장된 캐시 버전 사용) fetch(`/api/templates/${this.config.templateId}/routes.json${storedCacheVersion > 0 ? `?v=${storedCacheVersion}` : ''}`) .then(response => { if (!response.ok) { throw new Error(`Failed to load routes: ${response.statusText}`); } return response.json(); }) .then(result => { if (!result.success) { throw new Error('Failed to load routes from API'); } return result.data; }) .catch(error => { logger.error('Error loading routes:', error); throw error; }), // 사용자 정보 프리로드 (에러 발생 시 무시) authManager.preloadAuth(this.config.templateType === 'admin' ? 'admin' : 'user'), // 템플릿 config.json 로딩 (errorHandling 파싱) fetch(`/api/templates/${this.config.templateId}/config.json`) .then(response => { if (!response.ok) { // config.json 로드 실패는 무시 (선택적) return null; } return response.json(); }) .then(result => { if (!result?.success || !result?.data) { return null; } return result.data; }) .catch(error => { logger.warn('Error loading template config:', error); return null; }), ]); logger.log('Template Engine initialized'); logger.log('ComponentRegistry loaded'); logger.log('Routes data loaded'); logger.log('User info preloaded'); logger.log('Template config loaded:', templateConfig); // 확장 기능 캐시 버전 저장 (모듈/플러그인 활성화 시 갱신됨) if (templateConfig?.cache_version !== undefined) { const previousVersion = this.loadCacheVersionFromStorage(); this.extensionCacheVersion = templateConfig.cache_version; this.saveCacheVersionToStorage(this.extensionCacheVersion); logger.log('Extension cache version:', this.extensionCacheVersion); // 캐시 버전이 변경된 경우 routes.json 재로드 필요 if (previousVersion !== null && previousVersion !== this.extensionCacheVersion) { logger.log('Cache version changed, reloading routes...'); // routes.json을 새 캐시 버전으로 다시 로드 const newRoutesData = await fetch(`/api/templates/${this.config.templateId}/routes.json?v=${this.extensionCacheVersion}`) .then(response => { if (!response.ok) { throw new Error(`Failed to reload routes: ${response.statusText}`); } return response.json(); }) .then(result => { if (!result.success) { throw new Error('Failed to reload routes from API'); } return result.data; }); // Router에 새 routes 설정 (아래에서 Router 초기화 후 적용됨) if (Array.isArray(newRoutesData.routes)) { newRoutesData.routes = this.resolveRouteExpressions(newRoutesData.routes); // routesData를 갱신 (const 변수이므로 객체 속성만 변경) Object.assign(routesData, newRoutesData); logger.log('Routes reloaded with new cache version'); } // 다국어 데이터도 새 캐시 버전으로 재로드 try { const { TranslationEngine } = await import('./template-engine/TranslationEngine'); const translationEngine = TranslationEngine.getInstance(); translationEngine.setCacheVersion(this.extensionCacheVersion); const locale = this.config.locale || 'ko'; const fallbackLocale = 'en'; await translationEngine.loadTranslations(this.config.templateId, locale, '/api', true); if (locale !== fallbackLocale) { await translationEngine.loadTranslations(this.config.templateId, fallbackLocale, '/api', true); } logger.log('Translations reloaded with new cache version'); } catch (translationError) { logger.error('Failed to reload translations:', translationError); } } } // 템플릿 레벨 에러 핸들링 설정 저장 및 ErrorHandlingResolver에 등록 if (templateConfig?.errorHandling) { this.templateErrorHandling = templateConfig.errorHandling; const errorHandlingResolver = getErrorHandlingResolver(); errorHandlingResolver.setTemplateConfig(this.templateErrorHandling); logger.log('Template errorHandling registered:', this.templateErrorHandling); } // 2. AuthManager 이벤트 핸들러 등록 authManager.on('logout', () => { logger.log('User logged out'); }); authManager.on('authStateChange', (state) => { logger.log('Auth state changed:', state); }); logger.log('AuthManager event handlers registered'); // 2.5 ApiClient에 onUnauthorized 콜백 설정 // 토큰 갱신 실패 시 로그인 페이지로 리다이렉트 const apiClient = getApiClient(); const authType = this.config.templateType === 'admin' ? 'admin' : 'user'; const authConfig = authManager.getConfig(authType); apiClient.setOnUnauthorized(() => { logger.log('Unauthorized - redirecting to login page'); // 토큰 삭제 apiClient.removeToken(); // onUnauthorized 콜백이 발동했다는 것은 토큰이 서버에서 거부되었다는 의미 // (apiClient 는 토큰 미보유 시 요청을 skip 하므로 콜백도 발동 안 함). // 즉 사용자는 로그인된 상태였다가 세션이 만료된 케이스이므로 // reason='session_expired' 를 부여해 로그인 페이지에서 안내 토스트가 노출되도록 한다. const returnUrl = window.location.pathname + window.location.search; const loginUrl = authManager.getLoginRedirectUrl( authType as AuthType, returnUrl, 'session_expired' ); window.location.href = loginUrl; }); logger.log('ApiClient onUnauthorized callback registered'); // 3. LayoutLoader 초기화 (캐시 버전 설정) this.layoutLoader = new LayoutLoader(componentRegistry); if (this.extensionCacheVersion > 0) { this.layoutLoader.setCacheVersion(this.extensionCacheVersion); } logger.log('LayoutLoader initialized:', this.layoutLoader); // 3.5 ErrorPageHandler 초기화 (DataSourceManager 의존성 주입) const errorDataSourceManager = new DataSourceManager({ onUnauthorized: () => { logger.warn('Unauthorized request in error page'); }, }); this.errorPageHandler = new ErrorPageHandler({ templateId: this.config.templateId, layoutLoader: this.layoutLoader, locale: this.config.locale, debug: this.config.debug, renderFunction: renderTemplate, dataSourceManager: errorDataSourceManager, globalState: this.globalState, }); logger.log('ErrorPageHandler initialized with DataSourceManager'); // 3.6 서버에서 주입된 에러 상태 확인 (503 의존성 미충족 등) // window.G7Error가 있으면 에러 페이지만 렌더링하고 초기화 종료 if (await this.handleServerError()) { logger.log('Server error handled, skipping normal initialization'); return; } // 4. Router 초기화 및 라우트 설정 this.router = new Router(this.config.templateId); if (Array.isArray(routesData.routes)) { const resolvedRoutes = this.resolveRouteExpressions(routesData.routes); this.router.setRoutes(resolvedRoutes); } else { throw new Error('Invalid routes data format'); } logger.log('Router initialized:', this.router); logger.log('Routes set:', this.router.getRoutes()); // 6. ActionDispatcher에 navigate 함수 및 setGlobalState 주입 const { getActionDispatcher } = await import('./template-engine'); const actionDispatcher = getActionDispatcher(); if (actionDispatcher) { actionDispatcher.setDefaultContext({ navigate: (path: string) => this.router?.navigate(path), }); // setGlobalState 함수 주입 actionDispatcher.setGlobalStateUpdater((updates: any, opts?: { render?: boolean }) => this.setGlobalState(updates, opts)); logger.log('Navigate function and setGlobalState injected to ActionDispatcher'); } // 7. 모듈/플러그인 에셋 로드 (핸들러 등록 위해 초기 라우트 처리 전에 실행) await this.loadExtensionAssets(); // 7.5 템플릿 핸들러 등록 (window.G7TemplateHandlers에서) // components.iife.js의 initTemplate()은 window.load 이벤트에서 핸들러를 등록하지만, // app.init()은 DOMContentLoaded에서 시작되므로 init_actions가 window.load 전에 실행될 수 있음. // 이를 방지하기 위해 초기화 시점에 직접 등록합니다. this.reinitializeTemplateHandlers(); // 8. 라우트 변경 이벤트 핸들러 등록 this.router.on('routeChange', (route: Route) => this.handleRouteChange(route)); // 8.5 routeNotFound 이벤트 핸들러 등록 (404 에러 페이지 처리) this.router.on('routeNotFound', (path: string) => this.handleRouteNotFound(path)); // 8.6 레이아웃 편집기 모드 가드 // // URL 이 `/admin/layout-editor/:identifier` 패턴이면 라우터 매칭을 건너뛰고 // 직접 renderTemplate 호출 — template-engine.ts 의 checkLayoutEditorMode 분기가 // LayoutEditorChrome 을 같은 reactRoot + 코어 컨텍스트 안에서 렌더한다. // // 이 가드가 없으면 `/admin/layout-editor/...` 가 일반 라우트에 매칭되지 않아 // routeNotFound → 404 페이지 렌더 흐름을 타게 되고, 그 안의 renderTemplate // 호출에서야 비로소 편집기 분기가 작동한다. 결과적으로 화면은 정상이지만 // 콘솔에 `[Router] No route matched` 워닝 + 불필요한 `/api/layouts/.../404.json` // fetch 가 발생하며, 후속 Phase 에서 라우트 의존 기능 도입 시 회귀 위험이 있다. if (typeof window !== 'undefined' && checkLayoutEditorMode(window.location.pathname)) { logger.log('Layout editor mode detected — skipping router match'); await renderTemplate({ containerId: 'app', layoutJson: { components: [] } as any, dataContext: {}, translationContext: { templateId: this.config.templateId, locale: this.config.locale, }, }); logger.log('Template App initialized in layout editor mode'); return; } // 9. 초기 라우트 처리 this.router.navigateToCurrentPath(); logger.log('Template App initialized successfully'); } catch (error) { logger.error('Initialization failed:', error); this.showInitError(error as Error); } } /** * 모듈/플러그인 에셋 로드 * * window.G7Config.moduleAssets 및 pluginAssets에서 에셋 정보를 읽어 * 동적으로 JS/CSS를 로드합니다. * * 모듈 JS는 IIFE 형태로 빌드되어 로드 즉시 initModule()이 실행되고, * ActionDispatcher에 핸들러가 등록됩니다. */ private async loadExtensionAssets(): Promise { try { const moduleAssetLoader = getModuleAssetLoader(); // 모듈 에셋 로드 const moduleAssets = parseModuleAssetsFromConfig(); if (moduleAssets.length > 0) { logger.log('Loading module assets:', moduleAssets.map(m => m.identifier)); await moduleAssetLoader.loadActiveExtensionAssets(moduleAssets); } // 플러그인 에셋 로드 const pluginAssets = parsePluginAssetsFromConfig(); if (pluginAssets.length > 0) { logger.log('Loading plugin assets:', pluginAssets.map(p => p.identifier)); await moduleAssetLoader.loadActiveExtensionAssets(pluginAssets); } if (moduleAssets.length > 0 || pluginAssets.length > 0) { logger.log('Extension assets loaded successfully'); } } catch (error) { // 에셋 로드 실패는 경고만 출력하고 앱 계속 진행 logger.warn('Failed to load extension assets:', error); } } /** * 라우트 변경 핸들러 */ private async handleRouteChange(route: Route): Promise { // 새 요청 ID 생성 (이전 요청 무시용) const routeChangeId = ++this.currentRouteChangeId; // engine-v1.17.4: 페이지 전환 시 전역 상태 컨텍스트 초기화 // - __g7ForcedLocalFields: 비동기 setLocal fallback에서 설정된 강제 필드 // - __g7ActionContext: 이전 페이지의 액션 컨텍스트 (stale setState 방지) // - __g7PendingLocalState: 이전 페이지의 pending 상태 (window as any).__g7ForcedLocalFields = undefined; (window as any).__g7ActionContext = undefined; (window as any).__g7PendingLocalState = undefined; // engine-v1.24.6: 추가 전역 변수 초기화 (이전 페이지 잔존 방지) // - __g7LastSetLocalSnapshot: getLocal() fallback — 이전 페이지 setLocal 스냅샷 잔존 // - __g7SetLocalOverrideKeys: setLocal override 키 — useLayoutEffect 미처리 시 잔존 // - __g7SequenceLocalSync: sequence 핸들러 동기화 — 이전 페이지 sequence 상태 잔존 (window as any).__g7LastSetLocalSnapshot = undefined; (window as any).__g7SetLocalOverrideKeys = undefined; (window as any).__g7SequenceLocalSync = undefined; // [engine-v1.43.0+] 자동바인딩 경로 레지스트리 — 이전 페이지 컴포넌트의 언마운트가 라우트 전환과 // 경쟁할 수 있으므로 강제 재초기화. undefined 대신 빈 Map을 써서 경쟁 상태의 이전 페이지 cleanup이 // 이후에 registry.delete() 시도할 때 참조 오류 방지. (window as any).__g7AutoBindingPaths = new Map(); try { logger.log('Route changed:', route, 'requestId:', routeChangeId); if (!this.layoutLoader) { throw new Error('LayoutLoader is not initialized'); } // 1. 레이아웃 JSON 로드 if (!route.layout) { throw new Error('Route layout is not defined'); } // 시스템 레이아웃: __preview__ 는 프리뷰 API 엔드포인트로 매핑 let layoutPath = route.layout; const isPreviewMode = layoutPath === '__preview__'; if (isPreviewMode && route.params?.token) { layoutPath = `__preview__/${route.params.token}`; } const layoutData = await this.layoutLoader.loadLayout( this.config.templateId, layoutPath ); // 새 요청이 들어왔으면 현재 요청 무시 if (routeChangeId !== this.currentRouteChangeId) { logger.log('Route change cancelled (newer request exists):', routeChangeId); return; } logger.log('Layout loaded:', layoutData); // 1.5 레이아웃 레벨 에러 핸들링 설정을 ErrorHandlingResolver에 등록 const errorHandlingResolver = getErrorHandlingResolver(); if (layoutData.errorHandling) { errorHandlingResolver.setLayoutConfig(layoutData.errorHandling); logger.log('Layout errorHandling registered:', layoutData.errorHandling); } else { // 이전 레이아웃의 설정 초기화 errorHandlingResolver.clearLayoutConfig(); } // 2. data_sources 조건부 필터링 및 분류 const rawDataSources = layoutData.data_sources || []; // Router에서 전달받은 query 정보 사용 (window.location.search 대신) // 배열 쿼리 파라미터 지원 (key[]=[...] 형태) const queryObject: Record = route.query || {}; // queryObject를 URLSearchParams로 변환 (배열 값 지원) const queryParams = new URLSearchParams(); for (const [key, value] of Object.entries(queryObject)) { if (Array.isArray(value)) { for (const item of value) { queryParams.append(key, item); } } else { queryParams.set(key, value); } } // 조건 평가용 컨텍스트 구성 (route, query, _global) const conditionContext = { route: route.params || {}, query: queryObject, _global: this.globalState, }; // 2.5 외부 스크립트 로드 (scripts 속성 처리) if (layoutData.scripts && Array.isArray(layoutData.scripts)) { await this.loadLayoutScripts(layoutData.scripts, conditionContext); } // DataSourceManager를 사용하여 조건부 필터링 const { DataSourceManager, getActionDispatcher } = await import('./template-engine'); const dataSourceManager = new DataSourceManager(); // 프리뷰 모드 설정: ActionDispatcher에 억제 모드 적용 + 전역 상태 플래그 + 배너 표시 // @since engine-v1.26.1 if (isPreviewMode) { const actionDispatcher = getActionDispatcher(); if (actionDispatcher) { actionDispatcher.setPreviewMode(true); } // _global.__isPreview 플래그 주입 (레이아웃 JSON에서 조건부 렌더링에 사용 가능) this.setGlobalState({ __isPreview: true }); // 프리뷰 안내 배너 표시 SystemBannerManager.show({ id: 'preview-mode', message: { ko: '\u26a0 미리보기 모드 — 페이지 이동이 비활성화됩니다', en: '\u26a0 Preview Mode — Navigation is disabled', }, background: 'linear-gradient(90deg, #f59e0b, #d97706)', color: 'white', }); logger.log('Preview mode activated'); } else { // 프리뷰 모드 해제 (이전 프리뷰 상태가 남아있을 수 있으므로) const actionDispatcher = getActionDispatcher(); if (actionDispatcher?.isPreviewMode()) { actionDispatcher.setPreviewMode(false); SystemBannerManager.hide('preview-mode'); } } // globalHeaders 설정 (레이아웃에서 정의한 전역 헤더) // 클래스 속성에 저장하여 refetchDataSource에서도 사용 this.currentGlobalHeaders = layoutData.globalHeaders || []; if (this.currentGlobalHeaders.length > 0) { dataSourceManager.setGlobalHeaders(this.currentGlobalHeaders); // ActionDispatcher에도 globalHeaders 설정 const actionDispatcher = getActionDispatcher(); if (actionDispatcher) { actionDispatcher.setGlobalHeaders(this.currentGlobalHeaders); } logger.log('globalHeaders set:', this.currentGlobalHeaders.map((h: any) => h.pattern)); } // named_actions 설정 (레이아웃에서 정의한 재사용 가능 액션) if (layoutData.named_actions && Object.keys(layoutData.named_actions).length > 0) { const actionDispatcher = getActionDispatcher(); if (actionDispatcher) { actionDispatcher.setNamedActions(layoutData.named_actions); } } // if 조건에 따라 데이터 소스 필터링 (같은 id 중 조건 만족하는 첫 번째만 선택) const dataSources = dataSourceManager.filterByCondition(rawDataSources, conditionContext); if (rawDataSources.length !== dataSources.length) { logger.log('Data sources filtered by condition:', { before: rawDataSources.map((s: any) => s.id), after: dataSources.map((s: any) => s.id), }); } const blockingSources = dataSources.filter( (source: any) => source.loading_strategy === 'blocking' ); // WebSocket 소스는 이벤트 리스너(실시간 알림)이지 데이터 제공자가 아님 // Step 6에서 별도로 구독 처리되므로 progressive 목록에서 제외 // 포함 시 progressiveDataInit에서 undefined로 초기화되어 blur_until_loaded가 영구 블러됨 const progressiveAndBackgroundSources = dataSources.filter( (source: any) => (source.loading_strategy || 'progressive') !== 'blocking' && source.type !== 'websocket' ); // 2.5 transition 오버레이: blocking 데이터 fetch 전 또는 wait_for 명시 시 표시 (@since engine-v1.24.0, wait_for engine-v1.30.0) // blocking 데이터 로딩 대기 시간 또는 progressive 데이터 fetch 완료까지 skeleton/spinner UI 표시 // 3단계 타겟팅: target → fallback_target → fullpage (@since engine-v1.24.2) // // wait_for: progressive 데이터소스를 명시적으로 spinner 가드 대상으로 등록 // - background/websocket 데이터소스는 의도상 사용자 차단 불가 → 자동 무시 // - 존재하지 않는 ID 도 자동 무시 (검증은 백엔드 UpdateLayoutContentRequest 에서 수행) const waitForIds: string[] = Array.isArray((layoutData.transition_overlay as any)?.wait_for) ? ((layoutData.transition_overlay as any).wait_for as string[]) : []; const waitForActive = waitForIds.length > 0 && dataSources.some((source: any) => waitForIds.includes(source.id) && source.type !== 'websocket' && (source.loading_strategy || 'progressive') !== 'background' ); if ((blockingSources.length > 0 || waitForActive) && layoutData.transition_overlay) { const overlayConfig: any = typeof layoutData.transition_overlay === 'boolean' ? { enabled: layoutData.transition_overlay, style: 'opaque' } : layoutData.transition_overlay; if (overlayConfig.enabled && overlayConfig.style === 'skeleton' && overlayConfig.skeleton?.component && overlayConfig.target) { this.renderSkeletonOverlay(overlayConfig.target, overlayConfig.skeleton, layoutData, overlayConfig.fallback_target); } else if (overlayConfig.enabled && overlayConfig.style === 'spinner' && overlayConfig.target) { this.renderSpinnerOverlay(overlayConfig.target, overlayConfig.spinner, overlayConfig.fallback_target); } } // 3. blocking 데이터 소스 먼저 fetch (렌더링 전에 완료 필요) let blockingData: Record = {}; let dataSourceErrors: Record = {}; // initLocal 옵션으로 초기화할 로컬 상태 let localInit: Record = {}; // initIsolated 옵션으로 초기화할 격리된 상태 let isolatedInit: Record = {}; // 이전 WebSocket 구독 해제 if (this.currentWebSocketSubscriptions.length > 0) { logger.log('Unsubscribing previous WebSocket subscriptions:', this.currentWebSocketSubscriptions); dataSourceManager.unsubscribeWebSockets(this.currentWebSocketSubscriptions); this.currentWebSocketSubscriptions = []; } // 현재 데이터 소스 정보 저장 (refetch용) this.currentDataSources = dataSources; // 원본(if 필터링 전) 보존 — updateQueryParams(replace:true) 의 if 재평가용 this.currentRawDataSources = rawDataSources; this.currentRouteParams = route.params || {}; this.currentQueryParams = queryParams; // DEBUG: queryParams 로깅 logger.log(`handleRouteChange #${routeChangeId} - queryObject:`, queryObject); logger.log(`handleRouteChange #${routeChangeId} - queryParams:`, queryParams.toString()); if (blockingSources.length > 0) { logger.log('Fetching blocking data sources:', blockingSources.map((s: any) => s.id)); // fetchDataSourcesWithResults를 사용하여 에러 상태도 추적 const results = await dataSourceManager.fetchDataSourcesWithResults( blockingSources, route.params || {}, queryParams ); // 결과를 데이터와 에러로 분리 results.forEach((result) => { if (result.state === 'success' && result.data !== undefined) { blockingData[result.id] = result.data; } else if (result.state === 'error' && result.error) { // Axios 에러인 경우 response.data.message에서 실제 API 응답 메시지 추출 const axiosError = result.error as any; const apiMessage = axiosError.response?.data?.message; dataSourceErrors[result.id] = { message: apiMessage || result.error.message, status: axiosError.response?.status || (axiosError as any).status, }; } }); // initLocal/initGlobal/initIsolated 처리 (blocking 데이터 소스) this.processInitOptions(blockingSources, blockingData, localInit, isolatedInit); // fetch된 데이터 저장 (refetch 및 get용) this.currentFetchedData = { ...blockingData }; logger.log('Blocking data loaded:', Object.keys(blockingData)); if (Object.keys(dataSourceErrors).length > 0) { logger.log('Data source errors:', dataSourceErrors); } if (Object.keys(localInit).length > 0) { logger.log('Local state init (blocking):', Object.keys(localInit)); } if (Object.keys(isolatedInit).length > 0) { logger.log('Isolated state init (blocking):', Object.keys(isolatedInit)); } } // 4. progressive + background 데이터가 있으면 Transition 시작 (렌더링 전) // blur_until_loaded 컴포넌트가 blur 효과를 적용할 수 있도록 renderTemplate 전에 설정 const hasProgressiveData = progressiveAndBackgroundSources.length > 0; if (hasProgressiveData) { transitionManager.setPending(true); logger.log('Transition started before rendering'); } // 새 요청이 들어왔으면 현재 요청 무시 (blocking 데이터 로드 후) if (routeChangeId !== this.currentRouteChangeId) { logger.log('Route change cancelled after blocking data (newer request exists):', routeChangeId); return; } // 5. 초기 렌더링 (blocking 데이터 + 라우트 파라미터 + 쿼리 파라미터 + 전역 상태) // progressive 데이터 소스가 있을 때 이전 데이터 컨텍스트 유지 // blur_until_loaded 컴포넌트가 이전 데이터를 보여주면서 blur 효과 적용 const { getState } = await import('./template-engine'); const previousDataContext = getState().currentDataContext || {}; // progressive 데이터 소스 ID 목록 추출 const progressiveDataSourceIds = progressiveAndBackgroundSources.map((s: any) => s.id); // progressive 데이터 소스 초기화 (blur_until_loaded가 개별 위젯에서 작동하도록) // 각 데이터 소스 키를 undefined로 초기화하여 dataContext에 존재하게 함 // 이렇게 해야 blur_until_loaded가 자신의 data_sources만 체크할 수 있음 const progressiveDataInit: Record = {}; if (hasProgressiveData) { progressiveDataSourceIds.forEach((id: string) => { // 이전 데이터 컨텍스트에 값이 있으면 유지, 없으면 undefined로 초기화 if (previousDataContext[id] !== undefined) { progressiveDataInit[id] = previousDataContext[id]; } else { // undefined로 명시적 초기화 (키가 존재해야 blur_until_loaded가 체크 가능) progressiveDataInit[id] = undefined; } }); const preservedKeys = Object.keys(progressiveDataInit).filter( id => progressiveDataInit[id] !== undefined ); if (preservedKeys.length > 0) { logger.log('Preserving previous progressive data:', preservedKeys); } logger.log('Progressive data sources initialized:', progressiveDataSourceIds); } // 주의: initialDataContext 생성 시점에 this.globalState를 참조 // navigate와 toast가 동시에 실행되는 경우, toast가 setGlobalState를 호출하여 // this.globalState.toasts가 설정될 수 있음. 이를 반영하기 위해 // renderTemplate 직전에 _global을 다시 갱신함 // defines 처리: 레이아웃에 정의된 정적 상수를 _defines에 주입 const definesData = layoutData.defines || {}; const initialDataContext = { ...progressiveDataInit, // progressive 데이터 소스 초기화 (blur_until_loaded 지원) ...route.params, ...blockingData, route: { ...(route.params || {}), path: route.path }, query: queryObject, _global: { ...this.globalState }, // 나중에 갱신됨 _globalSetState: (updates: Partial) => this.setGlobalState(updates), // Form dataKey="_global.xxx" 지원 _dataSourceErrors: Object.keys(dataSourceErrors).length > 0 ? dataSourceErrors : undefined, // initLocal 옵션으로 초기화할 로컬 상태 (DynamicRenderer에서 처리) _localInit: Object.keys(localInit).length > 0 ? localInit : undefined, // initIsolated 옵션으로 초기화할 격리된 상태 (IsolatedStateProvider에서 처리) _isolatedInit: Object.keys(isolatedInit).length > 0 ? isolatedInit : undefined, // defines: 레이아웃에 정의된 정적 상수 (변경 불가) _defines: Object.keys(definesData).length > 0 ? definesData : undefined, }; // computed 처리: 레이아웃에 정의된 파생 상태 계산 if (layoutData.computed && Object.keys(layoutData.computed).length > 0) { const computedData = this.calculateComputed(layoutData.computed, initialDataContext); if (Object.keys(computedData).length > 0) { (initialDataContext as any)._computed = computedData; // globalState에도 _computed 저장 (DataGrid expandChildren 등에서 접근 가능하도록) this.globalState._computed = computedData; logger.log('Computed values calculated:', Object.keys(computedData)); } // computed 정의를 dataContext에 저장 (DynamicRenderer에서 _local 변경 시 재계산용) (initialDataContext as any)._computedDefinitions = layoutData.computed; } // 레이아웃 전환 감지: 다른 레이아웃이면 _local 완전 초기화 // (initLocal 유무와 무관하게 항상 실행되어야 함) const newLayoutName = layoutData.layout_name || route.layout; const isLayoutChanged = this.currentLayoutName !== '' && this.currentLayoutName !== newLayoutName; if (isLayoutChanged) { // 다른 레이아웃으로 전환 → _local 완전 초기화 (이전 레이아웃 잔존값 제거) logger.log('_local reset due to layout change:', { from: this.currentLayoutName, to: newLayoutName }); this.globalState._local = {}; } this.currentLayoutName = newLayoutName; // 레이아웃 레벨 상태 초기화 (정적 초기값 설정) // 실행 순서: initLocal/initGlobal/initIsolated → 데이터소스 initLocal/initGlobal/initIsolated → initActions // initLocal 또는 state (하위 호환) 처리 const layoutInitLocal = layoutData.initLocal || layoutData.state; if (layoutInitLocal && Object.keys(layoutInitLocal).length > 0) { // _local 상태가 없으면 초기화 if (!this.globalState._local) { this.globalState._local = {}; } // initLocal 블록의 값을 _local에 병합 // 기존 _local 값이 있으면 유지하고, 없는 키만 initLocal에서 가져옴 for (const [key, value] of Object.entries(layoutInitLocal)) { if (this.globalState._local[key] === undefined) { this.globalState._local[key] = JSON.parse(JSON.stringify(value)); } } // dataContext에도 반영 (initialDataContext as any)._local = { ...this.globalState._local }; logger.log('initLocal applied to _local:', Object.keys(layoutInitLocal)); } // initGlobal 처리 (레이아웃 레벨 _global 초기값) if (layoutData.initGlobal && Object.keys(layoutData.initGlobal).length > 0) { // initGlobal 블록의 값을 _global에 깊은 병합 // 기존 _global 값이 있으면 유지하고, 없는 키만 initGlobal에서 가져옴 for (const [key, value] of Object.entries(layoutData.initGlobal)) { if (this.globalState[key] === undefined) { this.globalState[key] = JSON.parse(JSON.stringify(value)); } } logger.log('initGlobal applied to _global:', Object.keys(layoutData.initGlobal)); } // initIsolated 처리 (레이아웃 레벨 _isolated 초기값) // isolatedInit에 저장하여 DynamicRenderer에서 IsolatedStateProvider에 전달 if (layoutData.initIsolated && Object.keys(layoutData.initIsolated).length > 0) { // 기존 isolatedInit과 병합 for (const [key, value] of Object.entries(layoutData.initIsolated)) { if (isolatedInit[key] === undefined) { isolatedInit[key] = JSON.parse(JSON.stringify(value)); } } logger.log('initIsolated applied:', Object.keys(layoutData.initIsolated)); } // _global을 최신 상태로 갱신 (initLocal/initGlobal/initIsolated 처리 이후) // _local 리셋 및 layoutInitLocal 적용이 완료된 상태에서 스냅샷해야 // SPA 네비게이션 시 이전 페이지의 stale _local 키가 _global._local에 남지 않음 initialDataContext._global = { ...this.globalState, // 레이아웃 경고를 _global에 주입 (베이스 레이아웃에서 LayoutWarnings 컴포넌트가 사용) layoutWarnings: layoutData.warnings || [], }; // initActions 또는 init_actions 실행 (렌더링 전에 실행하여 _local/_global 초기값 설정) // initActions가 우선, init_actions는 하위 호환을 위해 지원 const initActionsToExecute = layoutData.initActions || layoutData.init_actions; if (initActionsToExecute && initActionsToExecute.length > 0) { await this.executeInitActions(initActionsToExecute, initialDataContext); logger.log('initActions executed before render'); // initActions에서 설정한 상태를 dataContext에 반영 const globalState = this.globalState; // _local 상태 반영 if (globalState._local) { // initActions에서 설정한 _local과 processInitOptions에서 설정한 localInit 병합 // localInit가 우선 (API 데이터가 init_actions 기본값을 덮어씀) // 이 병합이 없으면 첫 번째 렌더링에서 init_actions의 빈 배열이 사용됨 if (Object.keys(localInit).length > 0) { for (const [key, value] of Object.entries(localInit)) { if (globalState._local[key] !== undefined) { globalState._local[key] = this.deepMerge(globalState._local[key], value); } else { globalState._local[key] = value; } } logger.log('localInit merged into _local after initActions:', Object.keys(localInit)); } (initialDataContext as any)._local = globalState._local; logger.log('_local merged into dataContext:', globalState._local); } // _global 상태 반영 (dataKey="_global.xxx" 지원) // initActions에서 setState target: "global"로 설정한 값들을 반영 initialDataContext._global = { ...this.globalState, layoutWarnings: layoutData.warnings || [], }; logger.log('_global merged into dataContext after initActions'); // initActions 실행 후 computed 재계산 (initActions에서 설정한 _local/_global 값 반영) if (layoutData.computed && Object.keys(layoutData.computed).length > 0) { const computedData = this.calculateComputed(layoutData.computed, initialDataContext); if (Object.keys(computedData).length > 0) { (initialDataContext as any)._computed = computedData; this.globalState._computed = computedData; logger.log('Computed values recalculated after init_actions:', Object.keys(computedData)); } } // initActions에서 refetchDataSource로 로드된 데이터소스 데이터를 dataContext에 반영 // 이 처리가 없으면 refetchDataSource가 updateTemplateData를 호출해도 // renderTemplate에서 dataContext가 새로 설정되면서 데이터 손실 발생 // (troubleshooting-state-global.md 사례 3 참조) if (Object.keys(this.currentFetchedData).length > 0) { for (const [dsId, dsData] of Object.entries(this.currentFetchedData)) { (initialDataContext as any)[dsId] = dsData; } logger.log('Fetched data sources merged into dataContext after initActions:', Object.keys(this.currentFetchedData)); } } // transition_overlay: 레이아웃 전환 시 stale DOM 방지 오버레이 (@since engine-v1.23.0) // skeleton 스타일은 blocking fetch 전에 이미 표시됨 (step 2.5) // 여기서는 non-skeleton 스타일(opaque/blur/fade)만 처리 if (layoutData.transition_overlay && !this.skeletonOverlayContainer) { this.showTransitionOverlay(layoutData.transition_overlay, layoutData); } await renderTemplate({ containerId: 'app', layoutJson: layoutData, dataContext: initialDataContext, translationContext: { templateId: this.config.templateId, locale: this.config.locale, }, }); logger.log('Initial render complete with blocking data'); // renderTemplate이 #app DOM을 교체하면 타겟 내부의 spinner도 사라짐 // 새 DOM의 타겟에 spinner를 재생성 (@since engine-v1.29.0) this.reattachSpinnerOverlay(); // 6. progressive + background 데이터 소스 fetch (렌더링 후) if (progressiveAndBackgroundSources.length > 0) { // 새 요청이 들어왔으면 현재 요청 무시 (progressive fetch 전) if (routeChangeId !== this.currentRouteChangeId) { logger.log('Route change cancelled before progressive fetch (newer request exists):', routeChangeId); // transition 상태도 정리 transitionManager.setPending(false); this.hideTransitionOverlay(); return; } logger.log('Fetching progressive/background data sources:', progressiveAndBackgroundSources.map((s: any) => s.id)); const { updateTemplateData, getState } = await import('./template-engine'); try { // progressive/background 데이터를 개별적으로 fetch하고 완료될 때마다 업데이트 // 각 데이터 소스가 완료되면 해당 위젯의 blur만 해제됨 const fetchPromises = progressiveAndBackgroundSources.map(async (source: any) => { try { const results = await dataSourceManager.fetchDataSourcesWithResults( [source], route.params || {}, queryParams, this.globalState // blocking 데이터 소스의 initGlobal로 설정된 값 접근 지원 ); const result = results[0]; // 결과가 없으면 무시 (websocket 타입 등 fetch 대상이 아닌 경우) if (!result) { logger.log(`Data source ${source.id} skipped (no fetch result)`); return; } // 새 요청이 들어왔으면 현재 요청 무시 if (routeChangeId !== this.currentRouteChangeId) { logger.log(`Data source ${source.id} fetch cancelled (newer request exists)`); return; } if (result.state === 'success' && result.data !== undefined) { // 개별 데이터 소스 로드 완료 - 즉시 업데이트하여 해당 위젯 blur 해제 logger.log(`Progressive data source loaded: ${source.id}`); // fetch된 데이터 업데이트 this.currentFetchedData[source.id] = result.data; // initLocal/initGlobal 처리 const singleLocalInit: Record = {}; this.processInitOptions([source], { [source.id]: result.data }, singleLocalInit); // 캐시 무효화 const state = getState(); if (state.bindingEngine) { const keysToInvalidate = [source.id]; // initGlobal이 있으면 _global 키도 무효화 if (source.initGlobal) { keysToInvalidate.push('_global'); } // initLocal이 있으면 _local 키도 무효화 if (source.initLocal) { keysToInvalidate.push('_local'); } state.bindingEngine.invalidateCacheByKeys(keysToInvalidate); } // 개별 데이터 소스 업데이트 (해당 위젯 blur 해제) const singleUpdateData: Record = { [source.id]: result.data, }; // initLocal이 있으면 추가 if (Object.keys(singleLocalInit).length > 0) { singleUpdateData._localInit = singleLocalInit; Object.assign(localInit, singleLocalInit); } // initGlobal이 있으면 _global 상태도 업데이트 if (source.initGlobal) { singleUpdateData._global = { ...this.globalState }; } updateTemplateData(singleUpdateData); } else if (result.state === 'error' && result.error) { // 에러 처리 const axiosError = result.error as any; const apiMessage = axiosError.response?.data?.message; dataSourceErrors[source.id] = { message: apiMessage || result.error.message, status: axiosError.response?.status || (axiosError as any).status, }; logger.log(`Progressive data source error: ${source.id}`, dataSourceErrors[source.id]); // 에러가 발생해도 undefined가 아닌 에러 상태로 표시하여 blur 해제 // _dataSourceErrors를 업데이트하여 컴포넌트에서 에러 상태 확인 가능 updateTemplateData({ [source.id]: null, // null로 설정하여 로딩 완료 표시 (에러 상태) _dataSourceErrors: { ...dataSourceErrors }, }); } } catch (error) { logger.error(`Failed to fetch data source: ${source.id}`, error); } }); // 모든 fetch 완료 대기 await Promise.all(fetchPromises); // 새 요청이 들어왔으면 현재 요청 무시 (progressive fetch 후) if (routeChangeId !== this.currentRouteChangeId) { logger.log('Route change cancelled after progressive fetch (newer request exists):', routeChangeId); return; } logger.log('All progressive data sources loaded'); } finally { // Transition 종료 (isPending = false) transitionManager.setPending(false); this.hideTransitionOverlay(); } } else { // progressive 데이터가 없으면 즉시 오버레이 제거 this.hideTransitionOverlay(); } // 7. WebSocket 데이터 소스 구독 (실시간 업데이트용) // progressive fetch 완료 후 구독: channel/event 표현식이 fetched 데이터(current_user 등) 참조 가능 const webSocketSources = dataSources.filter((s: any) => s.type === 'websocket'); if (webSocketSources.length > 0) { logger.log('Subscribing WebSocket data sources:', webSocketSources.map((s: any) => s.id)); const { updateTemplateData: wsUpdateData, getState: wsGetState } = await import('./template-engine'); // 채널/이벤트 표현식 평가 컨텍스트 // blocking + progressive 데이터 모두 포함 (표현식이 fetched 데이터 참조 가능) const wsBindingContext = { ...this.currentFetchedData, ...route.params, route: { ...(route.params || {}), path: route.path }, query: queryObject, _global: { ...this.globalState }, }; // 디버깅: WebSocket 구독 직전 컨텍스트 키 로깅 // 표현식 평가 실패 시 어떤 키가 누락되었는지 확인용 logger.log('WebSocket binding context keys:', Object.keys(wsBindingContext)); this.currentWebSocketSubscriptions = dataSourceManager.subscribeWebSockets( dataSources, (sourceId: string, data: unknown) => { // WebSocket에서 데이터 수신 시 템플릿 업데이트 logger.log(`WebSocket data received for: ${sourceId}`, data); // fetch된 데이터 업데이트 this.currentFetchedData[sourceId] = data; // 타겟 데이터 소스 정의 찾기 (initGlobal/initLocal 캐시 무효화용) const targetSource = dataSources.find((s: any) => s.id === sourceId); // 캐시 무효화 const state = wsGetState(); if (state.bindingEngine) { const keysToInvalidate = [sourceId]; // 타겟 데이터 소스에 initGlobal이 있으면 _global 키도 무효화 if (targetSource?.initGlobal) { keysToInvalidate.push('_global'); } // 타겟 데이터 소스에 initLocal이 있으면 _local 키도 무효화 if (targetSource?.initLocal) { keysToInvalidate.push('_local'); } state.bindingEngine.invalidateCacheByKeys(keysToInvalidate); } // 템플릿 업데이트 wsUpdateData({ [sourceId]: data }); // engine-v1.33.0: WebSocket 데이터 소스의 onReceive 액션 실행 // websocket 소스 정의(원본)에서 onReceive 배열을 찾음 // sourceId는 target_source가 적용된 ID이므로 원본 websocket 소스를 별도로 찾음 const websocketSource = dataSources.find( (s: any) => s.type === 'websocket' && (s.target_source || s.id) === sourceId ); const onReceiveActions = websocketSource?.onReceive; if (Array.isArray(onReceiveActions) && onReceiveActions.length > 0) { const G7Core = (window as any).G7Core; if (G7Core?.dispatch) { // 각 액션을 순차 실행, $args[0]로 페이로드 접근 가능 (async () => { for (const action of onReceiveActions) { try { // dispatchAction에 직접 호출하여 $args 컨텍스트 주입 const actionDispatcher = this.getActionDispatcher?.(); if (actionDispatcher) { await actionDispatcher.dispatchAction(action, { navigate: this.getRouter?.() ? (path: string, opts?: any) => this.getRouter().navigate(path, opts) : undefined, setState: (updates: any) => this.setGlobalState(updates), state: this.globalState, data: { ...this.globalState, $args: [data], $event: data }, _isDispatchFallbackContext: true, }); } } catch (err) { logger.error( `WebSocket onReceive action failed for ${sourceId}:`, err ); } } })(); } } }, wsBindingContext ); logger.log('WebSocket subscriptions established:', this.currentWebSocketSubscriptions); } logger.log('Layout rendered successfully'); } catch (error) { logger.error('Route change handling failed:', error); this.hideTransitionOverlay(); this.showRouteError(error as Error); } } /** * initLocal/initGlobal/initIsolated 옵션 처리 * * 데이터 소스의 initLocal/initGlobal/initIsolated 옵션에 따라 * 응답 데이터를 로컬/전역/격리된 상태에 복사합니다. * * initLocalDefaults 지원: * - initLocalDefaults 객체의 각 키-값을 기본값으로 사용 * - API 응답 데이터와 병합하여 API 값이 없으면 기본값 사용 * - 값이 {{...}} 표현식이면 fetchedData를 컨텍스트로 평가 * * refetchOnMount: true인 데이터 소스가 있으면 _forceLocalInit 플래그를 추가하여 * DynamicRenderer에서 해시 비교 없이 강제로 로컬 상태를 초기화하도록 합니다. * * @param dataSources 데이터 소스 배열 * @param fetchedData fetch된 데이터 (sourceId -> data) * @param localInit 로컬 상태 초기화 객체 (mutate됨) * @param isolatedInit 격리된 상태 초기화 객체 (선택적, mutate됨) */ private processInitOptions( dataSources: any[], fetchedData: Record, localInit: Record, isolatedInit?: Record ): void { // refetchOnMount: true인 데이터 소스가 있는지 확인 const hasRefetchOnMount = dataSources.some( (source: any) => source.refetchOnMount === true && source.initLocal ); // refetchOnMount가 있으면 강제 초기화 플래그 추가 if (hasRefetchOnMount) { localInit._forceLocalInit = Date.now(); logger.log('refetchOnMount detected, forcing local state init'); } dataSources.forEach((source: any) => { const data = fetchedData[source.id]; if (!data) { return; } // API 응답에서 실제 데이터 추출 (data.data 또는 data 자체) const actualData = data.data ?? data; // initLocal 처리: _local[key]에 데이터 복사 (깊은 병합) // 레이아웃 레벨 initLocal 기본값이 있으면 유지하고, 데이터소스 값과 병합 // 문자열 형태: 전체 데이터 저장 // 객체 형태 { key, path }: 특정 필드만 저장 // 맵 형태 { targetKey: pathOrExpression }: 여러 필드를 각각 저장 (engine-v1.7.0+) if (source.initLocal) { if (typeof source.initLocal === 'string') { let mergedData = actualData; // initLocalDefaults가 있으면 기본값과 병합 if (source.initLocalDefaults && typeof source.initLocalDefaults === 'object') { const defaults = this.evaluateDefaults(source.initLocalDefaults, fetchedData); // 기본값을 먼저 적용하고, API 데이터로 덮어쓰기 (API 값 우선) mergedData = { ...defaults, ...actualData }; logger.log(`initLocalDefaults applied for ${source.initLocal}:`, Object.keys(defaults)); } // 레이아웃 레벨 initLocal 기본값과 깊은 병합 // 기존 _local[key] 값이 있으면 기본값으로 유지, API 응답 값과 병합 const existingValue = this.globalState._local?.[source.initLocal]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof mergedData === 'object') { localInit[source.initLocal] = this.deepMerge(existingValue, mergedData); logger.log(`initLocal (merged): ${source.id}.data -> _local.${source.initLocal}`); } else { localInit[source.initLocal] = mergedData; logger.log(`initLocal: ${source.id}.data -> _local.${source.initLocal}`); } } else if (typeof source.initLocal === 'object' && source.initLocal.key) { // 레거시 형식: { key: "targetKey", path: "data.path" } const { key, path } = source.initLocal; // path가 지정되면 해당 경로의 데이터만 추출 (표현식 지원) let targetData = path ? this.extractValueByPathOrExpression(actualData, path, source.id) : actualData; // initLocalDefaults가 있으면 기본값과 병합 if (source.initLocalDefaults && typeof source.initLocalDefaults === 'object') { const defaults = this.evaluateDefaults(source.initLocalDefaults, fetchedData); targetData = { ...defaults, ...targetData }; logger.log(`initLocalDefaults applied for ${key}:`, Object.keys(defaults)); } // 레이아웃 레벨 initLocal 기본값과 깊은 병합 const existingValue = this.globalState._local?.[key]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof targetData === 'object') { localInit[key] = this.deepMerge(existingValue, targetData); logger.log(`initLocal (merged): ${source.id}.data${path ? '.' + path : ''} -> _local.${key}`); } else { localInit[key] = targetData; logger.log(`initLocal: ${source.id}.data${path ? '.' + path : ''} -> _local.${key}`); } } else if (typeof source.initLocal === 'object') { // 맵 형식 (engine-v1.7.0+): 다양한 표기법 지원 // 1. 평탄 맵: { "selectedItems": "data.item_ids" } // 2. dot notation 타겟: { "checkout.item_coupons": "data.promotions.item_coupons" } // 3. 중첩 객체: { "checkout": { "item_coupons": "data.promotions.item_coupons" } } // 4. 병합 전략: { "_merge": "deep" | "shallow" | "replace", "key": "path" } // 병합 전략 추출 (기본값: deep) const mergeStrategy = (source.initLocal._merge as 'deep' | 'shallow' | 'replace') || 'deep'; // 중첩 객체 표기법을 dot notation으로 평탄화 const mappings = this.flattenNestedObjectToMappings(source.initLocal); for (const { targetPath, sourcePath } of mappings) { // 경로 또는 표현식에서 값 추출 const targetData = this.extractValueByPathOrExpression(actualData, sourcePath, source.id); // dot notation 경로 처리 if (targetPath.includes('.')) { // 중첩 경로: 기존 _local 값을 먼저 복사 후 setValueAtPath로 병합 const rootKey = targetPath.split('.')[0]; // localInit에 rootKey가 없으면 기존 _local 값 복사 if (localInit[rootKey] === undefined && this.globalState._local?.[rootKey] !== undefined) { // 기존 _local 값을 깊은 복사하여 localInit에 설정 localInit[rootKey] = JSON.parse(JSON.stringify(this.globalState._local[rootKey])); } // 이제 setValueAtPath로 중첩 경로에 값 설정 this.setValueAtPath(localInit, targetPath, targetData, mergeStrategy); logger.log(`initLocal map (${mergeStrategy}): ${source.id} ${sourcePath} -> _local.${targetPath}`); } else { // 단일 키: 기존 로직 (레이아웃 레벨 기본값과 병합) const existingValue = this.globalState._local?.[targetPath]; if (mergeStrategy === 'replace') { localInit[targetPath] = targetData; logger.log(`initLocal map (replace): ${source.id} ${sourcePath} -> _local.${targetPath}`); } else if (mergeStrategy === 'shallow') { if (existingValue !== undefined && typeof existingValue === 'object' && typeof targetData === 'object') { localInit[targetPath] = { ...existingValue, ...targetData }; logger.log(`initLocal map (shallow): ${source.id} ${sourcePath} -> _local.${targetPath}`); } else { localInit[targetPath] = targetData; logger.log(`initLocal map: ${source.id} ${sourcePath} -> _local.${targetPath}`); } } else { // deep merge (기본값) if (existingValue !== undefined && typeof existingValue === 'object' && typeof targetData === 'object') { localInit[targetPath] = this.deepMerge(existingValue, targetData); logger.log(`initLocal map (deep): ${source.id} ${sourcePath} -> _local.${targetPath}`); } else { localInit[targetPath] = targetData; logger.log(`initLocal map: ${source.id} ${sourcePath} -> _local.${targetPath}`); } } } } } } // initGlobal 처리: _global[key]에 데이터 복사 (깊은 병합) // 레이아웃 레벨 initGlobal 기본값이 있으면 유지하고, 데이터소스 값과 병합 // 배열 형태: 여러 전역 상태 동시 초기화 // 문자열 형태: 전체 데이터 저장 // 객체 형태 { key, path }: 특정 필드만 저장 // 맵 형태 { targetKey: pathOrExpression }: 여러 필드를 각각 저장 (engine-v1.7.0+) if (source.initGlobal) { // 맵 형식인지 확인: 배열이 아니고, 객체이고, 'key' 프로퍼티가 없음 const isMapFormat = typeof source.initGlobal === 'object' && !Array.isArray(source.initGlobal) && !('key' in source.initGlobal); if (isMapFormat) { // 맵 형식 (engine-v1.7.0+): { targetKey1: pathOrExpression1, targetKey2: pathOrExpression2 } for (const [targetKey, pathOrExpression] of Object.entries(source.initGlobal as Record)) { if (typeof pathOrExpression !== 'string') { logger.warn(`initGlobal map value must be string, got ${typeof pathOrExpression} for key ${targetKey}`); continue; } // 경로 또는 표현식에서 값 추출 const targetData = this.extractValueByPathOrExpression(actualData, pathOrExpression, source.id); // 레이아웃 레벨 initGlobal 기본값과 깊은 병합 const existingValue = this.globalState[targetKey]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof targetData === 'object') { this.globalState[targetKey] = this.deepMerge(existingValue, targetData); logger.log(`initGlobal map (merged): ${source.id} ${pathOrExpression} -> _global.${targetKey}`); } else { this.globalState[targetKey] = targetData; logger.log(`initGlobal map: ${source.id} ${pathOrExpression} -> _global.${targetKey}`); } } } else { // 기존 형식: 배열 또는 단일 값 const initGlobalItems = Array.isArray(source.initGlobal) ? source.initGlobal : [source.initGlobal]; for (const item of initGlobalItems) { if (typeof item === 'string') { // 레이아웃 레벨 initGlobal 기본값과 깊은 병합 const existingValue = this.globalState[item]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof actualData === 'object') { this.globalState[item] = this.deepMerge(existingValue, actualData); logger.log(`initGlobal (merged): ${source.id}.data -> _global.${item}`); } else { this.globalState[item] = actualData; logger.log(`initGlobal: ${source.id}.data -> _global.${item}`); } } else if (typeof item === 'object' && item.key) { const { key, path } = item; // path가 지정되면 해당 경로의 데이터만 추출 (표현식 지원) const targetData = path ? this.extractValueByPathOrExpression(actualData, path, source.id) : actualData; // 레이아웃 레벨 initGlobal 기본값과 깊은 병합 const existingValue = this.globalState[key]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof targetData === 'object') { this.globalState[key] = this.deepMerge(existingValue, targetData); logger.log(`initGlobal (merged): ${source.id}.data${path ? '.' + path : ''} -> _global.${key}`); } else { this.globalState[key] = targetData; logger.log(`initGlobal: ${source.id}.data${path ? '.' + path : ''} -> _global.${key}`); } } } } } // initIsolated 처리: _isolated[key]에 데이터 복사 (깊은 병합) // 레이아웃 레벨 initIsolated 기본값이 있으면 유지하고, 데이터소스 값과 병합 // isolatedState 속성이 정의된 컴포넌트에서만 유효 // 맵 형태 { targetKey: pathOrExpression }: 여러 필드를 각각 저장 (engine-v1.7.0+) if (isolatedInit && source.initIsolated) { if (typeof source.initIsolated === 'string') { // 레이아웃 레벨 initIsolated 기본값과 깊은 병합 const existingValue = isolatedInit[source.initIsolated]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof actualData === 'object') { isolatedInit[source.initIsolated] = this.deepMerge(existingValue, actualData); logger.log(`initIsolated (merged): ${source.id}.data -> _isolated.${source.initIsolated}`); } else { isolatedInit[source.initIsolated] = actualData; logger.log(`initIsolated: ${source.id}.data -> _isolated.${source.initIsolated}`); } } else if (typeof source.initIsolated === 'object' && source.initIsolated.key) { // 레거시 형식: { key: "targetKey", path: "data.path" } const { key, path } = source.initIsolated; // path가 지정되면 해당 경로의 데이터만 추출 (표현식 지원) const targetData = path ? this.extractValueByPathOrExpression(actualData, path, source.id) : actualData; // 레이아웃 레벨 initIsolated 기본값과 깊은 병합 const existingValue = isolatedInit[key]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof targetData === 'object') { isolatedInit[key] = this.deepMerge(existingValue, targetData); logger.log(`initIsolated (merged): ${source.id}.data${path ? '.' + path : ''} -> _isolated.${key}`); } else { isolatedInit[key] = targetData; logger.log(`initIsolated: ${source.id}.data${path ? '.' + path : ''} -> _isolated.${key}`); } } else if (typeof source.initIsolated === 'object') { // 맵 형식 (engine-v1.7.0+): { targetKey1: pathOrExpression1, targetKey2: pathOrExpression2 } for (const [targetKey, pathOrExpression] of Object.entries(source.initIsolated)) { if (typeof pathOrExpression !== 'string') { logger.warn(`initIsolated map value must be string, got ${typeof pathOrExpression} for key ${targetKey}`); continue; } // 경로 또는 표현식에서 값 추출 const targetData = this.extractValueByPathOrExpression(actualData, pathOrExpression, source.id); // 레이아웃 레벨 initIsolated 기본값과 깊은 병합 const existingValue = isolatedInit[targetKey]; if (existingValue !== undefined && typeof existingValue === 'object' && typeof targetData === 'object') { isolatedInit[targetKey] = this.deepMerge(existingValue, targetData); logger.log(`initIsolated map (merged): ${source.id} ${pathOrExpression} -> _isolated.${targetKey}`); } else { isolatedInit[targetKey] = targetData; logger.log(`initIsolated map: ${source.id} ${pathOrExpression} -> _isolated.${targetKey}`); } } } } }); } /** * initLocalDefaults의 각 값을 평가 * * {{...}} 표현식이면 fetchedData를 컨텍스트로 평가하고, * 그렇지 않으면 원본 값을 그대로 사용합니다. * * @param defaults 기본값 객체 * @param fetchedData fetch된 데이터 (표현식 평가 컨텍스트) * @returns 평가된 기본값 객체 */ private evaluateDefaults( defaults: Record, fetchedData: Record ): Record { const result: Record = {}; for (const [key, value] of Object.entries(defaults)) { if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { // {{...}} 표현식 평가 const expression = value.slice(2, -2).trim(); result[key] = this.evaluateExpression(expression, fetchedData); } else { // 그대로 사용 result[key] = value; } } return result; } /** * 간단한 표현식 평가 * * 점(.) 표기법과 옵셔널 체이닝(?.)을 지원하며, * nullish 병합 연산자(??)도 지원합니다. * * @param expression 평가할 표현식 (예: "board_config?.data?.defaults?.type ?? 'basic'") * @param context 평가 컨텍스트 * @returns 평가 결과 */ private evaluateExpression(expression: string, context: Record): any { try { // nullish 병합 연산자 처리 const nullishParts = expression.split('??').map(p => p.trim()); for (const part of nullishParts) { // 리터럴 값 체크 (따옴표로 감싸진 문자열, 숫자, boolean) if (/^['"].*['"]$/.test(part)) { return part.slice(1, -1); // 따옴표 제거 } if (part === 'true') return true; if (part === 'false') return false; if (/^-?\d+(\.\d+)?$/.test(part)) return Number(part); // 경로 평가 const value = this.getNestedValue(context, part); if (value !== undefined && value !== null) { return value; } } return undefined; } catch (error) { logger.warn(`Expression evaluation failed: ${expression}`, error); return undefined; } } /** * 중첩 객체에서 값 가져오기 * * 점(.) 표기법과 옵셔널 체이닝(?.)을 지원합니다. * * @param obj 대상 객체 * @param path 경로 (예: "board_config?.data?.defaults?.type") * @returns 찾은 값 또는 undefined */ private getNestedValue(obj: any, path: string): any { // ?. 를 . 로 정규화 const normalizedPath = path.replace(/\?\./g, '.'); let current = obj; // 경로를 파싱하여 각 부분 추출 (배열 인덱스 포함) // "[0].name" → ["[0]", "name"] // "data[0].name" → ["data", "[0]", "name"] // "items[0][1].value" → ["items", "[0]", "[1]", "value"] const parts = normalizedPath.split(/\.(?![^\[]*\])/).flatMap(part => { // 배열 인덱스 분리: "data[0]" → ["data", "[0]"] const matches = part.match(/^([^\[]*)((?:\[\d+\])*)$/); if (matches) { const [, base, indices] = matches; const result: string[] = []; if (base) result.push(base); // "[0][1]" → ["[0]", "[1]"] const indexMatches = indices.match(/\[\d+\]/g); if (indexMatches) result.push(...indexMatches); return result; } return [part]; }).filter(p => p !== ''); for (const part of parts) { if (current === undefined || current === null) { return undefined; } // 배열 인덱스 처리: "[0]" → 0 if (part.startsWith('[') && part.endsWith(']')) { const index = parseInt(part.slice(1, -1), 10); current = current[index]; } else { current = current[part]; } } return current; } /** * 경로 또는 표현식에서 값 추출 * * 경로가 {{...}} 형식이면 표현식으로 평가하고, * 그렇지 않으면 일반 경로로 처리합니다. * * @param obj 대상 객체 (API 응답 데이터) * @param pathOrExpression 경로 또는 표현식 * @param sourceId 데이터 소스 ID (로깅용) * @returns 추출된 값 * * @example * // 일반 경로 * extractValueByPathOrExpression(data, "data.items", "cart") * * @example * // 표현식 * extractValueByPathOrExpression(data, "{{data.items.map(i => i.id)}}", "cart") */ private extractValueByPathOrExpression(obj: any, pathOrExpression: string, sourceId: string): any { // 표현식 패턴 확인: {{...}} const expressionMatch = pathOrExpression.match(/^\{\{(.+)\}\}$/); if (expressionMatch) { // 표현식으로 평가 const expression = expressionMatch[1].trim(); const bindingEngine = new DataBindingEngine(); // 컨텍스트 구성: data 변수로 API 응답 접근 가능 const context: Record = { data: obj, // _global, _local도 컨텍스트에 추가 _global: this.globalState, _local: this.globalState._local || {}, }; try { const result = bindingEngine.evaluateExpression(expression, context); logger.log(`initLocal/initGlobal expression evaluated: ${pathOrExpression} -> `, result); return result; } catch (error) { logger.warn(`initLocal/initGlobal expression evaluation failed for ${sourceId}:`, pathOrExpression, error); return undefined; } } // 일반 경로로 처리 return this.getNestedValue(obj, pathOrExpression); } /** * 레이아웃의 외부 스크립트 로드 * * scripts 속성에 정의된 외부 스크립트를 동적으로 로드합니다. * if 조건이 있는 경우 조건을 평가하여 조건을 만족할 때만 로드합니다. * 이미 로드된 스크립트(같은 id)는 중복 로드하지 않습니다. * * @param scripts 스크립트 정의 배열 * @param conditionContext 조건 평가를 위한 컨텍스트 (route, query, _global) */ private async loadLayoutScripts( scripts: LayoutScript[], conditionContext: Record ): Promise { const loadPromises: Promise[] = []; for (const script of scripts) { // 조건 체크 (if 또는 conditions) // evaluateRenderCondition은 if가 우선, 둘 다 없으면 true 반환 if (script.if !== undefined || script.conditions !== undefined) { const shouldLoad = evaluateRenderCondition( { if: script.if, conditions: script.conditions }, conditionContext, this.bindingEngine, `script:${script.id}` ); if (!shouldLoad) { logger.log(`Script skipped (condition not met): ${script.id}`); continue; } } // 이미 로드된 스크립트는 건너뛰기 const existingScript = document.getElementById(script.id); if (existingScript) { logger.log(`Script already loaded: ${script.id}`); continue; } // 스크립트 동적 로드 (Promise로 래핑) const loadPromise = new Promise((resolve, reject) => { const scriptEl = document.createElement('script'); scriptEl.src = script.src; scriptEl.id = script.id; scriptEl.async = script.async ?? true; scriptEl.onload = () => { logger.log(`Script loaded successfully: ${script.id}`); resolve(); }; scriptEl.onerror = () => { logger.warn(`Failed to load script: ${script.id} (${script.src})`); // 스크립트 로드 실패는 경고만 출력하고 계속 진행 resolve(); }; document.head.appendChild(scriptEl); }); loadPromises.push(loadPromise); } // 모든 스크립트 로드 완료 대기 if (loadPromises.length > 0) { await Promise.all(loadPromises); logger.log(`All scripts loaded: ${scripts.filter(s => !document.getElementById(s.id) || loadPromises.length > 0).map(s => s.id).join(', ')}`); } } /** * 스크립트 조건 평가 * * {{...}} 형태의 표현식을 평가합니다. * * @param condition 조건 표현식 (예: "{{_global.pluginActive}}") * @param context 평가 컨텍스트 * @returns 조건 평가 결과 (truthy/falsy) */ private evaluateScriptCondition(condition: string, context: Record): boolean { try { // {{...}} 형태에서 표현식 추출 if (condition.startsWith('{{') && condition.endsWith('}}')) { const expression = condition.slice(2, -2).trim(); // 간단한 표현식 평가 (점 표기법, 옵셔널 체이닝, 메서드 호출) // Function 생성자를 사용하여 안전하게 평가 // eslint-disable-next-line @typescript-eslint/no-implied-eval const fn = new Function('ctx', ` with(ctx) { try { return Boolean(${expression}); } catch (e) { return false; } } `); return fn(context); } // {{}} 형태가 아니면 truthy 체크 return Boolean(condition); } catch (error) { logger.warn(`Failed to evaluate script condition: ${condition}`, error); return false; } } /** * 초기화 에러 화면 표시 */ private showInitError(error: Error): void { // Error를 TemplateEngineError로 변환 const templateError = toTemplateEngineError(error); // ErrorDisplay를 사용하여 렌더링 // 원본 에러도 전달하여 상세 메시지 추출 가능하도록 함 ErrorDisplay.renderFromError( 'app', templateError, '초기화 실패', this.config.debug, { templateId: this.config.templateId, locale: this.config.locale, }, undefined, // translationEngine error // originalError ); } /** * 레이아웃 전환 오버레이 표시 (순수 DOM 조작) * * React 렌더 사이클과 무관하게 즉시 적용됩니다. * * @since engine-v1.23.0 */ private showTransitionOverlay(config: boolean | { enabled: boolean; style?: string; target?: string; skeleton?: { component: string; animation?: string; iteration_count?: number }; spinner?: { component?: string; text?: string } }, layoutData?: any): void { // config 정규화 const normalized = typeof config === 'boolean' ? { enabled: config, style: 'opaque' as const, target: undefined as string | undefined, skeleton: undefined as any, spinner: undefined as any } : { enabled: config.enabled, style: config.style || 'opaque', target: config.target, skeleton: (config as any).skeleton, spinner: (config as any).spinner, fallback_target: (config as any).fallback_target as string | undefined }; if (!normalized.enabled) return; // 기존 오버레이가 있으면 제거 this.hideTransitionOverlay(); // skeleton 스타일: React 기반 스켈레톤 UI 렌더링 (@since engine-v1.24.0) // 3단계 타겟팅: target → fallback_target → fullpage (@since engine-v1.24.2) if (normalized.style === 'skeleton' && normalized.skeleton?.component && normalized.target && layoutData) { this.renderSkeletonOverlay(normalized.target, normalized.skeleton, layoutData, normalized.fallback_target); return; } // spinner 스타일: 커스텀 로딩 컴포넌트 또는 기본 스피너 렌더링 (@since engine-v1.29.0) // skeleton과 동일한 3단계 타겟팅 지원 if (normalized.style === 'spinner' && normalized.target) { this.renderSpinnerOverlay(normalized.target, normalized.spinner, normalized.fallback_target); return; } // 다크 모드 감지 const isDark = document.documentElement.classList.contains('dark'); // 스타일별 CSS 생성 let bgCss: string; let extraCss = ''; switch (normalized.style) { case 'blur': bgCss = isDark ? 'rgba(17,24,39,0.3)' : 'rgba(255,255,255,0.3)'; extraCss = 'backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);'; break; case 'fade': bgCss = isDark ? 'rgba(17,24,39,0.8)' : 'rgba(255,255,255,0.8)'; break; case 'skeleton': // skeleton 스타일이지만 필수 조건 미충족 시 opaque 폴백 bgCss = isDark ? 'rgb(17,24,39)' : 'rgb(249,250,251)'; break; case 'opaque': default: bgCss = isDark ? 'rgb(17,24,39)' : 'rgb(249,250,251)'; break; } if (normalized.target) { // CSS