Files
Gnuboard7/resources/js/core/template-engine.ts
T
HeuJung dd8f9a49c4 fix(core,template): 편집기 image 위젯 값 형태·데이터 연결 값 보호·상속 노드 편집 표면 수정
공개 는 헤더 「로고 이미지」에 이미지를 지정하면 화면이 엑박이 되는 제보였다.
image 위젯은 배경용으로 설계되어 {url,size,repeat,position} 객체를 내보내는데, 값 슬롯이
하나뿐인 apply 경로(propValue/cssVar/단일 styleProp)가 그 객체를 그대로 props 에 기록해
소비 컴포넌트가 [object Object] 를 URL 로 받았다. 예외도 콘솔 오류도 서버 로그도 남지
않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)이고, 편집기
미리보기는 정상이라 조작 중에는 이상이 보이지 않는다.

방어선을 넷으로 세웠다. 쓰기 축약(공용 헬퍼 scalarizeImageValue 단일 지점, 게이트는 위젯
이름이며 값 형태 sniffing 이 아니다) · 읽기 역조립(표현식 문자열도 되감아 업로드 1클릭에
소실되지 않게 한다) · 런타임 방어(업그레이드 전 화면을 위해 템플릿 Img 가 url 을 해석하고
손상값이면 src 를 아예 붙이지 않는다) · 저장 데이터 백필(업그레이드 스텝). 런타임과 백필은
완전히 같은 엄격 판정식(키 집합 ⊆ 4키 AND url 보유)을 쓴다 — 엔진의 느슨한 판정식을 백필에
이식하면 레이아웃 전수에서 정상 props 2,219건을 파괴한다(실측).

전수조사에서 파생한 인접 결함 넷을 함께 고쳤다.

- number 위젯이 코어 레지스트리에 미등록이라 「탭 표시 게시판 수」 같은 컨트롤이
 「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다. nodeKey apply 는 coreProps 가
 선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이었다.
- 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 data_bound 로 분류돼 편집이
 열려 있었는데, 저장 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
 사라졌다(저장은 200 이고 history 는 clear 돼 undo 도 불가). 출처 잠금이 항상 우선하도록
 판정 순서를 통일하고, 단일 판정 헬퍼로 인라인 편집·복제·Delete·잘라내기·드래그 commit
 까지 전 표면을 같은 기준으로 막았다.
- prop 자리의 표현식 값을 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작하는 순간 환경설정
 과의 연결이 소리 없이 끊겼다. 판정·배지·잠금·해제·복구를 ControlRenderer 공용 게이트
 한 곳으로 올려 신규 위젯에도 자동 적용되게 했다. 「직접 지정으로 바꾸기」에는 「되돌리기」를
 동반해 편도가 되지 않게 한다.
- 편집기 모드에서 updateTemplateData 가 빈 레이아웃으로 같은 reactRoot 에 두 번째 커밋을
 걸어 편집기 트리를 통째로 제거했다. renderTemplate 의 편집기 분기가 비동기라 부팅 중
 setGlobalState 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다.
2026-09-08 17:20:01 +09:00

1079 lines
41 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';
import { loadScriptWithRetry } from './template-engine/networkResilience';
// 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>` 를 주입하고 로드 완료를 대기한다.
* 동시 다중 호출은 in-flight promise 로 병합해 중복 주입을 막는다.
*
* 주입은 `loadScriptWithRetry` 에 위임한다 — 종전에는 이 경로만 재시도 계층이 없어,
* 일시적 네트워크 유실 한 번에 편집기가 통째로 열리지 않았다. 다른 모든 자산 경로
* (레이아웃 스크립트·확장 번들·CSS)가 이미 이 로더를 쓰므로 편집기만 예외일 이유가 없다.
*
* 실패 문구에는 번들 경로를 싣지 않는다. 사용자가 고칠 수 있는 정보가 아니고,
* 내부 배치 구조를 화면에 노출한다. 경로는 콘솔 로그로만 남긴다.
*
* @returns LayoutEditorChrome React 컴포넌트
* @throws {Error} 스크립트 로드 실패 또는 컴포넌트 미노출 시
*/
let layoutEditorLoadPromise: Promise<any> | null = null;
/** 편집기 번들 `<script>` element id */
const LAYOUT_EDITOR_SCRIPT_ID = 'g7-layout-editor-bundle';
function loadLayoutEditorBundle(): Promise<any> {
const g7 = (window as any).G7Core;
// 이미 로드됨 — 즉시 반환 (멱등)
if (g7?.__LayoutEditorChrome) {
return Promise.resolve(g7.__LayoutEditorChrome);
}
// 진행 중인 로드가 있으면 재사용 (중복 주입 방지)
if (layoutEditorLoadPromise) {
return layoutEditorLoadPromise;
}
layoutEditorLoadPromise = (async () => {
// 번들 URL — blade 가 주입한 버전 포함 URL 우선, 폴백은 표준 경로
const src =
(window as any).G7Config?.coreEditorAsset || '/build/core/layout-editor.min.js';
// 앞선 시도가 남긴 element 제거 — 남겨두면 IIFE 가 두 번 실행된다
document.getElementById(LAYOUT_EDITOR_SCRIPT_ID)?.remove();
// 편집기 엔트리가 컴포넌트 노출 직후 호출하는 준비 완료 콜백 (주입 전에 등록)
(window as any).G7Core = (window as any).G7Core || {};
(window as any).G7Core.__onChromeReady = () => {
/* 노출 시점 통지 — 실제 대기는 script load 이벤트가 담당한다 */
};
try {
await loadScriptWithRetry(
src,
{ id: LAYOUT_EDITOR_SCRIPT_ID },
{ label: 'layout-editor bundle' }
);
} catch (error) {
layoutEditorLoadPromise = null; // 재시도 가능하도록 초기화
logger.error(`편집기 번들 로드 실패 (${src})`, error);
throw new Error('편집기를 불러오지 못했습니다. 네트워크 상태를 확인한 뒤 다시 시도해 주세요.');
}
const chrome = (window as any).G7Core?.__LayoutEditorChrome;
if (!chrome) {
layoutEditorLoadPromise = null;
logger.error(`편집기 번들 로드됨 — 그러나 __LayoutEditorChrome 미노출 (${src})`);
throw new Error('편집기를 불러왔으나 초기화하지 못했습니다. 페이지를 새로고침해 주세요.');
}
return chrome;
})();
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;
}
// 레이아웃 편집기 모드 — 재렌더 금지 (renderTemplate 의 편집기 분기와 대칭).
//
// 편집기 모드에서 `TemplateApp.init` 은 `renderTemplate({ layoutJson: { components: [] } })`
// 으로 부르고, renderTemplate 의 편집기 분기가 그 빈 배열 대신 LayoutEditorChrome 을
// 같은 reactRoot 에 렌더한다. 그래서 `state.currentLayoutJson.components` 는 **빈 배열**이다.
// 여기서 그대로 재렌더하면 같은 루트에 빈 트리를 커밋해 편집기를 통째로 제거한다 — 화면이
// 백지가 되고 예외도 콘솔 오류도 남지 않는다.
//
// renderTemplate 의 편집기 분기는 비동기(`loadLayoutEditorBundle`)라, 부팅 중 도착한
// setGlobalState 한 번이 그 커밋 뒤에 실행되면 발현하는 **경합**이다(간헐 재현).
// 데이터 병합은 위에서 이미 끝났으므로 여기서는 렌더만 건너뛴다 — 편집기 트리는
// 자기 상태를 스스로 관리하고 currentLayoutJson 에 의존하지 않는다.
if (typeof window !== 'undefined' && checkLayoutEditorMode(window.location.pathname)) {
logger.log('레이아웃 편집기 모드 — 재렌더 건너뜀 (편집기 트리 보존)');
return;
}
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 사용)