_localInit 은 단일 슬롯이고 updateTemplateData 가 얕은 스프레드로 교체했다. 소비부(DynamicRenderer useEffect)는 React commit 이후에 실행되므로, initLocal 을 가진 progressive 소스가 둘 이상이면 두 payload 가 같은 commit 사이에 도착할 때 먼저 온 것이 한 번도 관측되지 않고 사라졌다. 무조건 누적 병합은 refetchDataSource 시 소비가 끝난 payload 를 재적용해 사용자 폼 편집을 되돌린다. 그래서 아직 관측되지 않은 슬롯만 병합하고, 관측된 슬롯은 종전대로 교체한다. 소비 여부는 소비부가 기록한 슬롯 참조로 판정해 해시 계산식을 생산부에 복제하지 않는다. 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 비운다. 검증: CPU 6배 스로틀로 경합 창을 강제로 열어 수정 전 0/8 → 수정 후 8/8. 동반: template-engine.test.ts 의 vi.mock 이 공유 런타임 export 를 빠뜨려 스위트가 로드되지 않던 것과, 코어 spec 2개가 없는 fixture 를 import 해 playwright 수집이 실패하던 것을 함께 복구했다.
1061 lines
40 KiB
TypeScript
1061 lines
40 KiB
TypeScript
/**
|
|
* 그누보드7 템플릿 엔진 진입점
|
|
*
|
|
* 모든 코어 엔진 모듈을 통합하고 외부에서 사용할 수 있는 공개 API를 제공합니다.
|
|
*
|
|
* @packageDocumentation
|
|
*/
|
|
|
|
import React, { startTransition } from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import { ComponentRegistry } from './template-engine/ComponentRegistry';
|
|
import { DataBindingEngine } from './template-engine/DataBindingEngine';
|
|
import { TranslationEngine, TranslationContext } from './template-engine/TranslationEngine';
|
|
import { ActionDispatcher, setActionDispatcherInstance } from './template-engine/ActionDispatcher';
|
|
import DynamicRenderer from './template-engine/DynamicRenderer';
|
|
import { LayoutLoader } from './template-engine/LayoutLoader';
|
|
import { TransitionProvider } from './template-engine/TransitionContext';
|
|
import { TranslationProvider } from './template-engine/TranslationContext';
|
|
import { transitionManager } from './template-engine/TransitionManager';
|
|
import { ResponsiveProvider } from './template-engine/ResponsiveContext';
|
|
import { responsiveManager } from './template-engine/ResponsiveManager';
|
|
import { SlotProvider } from './template-engine/SlotContext';
|
|
import { DataSourceManager } from './template-engine/DataSourceManager';
|
|
import { ModalDataSourceWrapper } from './template-engine/ModalDataSourceWrapper';
|
|
import { ParentContextProvider } from './template-engine/ParentContextProvider';
|
|
import { TemplateNotFoundError } from './template-engine/TemplateEngineError';
|
|
import { mergeLocalInitSlot } from './template-engine/localInitSlot';
|
|
import { ErrorDisplay } from './template-engine/ErrorDisplay';
|
|
import { TemplateApp, initTemplateApp } from './TemplateApp';
|
|
import { createLogger } from './utils/Logger';
|
|
import { webSocketManager } from './websocket/WebSocketManager';
|
|
import { initializeG7CoreGlobals, initDevToolsAPI } from './template-engine/G7CoreGlobals';
|
|
import { checkLayoutEditorMode } from './template-engine/layout-editor/hooks/useEditorMode';
|
|
// LayoutEditorChrome 은 정적 import 하지 않는다 — 편집기는 별도 lazy 번들
|
|
// (layout-editor.min.js)로 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
|
// @since engine-v1.51.0
|
|
|
|
const logger = createLogger('TemplateEngine');
|
|
|
|
/**
|
|
* 템플릿 메타데이터 인터페이스
|
|
*/
|
|
interface TemplateMetadata {
|
|
identifier: string;
|
|
locales: string[];
|
|
name: Record<string, string>;
|
|
description: Record<string, string>;
|
|
version: string;
|
|
type: string;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 상태 인터페이스
|
|
*/
|
|
interface TemplateEngineState {
|
|
templateId: string | null;
|
|
locale: string;
|
|
isInitialized: boolean;
|
|
reactRoot: ReactDOM.Root | null;
|
|
containerId: string | null;
|
|
currentLayoutJson: any | null;
|
|
currentDataContext: Record<string, any>;
|
|
translationContext: TranslationContext;
|
|
registry: ComponentRegistry | null;
|
|
bindingEngine: DataBindingEngine | null;
|
|
translationEngine: TranslationEngine | null;
|
|
actionDispatcher: ActionDispatcher | null;
|
|
templateMetadata: TemplateMetadata | null;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 초기화 옵션
|
|
*/
|
|
interface InitOptions {
|
|
templateId: string;
|
|
templateType?: string;
|
|
locale?: string;
|
|
debug?: boolean;
|
|
/** 확장 기능 캐시 버전 (모듈/플러그인 활성화 시 갱신됨) */
|
|
cacheVersion?: number;
|
|
}
|
|
|
|
/**
|
|
* 렌더링 옵션
|
|
*/
|
|
interface RenderOptions {
|
|
containerId: string;
|
|
layoutJson: any;
|
|
dataContext?: Record<string, any>;
|
|
translationContext?: TranslationContext;
|
|
}
|
|
|
|
/**
|
|
* 데이터 업데이트 옵션
|
|
*/
|
|
interface UpdateOptions {
|
|
/**
|
|
* true인 경우 startTransition 없이 즉시 동기적으로 렌더링합니다.
|
|
* 드래그 앤 드롭 후 순서 변경 등 즉각적인 UI 반영이 필요한 경우 사용합니다.
|
|
* 기본값: false (startTransition 사용)
|
|
*/
|
|
sync?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 공개 API
|
|
*/
|
|
interface TemplateEngineAPI {
|
|
initTemplateEngine: (options: InitOptions) => Promise<void>;
|
|
renderTemplate: (options: RenderOptions) => Promise<void>;
|
|
updateTemplateData: (data: Record<string, any>) => void;
|
|
destroyTemplate: () => void;
|
|
getState: () => Readonly<TemplateEngineState>;
|
|
}
|
|
|
|
/**
|
|
* 전역 상태
|
|
*/
|
|
const state: TemplateEngineState = {
|
|
templateId: null,
|
|
locale: 'ko',
|
|
isInitialized: false,
|
|
reactRoot: null,
|
|
containerId: null,
|
|
currentLayoutJson: null,
|
|
currentDataContext: {},
|
|
translationContext: {
|
|
templateId: '',
|
|
locale: 'ko',
|
|
},
|
|
registry: null,
|
|
bindingEngine: null,
|
|
translationEngine: null,
|
|
actionDispatcher: null,
|
|
templateMetadata: null,
|
|
};
|
|
|
|
/**
|
|
* 디버그 모드 플래그 (ModalDataSourceWrapper 등에서 참조)
|
|
*/
|
|
let debugMode = false;
|
|
|
|
/**
|
|
* React가 준비되기 전에 도착한 데이터 업데이트를 저장하는 큐
|
|
* renderTemplate 완료 후 순차적으로 적용됨
|
|
*/
|
|
const pendingDataUpdates: Array<{ data: Record<string, any>; options?: UpdateOptions }> = [];
|
|
|
|
/**
|
|
* 템플릿 메타데이터 로드
|
|
*
|
|
* 템플릿 컴포넌트 번들에 포함된 메타데이터를 가져옵니다.
|
|
* API 호출 없이 전역 변수에서 직접 접근하여 성능을 개선합니다.
|
|
*
|
|
* @param templateId - 템플릿 ID
|
|
* @returns 템플릿 메타데이터
|
|
*/
|
|
async function loadTemplateMetadata(templateId: string): Promise<TemplateMetadata> {
|
|
try {
|
|
logger.log('템플릿 메타데이터 로드 중...', templateId);
|
|
|
|
// 템플릿 컴포넌트 번들의 전역 변수명 생성
|
|
// 예: 'sirsoft-admin_basic' → 'SirsoftAdminBasic'
|
|
const globalVarName = templateId
|
|
.split('-')
|
|
.map(part =>
|
|
part
|
|
.split('_')
|
|
.map(subPart => subPart.charAt(0).toUpperCase() + subPart.slice(1))
|
|
.join('')
|
|
)
|
|
.join('');
|
|
|
|
// 전역 변수에서 메타데이터 가져오기
|
|
const templateBundle = (window as any)[globalVarName];
|
|
|
|
if (templateBundle?.templateMetadata) {
|
|
logger.log('템플릿 메타데이터 로드 완료 (전역 변수)', templateBundle.templateMetadata);
|
|
return templateBundle.templateMetadata;
|
|
}
|
|
|
|
// 폴백: 템플릿 번들에 메타데이터가 없는 경우 경고
|
|
logger.warn(
|
|
`템플릿 번들에 메타데이터가 없습니다. 전역 변수: ${globalVarName}`
|
|
);
|
|
|
|
// 기본값 반환
|
|
return {
|
|
identifier: templateId,
|
|
locales: ['ko', 'en'],
|
|
name: { ko: templateId, en: templateId },
|
|
description: { ko: '', en: '' },
|
|
version: '1.0.0',
|
|
type: 'admin',
|
|
};
|
|
} catch (error) {
|
|
logger.error('템플릿 메타데이터 로드 실패', error);
|
|
// 기본값 반환
|
|
return {
|
|
identifier: templateId,
|
|
locales: ['ko', 'en'],
|
|
name: { ko: templateId, en: templateId },
|
|
description: { ko: '', en: '' },
|
|
version: '1.0.0',
|
|
type: 'admin',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전역 변수 생성
|
|
*
|
|
* 템플릿 엔진 기획서에 명시된 전역 변수를 생성합니다:
|
|
* - $locale: 현재 언어 코드 (예: 'ko', 'en')
|
|
* - $locales: 시스템 활성 언어 목록 (활성 언어팩 기반, 예: ['ko', 'en', 'ja'])
|
|
* - $templateLocales: 현재 템플릿이 자체 번역을 제공하는 언어 목록 (template.json `locales`)
|
|
* - $user: 현재 로그인한 사용자 정보 (추후 구현)
|
|
* - $auth: 인증 상태 (추후 구현)
|
|
*
|
|
* @returns 전역 변수 객체
|
|
*/
|
|
function createGlobalVariables(): Record<string, any> {
|
|
// 시스템 활성 언어팩(_global.appConfig.supportedLocales) 을 우선 사용한다.
|
|
// 언어팩 설치/제거 시 즉시 사용자 선택 UI 에 반영되어야 하기 때문이다.
|
|
// 미초기화/누락 시에만 템플릿 정적 메타데이터로 폴백한다.
|
|
const templateApp = (typeof window !== 'undefined' ? (window as any).__templateApp : undefined);
|
|
const systemLocales = templateApp?.globalState?.appConfig?.supportedLocales;
|
|
const templateLocales = state.templateMetadata?.locales;
|
|
|
|
return {
|
|
$locale: state.locale,
|
|
$locales: (Array.isArray(systemLocales) && systemLocales.length > 0)
|
|
? systemLocales
|
|
: (templateLocales || ['ko', 'en']),
|
|
$templateLocales: templateLocales || ['ko', 'en'],
|
|
$templateId: state.templateId,
|
|
// 추후 추가 예정:
|
|
// $user: getCurrentUser(),
|
|
// $auth: getAuthState(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 초기화
|
|
*
|
|
* 모든 엔진 인스턴스를 생성하고 템플릿 컴포넌트를 로드합니다.
|
|
*
|
|
* @param options - 초기화 옵션
|
|
* @throws {Error} 이미 초기화된 경우 또는 초기화 실패 시
|
|
*/
|
|
async function initTemplateEngine(options: InitOptions): Promise<void> {
|
|
try {
|
|
logger.log('템플릿 엔진 초기화 시작', options);
|
|
|
|
// 이미 초기화된 경우 에러
|
|
if (state.isInitialized) {
|
|
throw new Error('템플릿 엔진이 이미 초기화되었습니다. destroyTemplate()을 먼저 호출하세요.');
|
|
}
|
|
|
|
// 옵션 검증
|
|
if (!options.templateId) {
|
|
throw new TemplateNotFoundError();
|
|
}
|
|
|
|
// 디버그 모드 설정
|
|
debugMode = options.debug ?? false;
|
|
|
|
// G7Config에 debug 모드 설정 (DevTools에서 참조)
|
|
if (typeof window !== 'undefined') {
|
|
if (!(window as any).G7Config) {
|
|
(window as any).G7Config = {};
|
|
}
|
|
(window as any).G7Config.debug = debugMode;
|
|
}
|
|
|
|
// DevTools API 초기화 (debug 옵션 설정 후)
|
|
initDevToolsAPI();
|
|
|
|
// 상태 업데이트
|
|
state.templateId = options.templateId;
|
|
state.locale = options.locale || 'ko';
|
|
|
|
// 엔진 인스턴스 생성
|
|
logger.log('엔진 인스턴스 생성 중...');
|
|
|
|
state.registry = ComponentRegistry.getInstance();
|
|
state.bindingEngine = new DataBindingEngine();
|
|
state.translationEngine = TranslationEngine.getInstance();
|
|
|
|
// TranslationEngine에 캐시 버전 설정
|
|
if (options.cacheVersion !== undefined && options.cacheVersion > 0) {
|
|
state.translationEngine.setCacheVersion(options.cacheVersion);
|
|
}
|
|
|
|
// TranslationContext 초기화
|
|
state.translationContext = {
|
|
templateId: options.templateId,
|
|
locale: state.locale,
|
|
};
|
|
|
|
// ActionDispatcher에 TranslationEngine과 TranslationContext 전달
|
|
state.actionDispatcher = new ActionDispatcher(
|
|
{},
|
|
state.translationEngine,
|
|
state.translationContext
|
|
);
|
|
|
|
// ActionDispatcher 싱글톤 인스턴스로 설정
|
|
// renderItemChildren 등에서 getActionDispatcher()를 통해 동일한 인스턴스를 사용할 수 있도록 함
|
|
setActionDispatcherInstance(state.actionDispatcher);
|
|
|
|
// 다국어 파일 병렬 로드
|
|
logger.log('다국어 파일 로드 중...', options.templateId, state.locale);
|
|
|
|
const fallbackLocale = 'en';
|
|
const translationPromises = [
|
|
state.translationEngine
|
|
.loadTranslations(options.templateId, state.locale)
|
|
.then(() => {
|
|
logger.log(`다국어 파일 로드 완료: ${state.locale}`);
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(
|
|
`다국어 파일 로드 실패 (${state.locale}):`,
|
|
error instanceof Error ? error.message : error
|
|
);
|
|
}),
|
|
];
|
|
|
|
// 폴백 로케일 로드 (기본 로케일과 다른 경우에만)
|
|
if (state.locale !== fallbackLocale) {
|
|
translationPromises.push(
|
|
state.translationEngine
|
|
.loadTranslations(options.templateId, fallbackLocale)
|
|
.then(() => {
|
|
logger.log(`폴백 다국어 파일 로드 완료: ${fallbackLocale}`);
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(
|
|
`폴백 다국어 파일 로드 실패 (${fallbackLocale}):`,
|
|
error instanceof Error ? error.message : error
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
// 모든 다국어 파일 로드 대기 (병렬 실행)
|
|
await Promise.allSettled(translationPromises);
|
|
|
|
// 템플릿 메타데이터 로드
|
|
logger.log('템플릿 메타데이터 로드 중...', options.templateId);
|
|
state.templateMetadata = await loadTemplateMetadata(options.templateId);
|
|
|
|
// ComponentRegistry 로딩은 TemplateApp에서 병렬로 처리됨
|
|
// (성능 최적화를 위해 routes.json과 함께 병렬 로드)
|
|
|
|
// 초기화 완료
|
|
state.isInitialized = true;
|
|
|
|
logger.log('템플릿 엔진 초기화 완료 (ComponentRegistry는 별도 로드)');
|
|
} catch (error) {
|
|
logger.error('템플릿 엔진 초기화 실패', error);
|
|
|
|
// 초기화 실패 시 상태 초기화
|
|
state.isInitialized = false;
|
|
state.registry = null;
|
|
state.bindingEngine = null;
|
|
state.translationEngine = null;
|
|
state.actionDispatcher = null;
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 편집기 lazy 번들(layout-editor.min.js) 로드 후 LayoutEditorChrome 컴포넌트 반환.
|
|
*
|
|
* @since engine-v1.51.0
|
|
*
|
|
* 편집기는 메인 코어 번들에서 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
|
* 이미 로드돼 있으면 즉시 반환(멱등), 아니면 `<script>` 를 1회 주입하고 로드 완료를 대기한다.
|
|
* 동시 다중 호출은 in-flight promise 로 병합해 중복 주입을 막는다.
|
|
*
|
|
* @returns LayoutEditorChrome React 컴포넌트
|
|
* @throws {Error} 스크립트 로드 실패 또는 컴포넌트 미노출 시
|
|
*/
|
|
let layoutEditorLoadPromise: Promise<any> | null = null;
|
|
|
|
function loadLayoutEditorBundle(): Promise<any> {
|
|
const g7 = (window as any).G7Core;
|
|
|
|
// 이미 로드됨 — 즉시 반환 (멱등)
|
|
if (g7?.__LayoutEditorChrome) {
|
|
return Promise.resolve(g7.__LayoutEditorChrome);
|
|
}
|
|
|
|
// 진행 중인 로드가 있으면 재사용 (중복 주입 방지)
|
|
if (layoutEditorLoadPromise) {
|
|
return layoutEditorLoadPromise;
|
|
}
|
|
|
|
layoutEditorLoadPromise = new Promise((resolve, reject) => {
|
|
// 번들 URL — blade 가 주입한 버전 포함 URL 우선, 폴백은 표준 경로
|
|
const src =
|
|
(window as any).G7Config?.coreEditorAsset || '/build/core/layout-editor.min.js';
|
|
const scriptId = 'g7-layout-editor-bundle';
|
|
|
|
// 이미 주입된 <script> 재사용
|
|
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
|
|
|
|
const onReady = () => {
|
|
const chrome = (window as any).G7Core?.__LayoutEditorChrome;
|
|
if (chrome) {
|
|
resolve(chrome);
|
|
} else {
|
|
reject(new Error('편집기 번들 로드됨 — 그러나 __LayoutEditorChrome 미노출'));
|
|
}
|
|
};
|
|
|
|
// 편집기 엔트리가 컴포넌트 노출 직후 호출할 준비 완료 콜백 (onload fallback 병행)
|
|
(window as any).G7Core = (window as any).G7Core || {};
|
|
(window as any).G7Core.__onChromeReady = onReady;
|
|
|
|
if (existing) {
|
|
// 주입돼 있으나 아직 실행 전 — load 이벤트 대기
|
|
existing.addEventListener('load', onReady, { once: true });
|
|
existing.addEventListener(
|
|
'error',
|
|
() => reject(new Error(`편집기 번들 로드 실패: ${src}`)),
|
|
{ once: true }
|
|
);
|
|
return;
|
|
}
|
|
|
|
const script = document.createElement('script');
|
|
script.id = scriptId;
|
|
script.src = src;
|
|
script.async = false; // 로드 순서 보존 (메인 번들 이후)
|
|
script.addEventListener('load', onReady, { once: true });
|
|
script.addEventListener(
|
|
'error',
|
|
() => {
|
|
layoutEditorLoadPromise = null; // 재시도 가능하도록 초기화
|
|
reject(new Error(`편집기 번들 로드 실패: ${src}`));
|
|
},
|
|
{ once: true }
|
|
);
|
|
document.head.appendChild(script);
|
|
});
|
|
|
|
return layoutEditorLoadPromise;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 렌더링
|
|
*
|
|
* 레이아웃 JSON을 기반으로 React 컴포넌트를 렌더링합니다.
|
|
*
|
|
* @param options - 렌더링 옵션
|
|
* @throws {Error} 초기화되지 않은 경우 또는 렌더링 실패 시
|
|
*/
|
|
async function renderTemplate(options: RenderOptions): Promise<void> {
|
|
try {
|
|
logger.log('템플릿 렌더링 시작', options);
|
|
|
|
// 초기화 확인
|
|
if (!state.isInitialized) {
|
|
throw new Error('템플릿 엔진이 초기화되지 않았습니다. initTemplateEngine()을 먼저 호출하세요.');
|
|
}
|
|
|
|
// 옵션 검증
|
|
if (!options.containerId) {
|
|
throw new Error('containerId는 필수입니다.');
|
|
}
|
|
|
|
if (!options.layoutJson) {
|
|
throw new Error('layoutJson은 필수입니다.');
|
|
}
|
|
|
|
// DOM 컨테이너 확인
|
|
const container = document.getElementById(options.containerId);
|
|
if (!container) {
|
|
throw new Error(`컨테이너를 찾을 수 없습니다: #${options.containerId}`);
|
|
}
|
|
|
|
// 전역 변수 생성
|
|
const globalVariables = createGlobalVariables();
|
|
|
|
// 상태 업데이트 (전역 변수를 dataContext에 병합)
|
|
state.containerId = options.containerId;
|
|
state.currentLayoutJson = options.layoutJson;
|
|
state.currentDataContext = {
|
|
...globalVariables, // 전역 변수 (낮은 우선순위)
|
|
...(options.dataContext || {}), // 사용자 제공 데이터 (높은 우선순위)
|
|
};
|
|
state.translationContext = options.translationContext || {
|
|
templateId: state.templateId || '',
|
|
locale: state.locale,
|
|
};
|
|
|
|
// React Root 생성 또는 재사용
|
|
if (!state.reactRoot) {
|
|
logger.log('React Root 생성');
|
|
state.reactRoot = ReactDOM.createRoot(container);
|
|
}
|
|
|
|
// 레이아웃 편집기 모드 분기
|
|
// URL 이 `/admin/layout-editor/:identifier` 패턴이면 LayoutEditorChrome 을
|
|
// 같은 state.reactRoot + 같은 코어 컨텍스트 래퍼 안에서 렌더한다.
|
|
// 별도 createRoot / 별도 DOM 컨테이너 / 별도 컨텍스트 트리 일체 금지.
|
|
if (typeof window !== 'undefined') {
|
|
const editorMode = checkLayoutEditorMode(window.location.pathname);
|
|
if (editorMode) {
|
|
logger.log('레이아웃 편집기 모드 진입', editorMode);
|
|
|
|
// 편집기 lazy 번들 로드 (layout-editor.min.js) — 진입 시에만 로드
|
|
let LayoutEditorChrome: any;
|
|
try {
|
|
LayoutEditorChrome = await loadLayoutEditorBundle();
|
|
} catch (loadError) {
|
|
logger.error('레이아웃 편집기 번들 로드 실패', loadError);
|
|
// 컨테이너에 직접 에러 화면 렌더 (CSS 의존성 없는 인라인 스타일)
|
|
ErrorDisplay.render(options.containerId, {
|
|
title: '레이아웃 편집기 로드 실패',
|
|
message:
|
|
loadError instanceof Error
|
|
? loadError.message
|
|
: '편집기 번들을 불러오지 못했습니다.',
|
|
icon: 'fas fa-triangle-exclamation',
|
|
showStack: false,
|
|
showReloadButton: true,
|
|
debug: debugMode,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const chrome = React.createElement(LayoutEditorChrome, {
|
|
templateIdentifier: editorMode.templateIdentifier,
|
|
initialLocale: state.locale,
|
|
});
|
|
state.reactRoot.render(
|
|
React.createElement(
|
|
TranslationProvider,
|
|
{
|
|
translationEngine: state.translationEngine!,
|
|
translationContext: state.translationContext,
|
|
children: React.createElement(
|
|
TransitionProvider,
|
|
{
|
|
children: React.createElement(
|
|
ResponsiveProvider,
|
|
{
|
|
children: React.createElement(SlotProvider, { children: chrome }),
|
|
}
|
|
),
|
|
}
|
|
),
|
|
} as any
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 레이아웃 JSON의 components 배열 가져오기
|
|
const components = options.layoutJson.components || [];
|
|
|
|
if (components.length === 0) {
|
|
logger.warn('렌더링할 컴포넌트가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
// 레이아웃 JSON의 modals 배열 가져오기
|
|
const modals = options.layoutJson.modals || [];
|
|
logger.log('modals 배열:', modals);
|
|
logger.log('modals 개수:', modals.length);
|
|
|
|
// 루트 컴포넌트 렌더링 (TransitionProvider, ResponsiveProvider로 래핑하여 전환/반응형 상태 전파)
|
|
logger.log('DynamicRenderer로 렌더링 시작');
|
|
|
|
state.reactRoot.render(
|
|
React.createElement(
|
|
TranslationProvider,
|
|
{
|
|
translationEngine: state.translationEngine!,
|
|
translationContext: state.translationContext,
|
|
},
|
|
React.createElement(
|
|
TransitionProvider,
|
|
null,
|
|
React.createElement(
|
|
ResponsiveProvider,
|
|
null,
|
|
React.createElement(
|
|
SlotProvider,
|
|
null,
|
|
[
|
|
// 일반 컴포넌트 렌더링
|
|
...components.map((componentDef: any, index: number) => {
|
|
// @since engine-v1.24.5 레이아웃 이름을 key에 포함하여
|
|
// SPA 네비게이션 시 동일 base layout 공유 페이지 간 React 강제 remount
|
|
// @since engine-v1.24.8 _fromBase 컴포넌트는 stable key → 보존(update)
|
|
const layoutKey = state.currentLayoutJson?.layout_name || '';
|
|
return React.createElement(DynamicRenderer, {
|
|
key: (layoutKey && !componentDef._fromBase) ? `${componentDef.id}_${layoutKey}` : componentDef.id,
|
|
componentDef,
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
isRootRenderer: index === 0,
|
|
layoutKey,
|
|
});
|
|
}),
|
|
// 모달 렌더링 (ParentContextProvider로 감싸서 $parent._local 변경 시 모달만 리렌더링)
|
|
// modalStack 상태에 따라 isOpen 및 onClose 자동 주입
|
|
// 멀티 모달(중첩 모달) 지원: 스택에 있는 모든 모달이 렌더링됨
|
|
// data_sources가 정의된 모달은 ModalDataSourceWrapper로 감싸서 열릴 때 API 호출
|
|
React.createElement(
|
|
ParentContextProvider,
|
|
{ key: '__modal_parent_context_provider' },
|
|
modals.map((modalDef: any) => {
|
|
const modalStack = state.currentDataContext._global?.modalStack || [];
|
|
const isInStack = modalStack.includes(modalDef.id);
|
|
// 하위 호환성: modalStack이 없으면 activeModal 사용
|
|
const isOpen = isInStack || state.currentDataContext._global?.activeModal === modalDef.id;
|
|
// z-index는 스택 내 위치에 따라 동적으로 결정
|
|
const stackIndex = modalStack.indexOf(modalDef.id);
|
|
const zIndex = stackIndex >= 0 ? 50 + stackIndex : 50;
|
|
|
|
// 부모 컨텍스트 가져오기 ($parent 바인딩 지원)
|
|
// 모달 스택에서 현재 모달의 위치를 기반으로 부모 컨텍스트를 찾음
|
|
const layoutContextStack: Array<{
|
|
state: Record<string, any>;
|
|
setState: (updates: any) => void;
|
|
dataContext?: Record<string, any>;
|
|
}> = (window as any).__g7LayoutContextStack || [];
|
|
// 스택의 마지막 항목이 모달을 연 시점의 부모 컨텍스트
|
|
const parentContextEntry = layoutContextStack[layoutContextStack.length - 1];
|
|
const parentDataContext = parentContextEntry?.dataContext;
|
|
|
|
// 모달 렌더러 생성
|
|
const modalRenderer = React.createElement(DynamicRenderer, {
|
|
key: `modal_${modalDef.id}_renderer`,
|
|
componentDef: {
|
|
...modalDef,
|
|
props: {
|
|
...modalDef.props,
|
|
isOpen,
|
|
// z-index를 스택 위치에 따라 설정
|
|
style: {
|
|
...modalDef.props?.style,
|
|
zIndex,
|
|
},
|
|
// onClose는 closeModal 액션으로 연결
|
|
onClose: state.actionDispatcher?.createHandler(
|
|
{ type: 'click', handler: 'closeModal' },
|
|
state.currentDataContext
|
|
),
|
|
},
|
|
},
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
// $parent 바인딩을 위해 부모 데이터 컨텍스트 전달
|
|
parentDataContext,
|
|
});
|
|
|
|
// data_sources가 있으면 ModalDataSourceWrapper로 감싸기
|
|
if (modalDef.data_sources && modalDef.data_sources.length > 0) {
|
|
return React.createElement(ModalDataSourceWrapper, {
|
|
key: `modal_${modalDef.id}`,
|
|
isOpen,
|
|
modalId: modalDef.id,
|
|
dataSources: modalDef.data_sources,
|
|
dataContext: state.currentDataContext,
|
|
globalStateUpdater: state.actionDispatcher?.getGlobalStateUpdater(),
|
|
bindingEngine: state.bindingEngine!,
|
|
debug: debugMode,
|
|
children: modalRenderer,
|
|
});
|
|
}
|
|
|
|
return modalRenderer;
|
|
})
|
|
), // ParentContextProvider 종료
|
|
]
|
|
) // SlotProvider 종료
|
|
) // ResponsiveProvider 종료
|
|
) // TransitionProvider 종료
|
|
) // TranslationProvider 종료
|
|
);
|
|
|
|
logger.log('템플릿 렌더링 완료');
|
|
|
|
// React 준비 전에 큐에 저장된 데이터 업데이트 적용
|
|
if (pendingDataUpdates.length > 0) {
|
|
logger.log(`대기 중인 데이터 업데이트 ${pendingDataUpdates.length}건 적용`);
|
|
const updates = [...pendingDataUpdates];
|
|
pendingDataUpdates.length = 0; // 큐 비우기
|
|
|
|
// 큐에 있는 모든 데이터를 순차적으로 적용
|
|
for (const { data, options: updateOptions } of updates) {
|
|
updateTemplateData(data, updateOptions);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('템플릿 렌더링 실패', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 데이터 업데이트
|
|
*
|
|
* 현재 렌더링된 템플릿의 데이터 컨텍스트를 업데이트하고 재렌더링합니다.
|
|
*
|
|
* @param data - 업데이트할 데이터
|
|
* @param options - 업데이트 옵션 (sync: true면 startTransition 없이 즉시 렌더링)
|
|
* @throws {Error} 초기화되지 않은 경우 또는 렌더링되지 않은 경우
|
|
*/
|
|
function updateTemplateData(data: Record<string, any>, options?: UpdateOptions): void {
|
|
try {
|
|
logger.log('템플릿 데이터 업데이트 시작', Object.keys(data));
|
|
|
|
// 초기화 확인
|
|
if (!state.isInitialized) {
|
|
throw new Error('템플릿 엔진이 초기화되지 않았습니다.');
|
|
}
|
|
|
|
// 렌더링 확인 - React가 준비되지 않았으면 큐에 저장
|
|
if (!state.reactRoot || !state.currentLayoutJson) {
|
|
logger.log('React 미준비 - 데이터 업데이트 큐잉:', Object.keys(data));
|
|
pendingDataUpdates.push({ data, options });
|
|
return;
|
|
}
|
|
|
|
// 전역 변수 재생성
|
|
const globalVariables = createGlobalVariables();
|
|
|
|
// 데이터 병합 (전역 변수 유지)
|
|
// _global 객체는 명시적으로 깊은 병합 수행
|
|
const mergedGlobalState = {
|
|
...(state.currentDataContext._global || {}),
|
|
...(data._global || {}),
|
|
};
|
|
|
|
// _localInit도 얕은 스프레드로 교체하면 안 됨 (engine-v1.52.2)
|
|
// progressive 데이터소스가 둘 이상이면 각자 독립적으로 updateTemplateData를 호출하는데,
|
|
// 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
|
|
// 두 호출이 같은 commit 사이에 들어오면 나중 payload가 슬롯을 통째로 교체하여
|
|
// 먼저 도착한 소스의 initLocal이 한 번도 관측되지 않고 사라진다.
|
|
// → 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합한다. 상세: localInitSlot.ts
|
|
const mergedLocalInit = mergeLocalInitSlot(state.currentDataContext._localInit, data._localInit);
|
|
|
|
state.currentDataContext = {
|
|
...globalVariables, // 전역 변수 (낮은 우선순위)
|
|
...state.currentDataContext, // 기존 데이터
|
|
...data, // 새 데이터 (높은 우선순위)
|
|
_global: mergedGlobalState, // _global은 명시적으로 깊은 병합된 값 사용
|
|
_localInit: mergedLocalInit, // _localInit은 소비 전이면 누적 병합된 값 사용
|
|
};
|
|
|
|
// _local 및 _computed는 _global의 값을 canonical source로 동기화
|
|
// SPA 네비게이션 시 이전 페이지의 stale _local/_computed가 top-level에 잔존하는 것을 방지
|
|
// (renderTemplate → updateTemplateData 트리 구조 통일 후 React가 컴포넌트를 보존하면서 발생)
|
|
if (mergedGlobalState._local !== undefined) {
|
|
state.currentDataContext._local = mergedGlobalState._local;
|
|
}
|
|
if (mergedGlobalState._computed !== undefined) {
|
|
state.currentDataContext._computed = mergedGlobalState._computed;
|
|
}
|
|
|
|
const components = state.currentLayoutJson.components || [];
|
|
const modals = state.currentLayoutJson.modals || [];
|
|
logger.log('updateTemplateData - modals 배열:', modals);
|
|
logger.log('updateTemplateData - modals 개수:', modals.length);
|
|
logger.log('updateTemplateData - activeModal:', state.currentDataContext._global?.activeModal);
|
|
|
|
// 렌더링 함수 정의 (startTransition 유무에 따라 재사용)
|
|
const doRender = () => {
|
|
state.reactRoot!.render(
|
|
React.createElement(
|
|
TranslationProvider,
|
|
{
|
|
translationEngine: state.translationEngine!,
|
|
translationContext: state.translationContext,
|
|
},
|
|
React.createElement(
|
|
TransitionProvider,
|
|
null,
|
|
React.createElement(
|
|
ResponsiveProvider,
|
|
null,
|
|
React.createElement(
|
|
SlotProvider,
|
|
null,
|
|
[
|
|
// 일반 컴포넌트 렌더링
|
|
...components.map((componentDef: any, index: number) => {
|
|
// @since engine-v1.24.5 레이아웃 이름을 key에 포함하여
|
|
// SPA 네비게이션 시 동일 base layout 공유 페이지 간 React 강제 remount
|
|
// @since engine-v1.24.8 _fromBase 컴포넌트는 stable key → 보존(update)
|
|
const layoutKey = state.currentLayoutJson?.layout_name || '';
|
|
return React.createElement(DynamicRenderer, {
|
|
key: (layoutKey && !componentDef._fromBase) ? `${componentDef.id}_${layoutKey}` : componentDef.id,
|
|
componentDef,
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
isRootRenderer: index === 0,
|
|
layoutKey,
|
|
});
|
|
}),
|
|
// 모달 렌더링 (ParentContextProvider로 감싸서 $parent._local 변경 시 모달만 리렌더링)
|
|
// modalStack 상태에 따라 isOpen 및 onClose 자동 주입
|
|
// 멀티 모달(중첩 모달) 지원: 스택에 있는 모든 모달이 렌더링됨
|
|
// data_sources가 정의된 모달은 ModalDataSourceWrapper로 감싸서 열릴 때 API 호출
|
|
React.createElement(
|
|
ParentContextProvider,
|
|
{ key: '__modal_parent_context_provider_update' },
|
|
modals.map((modalDef: any) => {
|
|
const modalStack = state.currentDataContext._global?.modalStack || [];
|
|
const isInStack = modalStack.includes(modalDef.id);
|
|
// 하위 호환성: modalStack이 없으면 activeModal 사용
|
|
const isOpen = isInStack || state.currentDataContext._global?.activeModal === modalDef.id;
|
|
// z-index는 스택 내 위치에 따라 동적으로 결정
|
|
const stackIndex = modalStack.indexOf(modalDef.id);
|
|
const zIndex = stackIndex >= 0 ? 50 + stackIndex : 50;
|
|
|
|
// 부모 컨텍스트 가져오기 ($parent 바인딩 지원)
|
|
const layoutContextStack2: Array<{
|
|
state: Record<string, any>;
|
|
setState: (updates: any) => void;
|
|
dataContext?: Record<string, any>;
|
|
}> = (window as any).__g7LayoutContextStack || [];
|
|
const parentContextEntry2 = layoutContextStack2[layoutContextStack2.length - 1];
|
|
const parentDataContext2 = parentContextEntry2?.dataContext;
|
|
|
|
// 모달 렌더러 생성
|
|
const modalRenderer = React.createElement(DynamicRenderer, {
|
|
key: `modal_${modalDef.id}_renderer`,
|
|
componentDef: {
|
|
...modalDef,
|
|
props: {
|
|
...modalDef.props,
|
|
isOpen,
|
|
// z-index를 스택 위치에 따라 설정
|
|
style: {
|
|
...modalDef.props?.style,
|
|
zIndex,
|
|
},
|
|
// onClose는 closeModal 액션으로 연결
|
|
onClose: state.actionDispatcher?.createHandler(
|
|
{ type: 'click', handler: 'closeModal' },
|
|
state.currentDataContext
|
|
),
|
|
},
|
|
},
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
// $parent 바인딩을 위해 부모 데이터 컨텍스트 전달
|
|
parentDataContext: parentDataContext2,
|
|
});
|
|
|
|
// data_sources가 있으면 ModalDataSourceWrapper로 감싸기
|
|
if (modalDef.data_sources && modalDef.data_sources.length > 0) {
|
|
return React.createElement(ModalDataSourceWrapper, {
|
|
key: `modal_${modalDef.id}`,
|
|
isOpen,
|
|
modalId: modalDef.id,
|
|
dataSources: modalDef.data_sources,
|
|
dataContext: state.currentDataContext,
|
|
globalStateUpdater: state.actionDispatcher?.getGlobalStateUpdater(),
|
|
bindingEngine: state.bindingEngine!,
|
|
debug: debugMode,
|
|
children: modalRenderer,
|
|
});
|
|
}
|
|
|
|
return modalRenderer;
|
|
})
|
|
), // ParentContextProvider 종료
|
|
]
|
|
) // SlotProvider 종료
|
|
) // ResponsiveProvider 종료
|
|
) // TransitionProvider 종료
|
|
) // TranslationProvider 종료
|
|
);
|
|
};
|
|
|
|
// sync 옵션에 따라 렌더링 방식 선택
|
|
if (options?.sync) {
|
|
// 동기 모드: startTransition 없이 즉시 렌더링 (드래그 앤 드롭 등 즉각적인 UI 반영 필요 시)
|
|
logger.log('재렌더링 시작 (sync mode - 즉시 렌더링)');
|
|
doRender();
|
|
} else {
|
|
// 기본 모드: startTransition으로 래핑하여 깜빡임 방지
|
|
logger.log('재렌더링 시작 (with startTransition)');
|
|
startTransition(() => {
|
|
doRender();
|
|
});
|
|
}
|
|
|
|
logger.log('템플릿 데이터 업데이트 완료');
|
|
} catch (error) {
|
|
logger.error('템플릿 데이터 업데이트 실패', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 정리
|
|
*
|
|
* React Root를 언마운트하고 모든 상태를 초기화합니다.
|
|
*/
|
|
function destroyTemplate(): void {
|
|
try {
|
|
logger.log('템플릿 정리 시작');
|
|
|
|
// React Root 언마운트
|
|
if (state.reactRoot) {
|
|
logger.log('React Root 언마운트');
|
|
state.reactRoot.unmount();
|
|
state.reactRoot = null;
|
|
}
|
|
|
|
// 상태 초기화
|
|
state.templateId = null;
|
|
state.locale = 'ko';
|
|
state.isInitialized = false;
|
|
state.containerId = null;
|
|
state.currentLayoutJson = null;
|
|
state.currentDataContext = {};
|
|
state.translationContext = {
|
|
templateId: '',
|
|
locale: 'ko',
|
|
};
|
|
state.registry = null;
|
|
state.bindingEngine = null;
|
|
state.translationEngine = null;
|
|
state.actionDispatcher = null;
|
|
state.templateMetadata = null;
|
|
|
|
logger.log('템플릿 정리 완료');
|
|
} catch (error) {
|
|
logger.error('템플릿 정리 실패', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 상태 조회
|
|
*
|
|
* 읽기 전용 상태 객체를 반환합니다.
|
|
*
|
|
* @returns 현재 템플릿 엔진 상태
|
|
*/
|
|
function getState(): Readonly<TemplateEngineState> {
|
|
return Object.freeze({ ...state });
|
|
}
|
|
|
|
/**
|
|
* ActionDispatcher 인스턴스 조회
|
|
*
|
|
* @returns ActionDispatcher 인스턴스 또는 null
|
|
*/
|
|
function getActionDispatcher(): ActionDispatcher | null {
|
|
return state.actionDispatcher;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 공개 API
|
|
*/
|
|
const TemplateEngine: TemplateEngineAPI = {
|
|
initTemplateEngine,
|
|
renderTemplate,
|
|
updateTemplateData,
|
|
destroyTemplate,
|
|
getState,
|
|
};
|
|
|
|
/**
|
|
* 전역 객체에 노출
|
|
*
|
|
* G7Core 전역 API는 G7CoreGlobals.ts 모듈로 분리되어 있습니다.
|
|
*/
|
|
if (typeof window !== 'undefined') {
|
|
// G7Core 네임스페이스가 없으면 생성 (TemplateApp.ts에서 이미 생성했을 수 있음)
|
|
if (!(window as any).G7Core) {
|
|
(window as any).G7Core = {};
|
|
}
|
|
|
|
// TemplateEngine 노출
|
|
(window as any).G7Core.TemplateEngine = TemplateEngine;
|
|
|
|
// G7Core 전역 API 초기화
|
|
initializeG7CoreGlobals({
|
|
getState: () => ({
|
|
translationEngine: state.translationEngine,
|
|
translationContext: state.translationContext,
|
|
bindingEngine: state.bindingEngine,
|
|
actionDispatcher: state.actionDispatcher,
|
|
templateMetadata: state.templateMetadata,
|
|
}),
|
|
transitionManager,
|
|
responsiveManager,
|
|
webSocketManager,
|
|
});
|
|
|
|
logger.log('전역 객체 window.G7Core.TemplateEngine에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* ESM export
|
|
*/
|
|
export default TemplateEngine;
|
|
export {
|
|
TemplateEngine,
|
|
initTemplateEngine,
|
|
renderTemplate,
|
|
updateTemplateData,
|
|
destroyTemplate,
|
|
getState,
|
|
getActionDispatcher,
|
|
LayoutLoader,
|
|
DataSourceManager,
|
|
// @since engine-v1.51.0 편집기 lazy 번들 로더 (테스트/진단용 노출)
|
|
loadLayoutEditorBundle,
|
|
};
|
|
|
|
export type {
|
|
TemplateEngineAPI,
|
|
TemplateEngineState,
|
|
InitOptions,
|
|
RenderOptions,
|
|
UpdateOptions,
|
|
};
|
|
|
|
export type { LayoutData, LayoutComponent } from './template-engine/LayoutLoader';
|
|
export type { DataSource, DataSourceType, ConditionContext } from './template-engine/DataSourceManager';
|
|
|
|
// TemplateApp export (이미 상단에서 import됨)
|
|
export { TemplateApp, initTemplateApp };
|
|
export type { TemplateAppConfig } from './TemplateApp';
|
|
export type { Route } from './routing/Router';
|
|
|
|
// TemplateApp.ts 모듈에서 이미 전역 노출 처리함 (Object.defineProperty getter 사용)
|