Files
Gnuboard7/resources/js/core/template-engine/G7CoreGlobals.ts
T
HeuJung 953616552f fix(core,engine,ecommerce,admin_basic): 렌더 경로 판정·파서 단일화로 표현식 비대칭 일괄 해소
표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이
갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상
대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다.

판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은
회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다.

- 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 /
 _computed stale 전파 / 원본 타입 소실)
- 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설
- 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인
- BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환
- iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬
- 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임
- 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지
 못하던 결함 수정
- SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다
- audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소,
 신규 룰 5종 등재
2026-08-04 09:05:00 +09:00

4228 lines
150 KiB
TypeScript

/**
* NOTE: @since versions (engine-v1.x.x) refer to internal template engine
* development iterations, not G7 platform release versions.
*/
/**
* G7Core 전역 객체 초기화 모듈
*
* 템플릿 컴포넌트에서 사용할 수 있는 G7Core 전역 API를 노출합니다.
* template-engine.ts에서 분리되어 단일 책임 원칙을 따릅니다.
*
* @packageDocumentation
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createPortal } from 'react-dom';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import { ComponentRegistry } from './ComponentRegistry';
import { TranslationEngine, TranslationContext } from './TranslationEngine';
import { ActionDispatcher } from './ActionDispatcher';
import { DataBindingEngine, dataBindingEngine } from './DataBindingEngine';
import { hasPipes } from './PipeRegistry';
import { extractSingleBinding } from './BindingShape';
import { evaluateStringCondition } from './helpers/ConditionEvaluator';
import { DataSourceManager, dataSourceManager } from './DataSourceManager';
import DynamicRenderer from './DynamicRenderer';
import { useTransitionState } from './TransitionContext';
import { useTranslation, TranslationProvider, TranslationReactContext } from './TranslationContext';
import { useResponsive, ResponsiveProvider, ResponsiveContext } from './ResponsiveContext';
import { responsiveManager, BREAKPOINT_PRESETS } from './ResponsiveManager';
import { AuthManager } from '../auth/AuthManager';
import { getApiClient } from '../api/ApiClient';
import { createLogger, flushEarlyLogs } from '../utils/Logger';
import { WebSocketManager } from '../websocket/WebSocketManager';
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
// DiagnosticEngine/ServerConnector/StyleTracker/DevToolsPanel 은 정적 import 하지 않는다 —
// 디버그 전용 무거운 모듈은 별도 lazy 번들(devtools.min.js)로 분리되어 initDevToolsAPI() 가
// isEnabled() 참일 때만 런타임 <script> 주입으로 로드한다. @since engine-v1.51.0
import type { DiagnosticCategory } from '../devtools/types';
import {
renderItemChildren,
createChangeEvent,
createClickEvent,
createSubmitEvent,
createKeyboardEvent,
mergeClasses,
conditionalClass,
joinClasses,
} from './helpers';
import type { SlotContextValue } from './SlotContext';
import {
useControllableState,
shallowArrayEqual,
shallowObjectEqual,
} from '../hooks/useControllableState';
import { triggerModalParentUpdate } from './ParentContextProvider';
import { IdentityGuardInterceptor, IDENTITY_REDIRECT_STASH_KEY } from '../identity/IdentityGuardInterceptor';
const logger = createLogger('G7CoreGlobals');
/**
* G7Core.devTools 인터페이스
*
* 템플릿 컴포넌트와 헬퍼 함수에서 DevTools 추적 기능에 접근하기 위한 통합 인터페이스입니다.
* G7DevToolsCore.getInstance() 직접 호출 대신 이 인터페이스를 사용합니다.
*
* @example
* ```ts
* // renderItemChildren 등에서 사용
* if (G7Core.devTools?.isEnabled()) {
* G7Core.devTools.trackIteration(id, source, itemVar, indexVar, itemCount);
* }
* ```
*/
export interface G7DevToolsInterface {
/** DevTools 활성화 상태 확인 */
isEnabled(): boolean;
/** 렌더링 추적 */
trackRender(componentName: string): void;
/** iteration 추적 */
trackIteration(
id: string,
source: string,
itemVar: string,
indexVar: string | undefined,
itemCount: number
): void;
/** 조건부 렌더링 추적 */
trackIfCondition(
id: string,
condition: string,
result: boolean,
componentName?: string
): void;
/** 컴포넌트 마운트 추적 */
trackMount(componentId: string, info: { name: string; type: string; props?: Record<string, any>; parentId?: string }): void;
/** 컴포넌트 언마운트 추적 */
trackUnmount(componentId: string): void;
/** 표현식 평가 추적 */
trackExpressionEval(info: {
expression: string;
result: any;
resultType: string;
duration?: number;
method: 'resolve' | 'resolveBindings' | 'evaluateExpression';
componentId?: string;
componentName?: string;
propName?: string;
fromCache: boolean;
skipCache?: boolean;
steps?: Array<{
step: 'parse' | 'resolve' | 'evaluate' | 'cache';
input?: string;
output?: any;
path?: string;
value?: any;
fromCache?: boolean;
error?: string;
}>;
}): void;
/** 바인딩 평가 추적 */
trackBindingEval(expression?: string): void;
/** 캐시 히트 추적 */
recordCacheHit(): void;
/** 캐시 미스 추적 */
recordCacheMiss(): void;
/** 핸들러 등록 추적 */
trackHandlerRegistration(
name: string,
category: 'built-in' | 'custom' | 'module' | 'plugin',
description?: string,
source?: string
): void;
/** 핸들러 해제 추적 */
trackHandlerUnregistration(name: string): void;
/** 액션 로그 기록 */
logAction(action: {
id: string;
type: string;
params?: Record<string, any>;
resolvedParams?: Record<string, any>;
context?: Record<string, any>;
startTime: number;
endTime?: number;
duration?: number;
status: 'started' | 'success' | 'error';
result?: any;
error?: { name: string; message: string; stack?: string };
children?: Array<any>;
}): void;
/** 네트워크 요청 시작 추적 */
trackRequest(
url: string,
method: string,
options?: { requestBody?: any; dataSourceId?: string },
): string;
/** 네트워크 요청 완료 추적 */
completeRequest(requestId: string, statusCode: number, response?: any): void;
/** 네트워크 요청 실패 추적 */
failRequest(requestId: string, error: string): void;
/** 데이터소스 정의 추적 */
trackDataSourceDefinition(info: {
id: string;
type: 'api' | 'static' | 'route_params' | 'query_params' | 'websocket';
endpoint?: string;
method?: string;
autoFetch?: boolean;
initLocal?: any;
initGlobal?: any;
}): void;
/** 데이터소스 로딩 시작 추적 */
trackDataSourceLoading(id: string): void;
/** 데이터소스 로드 완료 추적 */
trackDataSourceLoaded(id: string, data: any, dataPath?: string): void;
/** 데이터소스 로드 실패 추적 */
trackDataSourceError(id: string, error: string): void;
/** Form 추적 */
trackForm(id: string, dataKey: string, inputs: Array<{ name: string; type: string; binding?: string }>): void;
/** Form 제거 추적 */
untrackForm(id: string): void;
/** setState 렌더링 상관관계 추적 - 시작 */
startStateChange(
statePath: string,
oldValue: any,
newValue: any,
trigger?: { actionId?: string; handlerType?: string; source?: string }
): string;
/** setState 렌더링 상관관계 추적 - 완료 */
completeStateChange(setStateId: string): void;
/** 컴포넌트 렌더링 추적 (상태 변경 컨텍스트 내) */
trackComponentRender(
componentId: string,
componentName: string,
renderDuration: number,
accessedStatePaths?: string[],
evaluatedBindings?: string[],
parentId?: string
): void;
/** _local 상태 업데이트 */
updateLocalState(localState: Record<string, any>): void;
/** _computed 상태 업데이트 */
updateComputedState(computedState: Record<string, any>): void;
/** $parent 컨텍스트 업데이트 (모달/자식 레이아웃에서 부모 상태 접근용) */
updateParentContext(parentContext: Record<string, any> | undefined): void;
/** 컴포넌트 상태 소스 추적 (g7-state-hierarchy) */
trackComponentStateSource?(
componentId: string,
componentName: string,
stateSource: {
global: string[];
local: string[];
context: string[];
},
stateProvider: {
type: 'globalState' | 'dynamicState' | 'componentContext';
hasComponentContext: boolean;
parentId?: string;
}
): void;
/** dynamicState 추적 (g7-state-hierarchy) */
trackDynamicState?(componentId: string, dynamicState: Record<string, any>): void;
/** 컨텍스트 플로우 추적 (g7-context-flow) */
trackContextFlow?(
componentId: string,
componentName: string,
contextReceived: boolean,
passedToChildren: boolean,
usedInRender: boolean,
parentId?: string
): void;
/** 컴포넌트 스타일 추적 (g7-styles) */
trackComponentStyle?(
componentId: string,
componentName: string,
classes: string[],
computedStyles: {
display: string;
visibility: string;
opacity: string;
position: string;
zIndex: string;
overflow: string;
width: string;
height: string;
backgroundColor: string;
color: string;
}
): void;
/** 인증 이벤트 추적 (g7-auth) */
trackAuthEvent?(
type: 'login' | 'logout' | 'token-refresh' | 'token-expired' | 'session-restored' | 'permission-denied' | 'api-unauthorized',
success: boolean,
error?: string,
details?: Record<string, any>
): void;
/** 인증 헤더 추적 (g7-auth) */
trackAuthHeader?(
url: string,
hasAuthHeader: boolean,
headerType?: string,
tokenValid?: boolean,
responseStatus?: number
): void;
/** 로그 추적 (g7-logs) */
trackLog?(
level: 'log' | 'warn' | 'error' | 'debug' | 'info',
prefix: string,
args: unknown[]
): void;
/**
* 액션 실행 추적 (debounce 지원)
*
* debounce 옵션이 있는 액션의 실행 상태를 추적합니다.
*/
trackAction?(info: {
handler: string;
type: string;
status: 'pending' | 'success' | 'error' | 'cancelled';
params?: Record<string, any>;
debounce?: {
delay: number;
status: 'pending' | 'executed' | 'cancelled';
scheduledAt?: number;
executedAt?: number;
mode?: 'leading' | 'trailing';
};
}): void;
/**
* 데이터소스 업데이트 추적 (부분 업데이트 지원)
*
* G7Core.dataSource.updateItem() 호출을 추적합니다.
*/
trackDataSourceUpdate?(info: {
dataSourceId: string;
updateType: 'full' | 'partial';
itemPath?: string;
itemId?: string | number;
updates?: Record<string, any>;
timestamp: number;
}): void;
// ============================================
// Sequence 실행 추적 메서드 (g7-sequence)
// ============================================
/**
* Sequence 실행 시작
*
* @param trigger 트리거 정보
* @returns 생성된 sequenceId
*/
startSequenceExecution?(trigger?: {
eventType?: string;
source?: string;
callbackType?: 'onSuccess' | 'onError';
}): string;
/**
* Sequence 내 액션 실행 전 상태 캡처
*
* @param sequenceId Sequence ID
* @param actionIndex 액션 인덱스
* @param handler 핸들러 이름
* @param params 액션 파라미터
* @param currentState 현재 상태
*/
captureSequenceActionBefore?(
sequenceId: string,
actionIndex: number,
handler: string,
params: Record<string, any>,
currentState: {
_global: Record<string, any>;
_local: Record<string, any>;
_isolated?: Record<string, any>;
}
): void;
/**
* Sequence 내 액션 실행 후 상태 캡처
*
* @param sequenceId Sequence ID
* @param actionIndex 액션 인덱스
* @param currentState 현재 상태
* @param duration 소요 시간
* @param result 액션 결과
* @param error 에러 (있는 경우)
*/
captureSequenceActionAfter?(
sequenceId: string,
actionIndex: number,
currentState: {
_global: Record<string, any>;
_local: Record<string, any>;
_isolated?: Record<string, any>;
},
duration: number,
result?: any,
error?: Error
): void;
/**
* Sequence 실행 완료
*
* @param sequenceId Sequence ID
* @param error 전체 에러 (있는 경우)
*/
endSequenceExecution?(sequenceId: string, error?: Error): void;
// ============================================
// Stale Closure 감지 메서드 (g7-stale-closure)
// ============================================
/**
* 상태 캡처 등록 (비동기 핸들러 시작 시)
*
* @param handlerId 핸들러 ID
* @param statePaths 캡처할 상태 경로들
* @param stateValues 현재 상태 값들
*/
registerStateCaptureForHandler?(
handlerId: string,
statePaths: string[],
stateValues: Record<string, any>
): void;
/**
* Stale Closure 감지 (비동기 작업 완료 후)
*
* @param handlerId 핸들러 ID
* @param location 발생 위치
* @param currentState 현재 상태
* @param warningType 경고 유형
* @param actionId 관련 액션 ID
*/
detectStaleClosure?(
handlerId: string,
location: string,
currentState: Record<string, any>,
warningType: 'async-state-capture' | 'callback-state-capture' | 'timeout-state-capture' | 'sequence-state-mismatch' | 'event-handler-stale',
actionId?: string
): any[];
/**
* Stale Closure 경고 직접 추가
*
* @param info 경고 정보
*/
trackStaleClosureWarning?(info: {
type: 'async-state-capture' | 'callback-state-capture' | 'timeout-state-capture' | 'sequence-state-mismatch' | 'event-handler-stale';
location: string;
capturedPath: string;
capturedValue: any;
capturedAt: number;
currentValue: any;
actionId?: string;
stackTrace?: string;
}): void;
// ============================================
// Modal State Scope 추적 메서드
// ============================================
/**
* 모달 열림 추적
*
* @param options 모달 정보
*/
trackModalOpen?(options: {
modalId: string;
modalName: string;
scopeType?: 'isolated' | 'shared' | 'inherited';
parentModalId?: string;
componentId?: string;
initialState?: Record<string, any>;
isolatedStateKeys?: string[];
sharedStateKeys?: string[];
}): void;
/**
* 모달 닫힘 추적
*
* @param modalId 모달 ID
* @param finalState 최종 상태 (선택)
*/
trackModalClose?(modalId: string, finalState?: Record<string, any>): void;
/**
* 모달 내 상태 변경 추적
*
* @param options 상태 변경 정보
*/
trackModalStateChange?(options: {
modalId: string;
stateKey: string;
previousValue: any;
newValue: any;
changeSource?: 'user-action' | 'api-response' | 'parent-sync' | 'init' | 'cleanup';
}): void;
// ============================================
// Nested Context 추적 메서드
// ============================================
/**
* Nested Context 추적 (expandChildren, cellChildren, iteration, modal, slot)
*
* @param info 컨텍스트 정보
* @returns 생성된 컨텍스트 ID
*/
trackNestedContext?(info: {
componentId: string;
componentType: 'expandChildren' | 'cellChildren' | 'iteration' | 'modal' | 'slot';
parentContext: {
available: string[];
values: Record<string, any>;
};
ownContext: {
added: string[];
values: Record<string, any>;
};
depth: number;
parentId?: string;
}): string;
/**
* Nested Context 접근 시도 기록
*
* @param contextId 컨텍스트 ID
* @param access 접근 정보
*/
trackNestedContextAccess?(contextId: string, access: {
path: string;
found: boolean;
value?: any;
error?: string;
}): void;
// ============================================
// Computed 추적 메서드
// ============================================
/**
* Computed 속성 등록
*
* @param name 속성명
* @param expression 표현식
* @param dependencies 의존성 배열
* @param value 현재 값
* @param computationTime 계산 시간
* @param componentId 컴포넌트 ID (선택)
* @param error 에러 메시지 (선택)
*/
trackComputedProperty?(
name: string,
expression: string,
dependencies: Array<{ type: string; path: string; value?: any }>,
value: any,
computationTime: number,
componentId?: string,
error?: string
): void;
/**
* Computed 재계산 기록
*
* @param computedName 속성명
* @param trigger 트리거 유형
* @param previousValue 이전 값
* @param newValue 새 값
* @param computationTime 계산 시간
* @param triggeredBy 트리거 정보 (선택)
* @param componentId 컴포넌트 ID (선택)
*/
trackComputedRecalc?(
computedName: string,
trigger: 'dependency-change' | 'manual' | 'init',
previousValue: any,
newValue: any,
computationTime: number,
triggeredBy?: {
type: string;
path: string;
oldValue?: any;
newValue?: any;
},
componentId?: string
): void;
// ============================================
// Named Actions 추적 메서드 (g7-named-actions)
// ============================================
/** named_actions 정의 등록 */
setNamedActionDefinitions?(definitions: Record<string, any>): void;
/** actionRef 해석 이력 기록 */
trackNamedActionRef?(log: {
actionRefName: string;
resolvedHandler: string;
timestamp: number;
}): void;
}
/**
* G7Core 전역 객체 초기화에 필요한 의존성 인터페이스
*/
export interface G7CoreDependencies {
/** 템플릿 엔진 상태 getter */
getState: () => {
translationEngine: TranslationEngine | null;
translationContext: TranslationContext;
bindingEngine: DataBindingEngine | null;
actionDispatcher: ActionDispatcher | null;
templateMetadata: { locales: string[] } | null;
};
/** TransitionManager 인스턴스 */
transitionManager: {
getIsPending: () => boolean;
subscribe: (callback: (isPending: boolean) => void) => () => void;
};
/** ResponsiveManager 인스턴스 */
responsiveManager: object;
/** WebSocketManager 인스턴스 */
webSocketManager: WebSocketManager;
/** SlotContext getter (SlotProvider 내부에서 설정) */
getSlotContext?: () => SlotContextValue | null;
/** DynamicRenderer 컴포넌트 getter */
getDynamicRenderer?: () => React.FC<any> | null;
/** ComponentRegistry getter */
getComponentRegistry?: () => any;
/** DataBindingEngine getter */
getDataBindingEngine?: () => DataBindingEngine | null;
/** TranslationEngine getter */
getTranslationEngine?: () => TranslationEngine | null;
/** ActionDispatcher getter */
getActionDispatcher?: () => ActionDispatcher | null;
}
/**
* React 라이브러리를 전역으로 노출
*
* 템플릿 번들에서 React를 사용할 수 있도록 합니다.
*/
function initReactGlobals(): void {
// React/ReactDOM/ReactJSXRuntime을 전역으로 노출 (템플릿 번들에서 사용)
(window as any).React = React;
// React 19에서 unstable_batchedUpdates가 제거되어 @dnd-kit 등에서 에러 발생
// no-op 폴리필을 제공하여 호환성 유지 (React 19에서는 자동 배칭이 기본값)
(window as any).ReactDOM = {
...ReactDOM,
createPortal,
unstable_batchedUpdates: (callback: () => void) => callback(),
};
(window as any).ReactJSXRuntime = ReactJSXRuntime;
logger.log('전역 객체 window.React, window.ReactDOM, window.ReactJSXRuntime에 노출됨');
}
/**
* 컴포넌트 이벤트 시스템 초기화
*
* 컴포넌트 간 통신을 위한 이벤트 시스템을 제공합니다.
*/
function initComponentEventSystem(G7Core: any): void {
const componentEventListeners = new Map<string, Set<(data?: any) => void | Promise<any>>>();
const devTools = G7DevToolsCore.getInstance();
G7Core.componentEvent = {
/**
* 컴포넌트 이벤트를 구독합니다.
* @param eventName 이벤트명 (예: 'triggerUpload:site_logo_uploader')
* @param callback 이벤트 발생 시 호출될 콜백
* @returns 구독 해제 함수
*/
on: (eventName: string, callback: (data?: any) => void | Promise<any>) => {
if (!componentEventListeners.has(eventName)) {
componentEventListeners.set(eventName, new Set());
}
componentEventListeners.get(eventName)!.add(callback);
// DevTools 추적
devTools.trackEventSubscribe(eventName);
return () => {
componentEventListeners.get(eventName)?.delete(callback);
// DevTools 추적
devTools.trackEventUnsubscribe(eventName);
};
},
/**
* 컴포넌트 이벤트를 발생시킵니다.
* @param eventName 이벤트명
* @param data 전달할 데이터
* @returns 모든 리스너의 실행 결과 Promise 배열
*/
emit: async (eventName: string, data?: any): Promise<any[]> => {
const listeners = componentEventListeners.get(eventName);
const listenerCount = listeners?.size ?? 0;
if (!listeners || listeners.size === 0) {
// DevTools 추적 (리스너 없음)
devTools.trackEventEmit(eventName, data, 0);
return [];
}
let emitError: Error | undefined;
let results: any[] = [];
try {
results = await Promise.all(
Array.from(listeners).map(async (callback) => {
try {
return await callback(data);
} catch (error) {
logger.error(`componentEvent: Error in listener for "${eventName}":`, error);
throw error;
}
})
);
} catch (error) {
emitError = error instanceof Error ? error : new Error(String(error));
}
// DevTools 추적
devTools.trackEventEmit(eventName, data, listenerCount, results, emitError);
if (emitError) {
throw emitError;
}
return results;
},
/**
* 특정 이벤트의 모든 리스너를 제거합니다.
* @param eventName 이벤트명
*/
off: (eventName: string) => {
componentEventListeners.delete(eventName);
// DevTools 추적
devTools.trackEventOff(eventName);
},
/**
* 모든 이벤트 리스너를 제거합니다.
*/
clear: () => {
componentEventListeners.clear();
// DevTools 추적
devTools.trackEventClear();
},
};
logger.log('전역 객체 window.G7Core.componentEvent에 노출됨');
}
/**
* 코어 런타임 표면을 `window.G7Core.__runtime` 으로 노출 — 편집기 lazy 번들 공유용.
*
* @since engine-v1.51.0
*
* 레이아웃 편집기 셸은 별도 번들(`layout-editor.min.js`)로 지연 로드된다(메인 번들 비대화
* 회피). 편집기는 코어 런타임의 렌더러/엔진/컨텍스트를 재사용해야 하는데, 이때 **동일 인스턴스
* 동일성**이 강제된다:
*
* - `getInstance()` 싱글톤(TranslationEngine 등)을 편집기 번들이 재번들하면 편집기 사본의
* 싱글톤이 메인 번들과 fork → 번역/데이터소스 상태가 어긋난다.
* - React Context(TranslationReactContext/ResponsiveContext 등)를 재번들하면 `createContext()`
* 가 두 번 실행돼 Context 객체 참조가 달라짐 → 편집기의 `useContext` 가 빈 값을 읽는다.
* - `DynamicRenderer` 는 사실상 코어 런타임 전체(ActionDispatcher/DataBindingEngine/모든
* Context/sortable)를 끌어오므로, 재번들 시 코어 대부분이 편집기 번들에 중복된다.
*
* 이를 막기 위해 메인 번들이 이 런타임 표면을 `G7Core.__runtime` 에 노출하고, 편집기 빌드는
* `resolve.alias` 로 `../DynamicRenderer` 등 상대 경로를 `G7Core.__runtime` 재export shim 으로
* 치환한다(편집기 소스는 무수정). 결과적으로 편집기 번들은 코어 런타임을 0바이트 중복으로
* **빌려 쓰고**, 싱글톤/컨텍스트 동일성이 자동 보장된다.
*
* 메인 `<script>` 는 동기 선행 실행되므로, 편집기 IIFE 가 실행될 때 `__runtime` 은 항상 존재한다.
*
* @param G7Core - window.G7Core 전역 객체
*/
function initCoreRuntimeExports(G7Core: any): void {
// 이미 노출돼 있으면 멱등 (중복 init 방지)
if (G7Core.__runtime) return;
// 노출 대상 = 편집기가 layout-editor/ 밖으로 직접 import 하는 11개 모듈의 런타임 값만.
// (Slot/Transition Provider 는 편집기가 직접 import 하지 않고 메인 번들 renderTemplate 이
// wrapping 하므로 __runtime 미포함. DynamicRenderer 내부의 Slot/Transition 의존은 메인 번들
// DynamicRenderer 에 이미 번들돼 있어 별도 공유 불필요.)
G7Core.__runtime = {
// 렌더러 (default export) — 프리뷰 캔버스
DynamicRenderer,
// 엔진/레지스트리 클래스 (getInstance 싱글톤)
ComponentRegistry,
TranslationEngine,
DataSourceManager,
dataSourceManager,
DataBindingEngine,
dataBindingEngine,
ActionDispatcher,
// 번역 컨텍스트 (Context 객체 동일성 필수)
TranslationReactContext,
TranslationProvider,
useTranslation,
// 반응형 컨텍스트/매니저
ResponsiveContext,
ResponsiveProvider,
useResponsive,
responsiveManager,
BREAKPOINT_PRESETS,
// 인증/로거
AuthManager,
createLogger,
// DevTools 추적 코어 (디버그 lazy 번들이 패널/진단엔진에서 공유) @since engine-v1.51.0
G7DevToolsCore,
};
logger.log('전역 객체 window.G7Core.__runtime(코어 런타임 표면)에 노출됨');
}
/**
* `G7Core.layoutEditor` 예약 접수함(ready 큐) 초기화 —.
*
* 레이아웃 편집기 확장점(`registerWidget`/`registerNodeEditor`/`registerCanvasOverlay`)은
* 편집기 셸(lazy 번들)이 로드될 때 `exposeLayoutEditorGlobals` 가 **실제 레지스트리 함수**로
* 노출한다. 그러나 템플릿 `initTemplate` 은 편집기 로드 **이전**에 실행되므로, 그 시점에
* `G7Core.layoutEditor` 가 없으면 등록이 허공으로 사라진다.
*
* 이를 막기 위해 메인 번들에서 **경량 stub** 을 항상 먼저 노출한다. stub 의 register* 는
* 등록 요청을 `__queue` 에 적재만 하고, `onReady(cb)` 는 `__readyCallbacks` 에 예약한다.
* 편집기 셸 로드 시 `exposeLayoutEditorGlobals` 가 실제 함수로 교체하면서 `__queue` 를
* 일괄 flush + `__readyCallbacks` 호출한다(그 후의 호출은 즉시 등록). 이미 실제 API 가
* 노출돼 있으면(편집기 먼저 로드) stub 으로 덮지 않는다.
*
* 메인 번들 추가분은 함수 3개 + 배열 2개뿐(번들 비대화 ~0). 큐/콜백 공유 상태는 코드
* 분할(메인 vs 편집기 번들) 경계를 넘어야 하므로 모듈 변수가 아니라 `G7Core.layoutEditor`
* **객체 자체**(`__queue`/`__readyCallbacks`)에 둔다.
*/
function initLayoutEditorStub(G7Core: any): void {
// 편집기 셸이 먼저 로드돼 실제 API 가 이미 있으면 stub 으로 덮지 않는다.
if (G7Core.layoutEditor && G7Core.layoutEditor.__isStub !== true) return;
if (G7Core.layoutEditor && G7Core.layoutEditor.__isStub === true) return; // 중복 init 멱등
const queue: Array<[string, ...unknown[]]> = [];
const readyCallbacks: Array<() => void> = [];
G7Core.layoutEditor = {
__isStub: true,
__queue: queue,
__readyCallbacks: readyCallbacks,
registerWidget: (name: string, comp: unknown) => queue.push(['widget', name, comp]),
registerNodeEditor: (kind: string, comp: unknown) => queue.push(['nodeEditor', kind, comp]),
registerCanvasOverlay: (kind: string, overlay: unknown) =>
queue.push(['canvasOverlay', kind, overlay]),
/** 편집기 ready 시 호출될 콜백 예약. 이미 ready 면(=stub 아님) 즉시 호출은 실제 API 책임 */
onReady: (cb: () => void) => {
if (typeof cb === 'function') readyCallbacks.push(cb);
},
};
logger.log('전역 객체 window.G7Core.layoutEditor 예약 접수함(stub) 노출됨');
}
/**
* 번역 관련 API 초기화
*/
function initTranslationAPI(G7Core: any, deps: G7CoreDependencies): void {
// useTranslation 훅 노출 (템플릿 컴포넌트에서 다국어 번역 함수 접근용)
G7Core.useTranslation = useTranslation;
/**
* G7Core.t() - 다국어 번역 함수 직접 접근
*
* 컴포넌트에서 훅 없이 간편하게 번역 함수를 사용할 수 있습니다.
*
* @example
* ```tsx
* // 간단한 번역
* const text = G7Core.t('common.confirm');
*
* // 파라미터를 포함한 번역
* const message = G7Core.t('admin.users.pagination_info', { from: 1, to: 10, total: 100 });
* ```
*
* @param key 번역 키 (예: 'common.confirm')
* @param params 번역 파라미터 (선택, 예: { count: 5 })
* @returns 번역된 문자열, 번역이 없으면 키 자체 반환
*/
G7Core.t = (key: string, params?: Record<string, string | number>): string => {
const state = deps.getState();
// 템플릿 엔진이 초기화되지 않은 경우 키 자체 반환
if (!state.translationEngine || !state.translationContext) {
return key;
}
if (params) {
// 파라미터를 TranslationEngine 형식으로 변환 (|key=value|key2=value2)
const paramsStr = '|' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('|');
return state.translationEngine.translate(key, state.translationContext, paramsStr);
}
return state.translationEngine.translate(key, state.translationContext);
};
logger.log('전역 객체 window.G7Core.useTranslation에 노출됨');
}
/**
* 이벤트 헬퍼 및 렌더링 헬퍼 초기화
*/
function initHelperAPIs(G7Core: any, deps: G7CoreDependencies): void {
// 이벤트 헬퍼 노출 (템플릿 컴포넌트에서 ActionDispatcher 호환 이벤트 생성용)
G7Core.createChangeEvent = createChangeEvent;
G7Core.createClickEvent = createClickEvent;
G7Core.createSubmitEvent = createSubmitEvent;
G7Core.createKeyboardEvent = createKeyboardEvent;
// UUID 생성 함수 노출 (임시 키 등 고유 식별자 생성용)
// 사용법: G7Core.uuid() → "550e8400-e29b-41d4-a716-446655440000"
// 레이아웃 표현식에서: {{$uuid()}}
G7Core.uuid = (): string => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// fallback: crypto.randomUUID 미지원 환경
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
// Logger 팩토리 노출 (모듈/템플릿에서 일관된 로깅을 위해)
G7Core.createLogger = createLogger;
logger.log('전역 객체 window.G7Core.createLogger에 노출됨');
// renderItemChildren 노출 (반복 아이템의 자식 컴포넌트 렌더링용)
// CardGrid 등에서 호출 시 translationContext, translationEngine, bindingEngine, actionDispatcher를 자동 주입
// 전역 상태(_global, _local, _computed)도 컨텍스트에 자동 병합하여 cellChildren에서 접근 가능
G7Core.renderItemChildren = (
children: any[],
itemContext: Record<string, any>,
componentMap: Record<string, any>,
keyPrefix?: string,
options?: any
) => {
const state = deps.getState();
// 전역 상태 가져오기
// TemplateApp.getGlobalState()는 globalState를 직접 반환 (예: { _remountKeys, sidebarOpen, ... })
// 이 값이 dataContext._global의 내용과 동일함
const templateApp = (window as any).__templateApp;
const globalStateContent = templateApp?.getGlobalState?.() || {};
// 전역 상태를 컨텍스트에 자동 병합 (cellChildren에서 _global 접근 가능)
// itemContext에 이미 해당 키가 있으면 itemContext 값 우선 (명시적 전달 존중)
// _local, _computed는 itemContext에서 전달받거나 비어있음
const mergedContext = {
_global: globalStateContent, // globalState 내용이 곧 _global
_local: itemContext._local || {},
_computed: itemContext._computed || {},
...itemContext,
};
// remount 지원: _global._remountKeys를 확인하여 컴포넌트 리마운트를 지원하는 키 생성 함수
// 적용된 remountId를 추적하여 중복 적용 방지
const appliedRemountIds = new Set<string>();
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
const remountKeys = mergedContext._global?._remountKeys;
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
appliedRemountIds.add(componentId);
return `${componentId}-remount-${remountKeys[componentId]}`;
}
return componentId || fallbackKey;
};
// options가 없거나 필요한 엔진이 없으면 현재 state에서 자동 주입
const mergedOptions = {
translationContext: state.translationContext,
translationEngine: state.translationEngine,
bindingEngine: state.bindingEngine,
actionDispatcher: state.actionDispatcher,
getRemountKey, // remount 지원 추가
...options,
};
return renderItemChildren(children, mergedContext, componentMap, keyPrefix, mergedOptions);
};
/**
* 조건 문자열 평가 노출 (템플릿 컴포넌트용)
*
* 템플릿 컴포넌트가 `new Function('row', ...)` 로 자체 평가기를 두면 엔진과 판정이
* 갈린다 — 실제로 `_local`/`_global` 을 참조하는 조건에서 두 컴포넌트가 예외를 서로
* 다르게 처리해 한쪽은 자식이 사라지고 다른 쪽은 항상 표시되는 **반대 방향** 결과가 났다.
* 이 API 는 엔진의 `evaluateStringCondition` 을 그대로 쓰고, 전역/로컬/계산 상태를
* 컨텍스트에 병합해 넘긴다.
*
* @param condition 조건 문자열 (예: `"{{row.charge_policy !== 'free'}}"`)
* @param extraContext 추가 컨텍스트 (예: `{ row }`)
* @returns 평가 결과. 조건이 비어 있으면 true
*
* @since engine-v1.56.1
*/
G7Core.evaluateCondition = (condition: string, extraContext?: Record<string, any>): boolean => {
if (!condition) return true;
const state = deps.getState();
const templateApp = (window as any).__templateApp;
const globalStateContent = templateApp?.getGlobalState?.() || {};
const context = {
_global: globalStateContent,
_local: globalStateContent._local || {},
_computed: globalStateContent._computed || {},
...(extraContext || {}),
};
const engine = state.bindingEngine ?? dataBindingEngine;
return evaluateStringCondition(condition, context, engine);
};
// getComponentMap 노출 (CardGrid 등에서 전체 컴포넌트 맵 접근용)
G7Core.getComponentMap = () => {
return ComponentRegistry.getInstance().getComponentMap();
};
/**
* component_layout을 렌더링하는 헬퍼 함수
*
* 컴포넌트에서 component_layout 정의를 받아서 React 요소로 렌더링합니다.
* 각 항목의 컨텍스트(item, index 등)를 주입하여 데이터 바인딩을 처리합니다.
*
* @param layoutDefs component_layout 정의 배열
* @param itemContext 항목별 컨텍스트 (예: { item: option, index: 0, isSelected: true })
* @param keyPrefix React key prefix
* @returns React 요소 배열
*
* @example
* ```tsx
* // RichSelect 컴포넌트에서 사용
* const itemElements = G7Core.renderComponentLayout(
* __componentLayoutDefs?.item,
* { item: option, index, isSelected },
* `item-${index}`
* );
* ```
*/
G7Core.renderComponentLayout = (
layoutDefs: any[] | undefined,
itemContext: Record<string, any>,
keyPrefix?: string
): React.ReactNode => {
if (!layoutDefs || !Array.isArray(layoutDefs) || layoutDefs.length === 0) {
return null;
}
const componentMap = ComponentRegistry.getInstance().getComponentMap();
const state = deps.getState();
// 전역 상태 가져오기 (remount 지원을 위해)
const templateApp = (window as any).__templateApp;
const globalState = templateApp?.getGlobalState?.() || {};
// 전역 상태를 컨텍스트에 병합 (remount 키 접근을 위해)
const mergedContext = {
_global: globalState._global || {},
_local: globalState._local || {},
_computed: globalState._computed || {},
...itemContext,
};
// remount 지원
const appliedRemountIds = new Set<string>();
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
const remountKeys = mergedContext._global?._remountKeys;
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
appliedRemountIds.add(componentId);
return `${componentId}-remount-${remountKeys[componentId]}`;
}
return componentId || fallbackKey;
};
return renderItemChildren(
layoutDefs,
mergedContext,
componentMap,
keyPrefix,
{
translationContext: state.translationContext,
translationEngine: state.translationEngine,
bindingEngine: state.bindingEngine,
actionDispatcher: state.actionDispatcher,
getRemountKey,
}
);
};
/**
* 확장 행(expandChildren) 컨텐츠를 렌더링하는 헬퍼 함수 (Phase 1-2)
*
* DataGrid, CardGrid 등에서 확장 행 렌더링 시 필요한 복잡한 로직을
* 캡슐화하여 60줄+ 코드를 10줄 이하로 줄입니다.
*
* @param config.children 렌더링할 컴포넌트 정의 배열 (expandChildren)
* @param config.row 현재 행 데이터
* @param config.expandContext 확장 컨텍스트 (바인딩 표현식 자동 평가)
* @param config.componentContext 부모 컴포넌트 컨텍스트 (state, setState)
* @param config.componentMap 컴포넌트 맵 (선택, 없으면 자동 조회)
* @param config.keyPrefix React key prefix
* @returns React 요소 배열
*
* @example
* ```tsx
* // DataGrid에서 확장 행 렌더링 (Before: 60줄+ → After: 10줄)
* const renderExpandedContent = useCallback((row: any) => {
* if (expandedRowRender) return expandedRowRender(row);
* if (expandChildren && expandChildren.length > 0) {
* return G7Core.renderExpandContent({
* children: expandChildren,
* row,
* expandContext,
* componentContext: __componentContext,
* keyPrefix: `expand-${row[idField]}`,
* });
* }
* return null;
* }, [expandChildren, expandContext, __componentContext, expandedRowRender, idField]);
* ```
*/
G7Core.renderExpandContent = (config: {
children: any[];
row: any;
expandContext?: Record<string, any>;
componentContext?: {
state?: Record<string, any>;
setState?: (updates: Record<string, any>) => void;
stateRef?: { current: Record<string, any> };
computedRef?: { current: Record<string, any> };
};
componentMap?: Record<string, React.ComponentType<any>>;
keyPrefix: string;
}): React.ReactNode => {
const { children, row, expandContext, componentContext, keyPrefix } = config;
if (!children || !Array.isArray(children) || children.length === 0) {
return null;
}
// 컴포넌트 맵 조회 (없으면 자동)
const componentMap = config.componentMap || ComponentRegistry.getInstance().getComponentMap();
const state = deps.getState();
// 1. 전역 상태 가져오기
const globalState = G7Core.state?.get?.() || {};
// 2. componentContext에서 최신 _local 상태 가져오기
// stateRef.current를 우선 사용하여 캐싱된 componentContext에서도 최신 상태 접근
// stateRef가 없으면 기존 state 사용 (하위 호환성)
const latestLocalState = componentContext?.stateRef?.current ?? componentContext?.state ?? {};
const mergedLocal = {
...(globalState._local || {}),
...latestLocalState,
};
// 3. componentContext에서 최신 _computed 상태 가져오기
// computedRef.current를 우선 사용하여 캐싱된 componentContext에서도 최신 _computed 접근
// _computed는 _local 기반으로 계산되므로 _local 변경 시 함께 업데이트됨
const latestComputed = componentContext?.computedRef?.current ?? globalState._computed ?? {};
// 4. expandContext 바인딩 표현식 자동 평가
const bindingEngine = state.bindingEngine ?? G7Core.getDataBindingEngine?.();
const evalContext = {
...globalState,
_local: mergedLocal,
_computed: latestComputed,
row,
item: row,
$item: row,
};
let resolvedExpandContext: Record<string, any> = {};
if (expandContext && typeof expandContext === 'object' && bindingEngine) {
try {
// expandContext의 각 값을 수동으로 바인딩 평가
// resolveObject는 DEFAULT_SKIP_BINDING_KEYS 때문에 expandContext 내부 키를 스킵할 수 있음
for (const [key, value] of Object.entries(expandContext)) {
if (typeof value === 'string') {
// 단일 Mustache 표현식인지 확인 ({{expr}} 형태).
// 판정은 BindingShape 정본 — 종전 `^\{\{([^}]+)\}\}$` 은 식 안에 `}` 가
// 들어간 경우(`?? {}` 등)를 단일 바인딩으로 보지 못해 보간 경로로 흘렸다.
// @since engine-v1.55.0
const expr = extractSingleBinding(value);
if (expr !== null) {
// 단일 바인딩 표현식: evaluateExpression 사용하여 원본 타입 유지
// resolveBindings는 문자열 보간용이라 배열/객체를 JSON 문자열로 변환함
try {
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 파이프가 적용되지 않는다. @since engine-v1.54.10
resolvedExpandContext[key] = hasPipes(expr)
? bindingEngine.evaluatePipeExpression(expr, evalContext, { skipCache: true })
: bindingEngine.evaluateExpression(expr, evalContext, { skipCache: true });
} catch (e) {
// 평가 실패 시 undefined (폴백 처리는 표현식 자체에서 || 로 처리)
resolvedExpandContext[key] = undefined;
}
} else {
// 혼합 표현식 (예: "Hello {{name}}"): resolveBindings 사용
resolvedExpandContext[key] = bindingEngine.resolveBindings(value, evalContext, { skipCache: true });
}
} else {
resolvedExpandContext[key] = value;
}
}
} catch (error) {
logger.warn('renderExpandContent: expandContext 평가 오류:', error);
}
}
// 4. 최종 컨텍스트 구성
const context = {
row,
item: row,
$item: row,
_local: mergedLocal,
_global: globalState._global || {},
_computed: globalState._computed || {},
...resolvedExpandContext,
};
// 5. remount 지원
const appliedRemountIds = new Set<string>();
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
const remountKeys = context._global?._remountKeys;
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
appliedRemountIds.add(componentId);
return `${componentId}-remount-${remountKeys[componentId]}`;
}
return componentId || fallbackKey;
};
// 6. 렌더링
return renderItemChildren(children, context, componentMap, keyPrefix, {
translationContext: state.translationContext,
translationEngine: state.translationEngine ?? undefined,
bindingEngine: state.bindingEngine ?? undefined,
actionDispatcher: state.actionDispatcher,
componentContext,
getRemountKey,
});
};
/**
* 깊은 객체 경로 접근 헬퍼 함수
*
* 동적 키를 포함한 깊은 객체 경로 접근과 폴백 값을 지원합니다.
* 다중 통화, 다국어, 중첩 설정 등 다양한 상황에서 활용할 수 있습니다.
*
* @param obj 접근할 객체
* @param path 경로 (문자열 또는 문자열 배열)
* @param fallback 경로가 존재하지 않을 때 반환할 기본값
* @returns 경로에 해당하는 값 또는 폴백 값
*
* @example
* ```ts
* // 다중 통화 가격 접근
* $get(product.prices, [currency, 'formatted'], product.price_formatted)
*
* // 다국어 객체 접근
* $get(product.name, locale, product.name.ko)
*
* // 중첩 설정 접근
* $get(settings, ['drivers', 'mail', 'host'], 'localhost')
*
* // 동적 키 접근
* $get(prices, [selectedPlan, 'monthly'], 0)
* ```
*/
G7Core.$get = function $get(obj: any, path: string | string[], fallback: any = undefined): any {
// null/undefined 객체는 즉시 폴백 반환
if (obj === null || obj === undefined) {
return fallback;
}
// 경로를 배열로 정규화
const keys = Array.isArray(path) ? path : [path];
// 빈 경로는 객체 자체 반환
if (keys.length === 0) {
return obj;
}
let current = obj;
for (const key of keys) {
// 현재 위치가 null/undefined이면 폴백 반환
if (current === null || current === undefined) {
return fallback;
}
// 키가 null/undefined이면 폴백 반환
if (key === null || key === undefined) {
return fallback;
}
// 다음 레벨로 이동
current = current[key];
}
// 최종 값이 null/undefined이면 폴백 반환
return current ?? fallback;
};
logger.log('전역 객체 window.G7Core.renderItemChildren에 노출됨');
logger.log('전역 객체 window.G7Core.renderComponentLayout에 노출됨');
logger.log('전역 객체 window.G7Core.renderExpandContent에 노출됨');
logger.log('전역 객체 window.G7Core.$get에 노출됨');
}
/**
* 액션 디스패치 API 초기화
*/
function initDispatchAPI(G7Core: any): void {
/**
* 액션을 디스패치합니다.
*
* @param action 실행할 액션 정의
* @param options 디스패치 옵션
* @param options.componentContext 컴포넌트 컨텍스트 (openModal 등에서 부모 컨텍스트 지정용)
* - state: 컴포넌트의 _local 상태
* - setState: 컴포넌트의 상태 업데이트 함수
* - 지정 시 __g7ActionContext보다 우선 사용됨
*
* @example
* // 기본 사용 (레이아웃 액션에서 자동으로 컨텍스트 설정됨)
* G7Core.dispatch({ handler: 'openModal', target: 'myModal' });
*
* // 컴포넌트에서 직접 호출 시 컨텍스트 명시적 전달
* G7Core.dispatch(
* { handler: 'openModal', target: 'myModal' },
* { componentContext: props.__componentContext }
* );
*/
G7Core.dispatch = async (action: any, options?: { componentContext?: any }) => {
const templateApp = (window as any).__templateApp;
if (!templateApp) {
logger.warn('G7Core.dispatch: TemplateApp이 초기화되지 않았습니다.');
return { success: false, error: new Error('TemplateApp이 초기화되지 않았습니다.') };
}
const actionDispatcher = templateApp.getActionDispatcher?.();
if (!actionDispatcher) {
logger.warn('G7Core.dispatch: ActionDispatcher를 찾을 수 없습니다.');
return { success: false, error: new Error('ActionDispatcher를 찾을 수 없습니다.') };
}
const router = templateApp.getRouter?.();
const globalState = templateApp.getGlobalState?.();
const setGlobalState = templateApp.setGlobalState?.bind(templateApp);
// engine-v1.17.3: 컴포넌트에서 직접 dispatch 호출 시 componentContext 전달 지원
// openModal 등에서 부모 컨텍스트를 정확히 지정하기 위해 사용
// 우선순위: options.componentContext > __g7ActionContext > global fallback
const passedComponentContext = options?.componentContext;
// 현재 실행 중인 액션의 컨텍스트가 있으면 이를 우선 사용
// 핸들러 내부에서 G7Core.modal.open() 등을 호출할 때 컴포넌트 컨텍스트 유지
// engine-v1.16.0: $parent 바인딩 컨텍스트 지원을 위해 필수
const actionContext = passedComponentContext || (window as any).__g7ActionContext;
// engine-v1.17.0: setLocal 후 즉시 dispatch 호출 시 최신 로컬 상태 참조 지원
// React setState는 비동기이므로 actionContext.state에 반영되기 전에 dispatch가 호출될 수 있음
// __g7PendingLocalState가 있으면 이를 context.state로 사용하여 최신 상태 보장
const pendingLocalState = (window as any).__g7PendingLocalState;
let contextState = actionContext?.state ?? globalState;
// pending 상태가 있으면 context.state에 병합
// 커스텀 핸들러에서 setLocal 후 dispatch 호출 시 {{_local.xxx}} 표현식에서 최신 값 참조
if (pendingLocalState && actionContext) {
contextState = pendingLocalState;
logger.log('[dispatch] Using __g7PendingLocalState for context.state:', pendingLocalState);
}
// v1.19.1: pendingLocalState가 있으면 context.data._local도 갱신
// refetchDataSource 핸들러에서 context.data._local을 contextLocalState로 우선 참조하므로 (v1.19.0)
// 커스텀 핸들러에서 setLocal → dispatch 호출 시 context.data._local이 렌더 시점의 stale 상태이면
// merge 순서에 의해 pendingLocalState(NEW)를 contextLocalState(OLD)가 덮어쓰는 문제 발생
// sequence 핸들러는 G7Core.dispatch를 경유하지 않으므로 이 코드 경로에 영향 없음
let contextData = actionContext?.data ?? globalState;
if (pendingLocalState && actionContext?.data?._local) {
contextData = {
...actionContext.data,
_local: pendingLocalState,
};
logger.log('[dispatch] Updated context.data._local with pendingLocalState');
}
const context = {
navigate: router
? (path: string, options?: { replace?: boolean; state?: any }) =>
router.navigate(path, options)
: undefined,
// 액션 컨텍스트가 있으면 컴포넌트의 setState/state 사용, 없으면 전역 사용
setState: actionContext?.setState ?? setGlobalState,
state: contextState,
data: contextData,
// engine-v1.17.2: 비동기 콜백에서 dispatch 호출 시 target=local setState 처리 지원
// 컴포넌트 컨텍스트 없이 dispatch 호출된 경우 마커 설정
// handleSetState에서 이 마커를 확인하여 globalStateUpdater({ _local: ... }) 경로 사용
_isDispatchFallbackContext: !actionContext?.setState,
};
// engine-v1.41.0: debounce 옵션 처리
// ActionDispatcher의 debouncedCall을 사용하여 기존 타이머 인프라 활용
if (action.debounce) {
const debounceKey = action.debounceKey || `dispatch-${action.handler}`;
const delay = typeof action.debounce === 'number' ? action.debounce : action.debounce.delay;
actionDispatcher.debouncedCall(debounceKey, delay, () => {
// 디바운스 타이머 fire 시 최신 컨텍스트로 액션 실행
const { debounce: _d, debounceKey: _k, ...actionWithoutDebounce } = action;
actionDispatcher.dispatchAction(actionWithoutDebounce, context);
});
return { success: true, debounced: true };
}
try {
return await actionDispatcher.dispatchAction(action, context);
} catch (error) {
logger.error('G7Core.dispatch: 액션 실행 오류:', error);
return {
success: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
};
logger.log('전역 객체 window.G7Core.dispatch에 노출됨');
}
/**
* dot notation 경로를 중첩 객체로 변환합니다.
*
* @example
* convertDotNotationToObject({ "filter.orderStatus": ["paid"] })
* // Returns: { filter: { orderStatus: ["paid"] } }
*
* @param updates 변환할 객체
* @returns 중첩 객체로 변환된 결과
*/
function convertDotNotationToObject(updates: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(updates)) {
if (key.includes('.')) {
// dot notation 경로를 배열로 분리
const keys = key.split('.');
let current = result;
// 중첩 객체 생성
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!(k in current)) {
current[k] = {};
} else if (typeof current[k] !== 'object' || current[k] === null) {
// 기존 값이 객체가 아니면 객체로 교체
current[k] = {};
}
current = current[k];
}
// 마지막 키에 값 할당
current[keys[keys.length - 1]] = value;
} else {
// dot notation이 아니면 그대로 복사
result[key] = value;
}
}
return result;
}
/**
* 중첩 객체에서 리프(leaf) 경로 전부를 dot notation 문자열 배열로 추출합니다.
*
* engine-v1.43.0+ 자동바인딩 경로 자동 승격 감지에 사용.
* `__g7AutoBindingPaths` 레지스트리에 fullPath 형식(`form.title`)으로 키가 등록되므로,
* setLocal의 `converted` 중첩 객체에서도 동일한 형식의 리프 경로를 추출해 교집합을 검사한다.
*
* 배열은 리프로 취급 (자동바인딩은 배열 자체 값에 매핑되므로).
*
* @example
* flattenLeafPaths({ form: { title: "X", content: "Y" } })
* // Returns: ["form.title", "form.content"]
*
* flattenLeafPaths({ hasChanges: true, form: { tags: ["a", "b"] } })
* // Returns: ["hasChanges", "form.tags"]
*
* @param obj 중첩 객체
* @param prefix 재귀용 prefix (내부)
* @returns 리프 경로 문자열 배열
*/
function flattenLeafPaths(obj: Record<string, any>, prefix = ''): string[] {
const paths: string[] = [];
if (!obj || typeof obj !== 'object') return paths;
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
paths.push(...flattenLeafPaths(value, fullKey));
} else {
paths.push(fullKey);
}
}
return paths;
}
/**
* 객체의 모든 키가 숫자(배열 인덱스)인지 확인합니다.
*
* @param obj 확인할 객체
* @returns 모든 키가 숫자이면 true
*/
function hasOnlyNumericKeys(obj: Record<string, any>): boolean {
const keys = Object.keys(obj);
return keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
}
/**
* 두 객체를 깊은 병합(deep merge)합니다.
*
* 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우,
* source의 각 키를 배열 인덱스로 해석하여 해당 위치의 요소를 병합합니다.
* 예: target = [{a:1}, {b:2}], source = {"0": {c:3}} → [{a:1, c:3}, {b:2}]
*
* @param target 병합 대상 객체
* @param source 병합할 소스 객체
* @returns 병합된 결과 객체
*/
/**
* base 객체에 없는 leaf 키만 extra에서 추가합니다.
*
* deepMerge와 달리 base에 이미 존재하는 값(배열 포함)은 절대 덮어쓰지 않습니다.
* extra에만 존재하는 키는 재귀적으로 추가됩니다.
*
* 용도: setLocal에서 dynamicLocal(actionContext.state)의 setState 전용 키를 globalLocal에
* 안전하게 추가할 때 사용. dynamicLocal의 stale 배열(init_actions 기본값)이 globalLocal의
* 정상 API 데이터를 덮어쓰는 것을 방지합니다.
*
* @since engine-v1.41.0
*
* @example
* ```ts
* const base = { form: { category_ids: [381, 384], name: 'A' } };
* const extra = { form: { category_ids: [], options: [] }, selectedProducts: [1] };
* addMissingLeafKeys(base, extra);
* // → { form: { category_ids: [381, 384], name: 'A', options: [] }, selectedProducts: [1] }
* // base의 category_ids는 보존, extra의 selectedProducts와 options는 추가
* ```
*/
function addMissingLeafKeys(base: Record<string, any>, extra: Record<string, any>): Record<string, any> {
const result = { ...base };
for (const key of Object.keys(extra)) {
if (!(key in result)) {
// base에 없는 키: extra 값 그대로 추가
result[key] = extra[key];
} else if (
result[key] !== null &&
typeof result[key] === 'object' &&
!Array.isArray(result[key]) &&
extra[key] !== null &&
typeof extra[key] === 'object' &&
!Array.isArray(extra[key])
) {
// 양쪽 모두 plain object: 재귀적으로 처리
result[key] = addMissingLeafKeys(result[key], extra[key]);
}
// base에 이미 존재하는 leaf 값(배열, 문자열, 숫자 등): 건너뜀 (base 값 보존)
}
return result;
}
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
// 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우
// → 배열 요소를 인덱스별로 병합
if (Array.isArray(target) && !Array.isArray(source) && hasOnlyNumericKeys(source)) {
const numericKeys = Object.keys(source).map((k) => parseInt(k, 10));
const maxKey = numericKeys.length > 0 ? Math.max(...numericKeys) : 0;
// Sparse array 방지: source의 최대 키가 target 배열 범위를 크게 초과하면
// 배열 인덱스가 아닌 ID/키 매핑(예: product_option_id → coupon)으로 간주
// → 배열 병합 대신 일반 객체 병합으로 처리 (sparse array 생성 방지)
if (maxKey >= target.length + numericKeys.length + 10) {
// Fall through to normal object merge below
} else {
const result = [...target];
for (const [key, value] of Object.entries(source)) {
const index = parseInt(key, 10);
if (index >= 0 && index < result.length) {
// 인덱스가 범위 내에 있으면 해당 요소와 병합
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
result[index] !== null &&
typeof result[index] === 'object'
) {
result[index] = deepMerge(result[index], value);
} else {
result[index] = value;
}
} else if (index >= result.length) {
// 인덱스가 범위를 초과하면 배열 확장
result[index] = value;
}
}
return result as any;
}
}
const result = { ...target };
for (const [key, value] of Object.entries(source)) {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
// 중첩 객체인 경우 재귀적으로 병합
if (result[key] !== null && typeof result[key] === 'object') {
// target[key]가 배열이든 객체든 deepMerge가 처리
result[key] = deepMerge(result[key], value);
} else {
result[key] = { ...value };
}
} else {
// 원시 값 또는 배열인 경우 그대로 할당
result[key] = value;
}
}
return result;
}
/**
* 전역 상태 관리 API 초기화
*/
function initStateAPI(G7Core: any): void {
G7Core.state = {
/**
* 현재 전역 상태를 반환합니다.
*/
get: () => {
const templateApp = (window as any).__templateApp;
return templateApp?.getGlobalState?.() || {};
},
/**
* 전역 상태를 업데이트합니다.
*
* dot notation을 지원합니다.
* 예: `{ "filter.orderStatus": ["paid"] }` → `{ filter: { orderStatus: ["paid"] } }`
*
* @param updates 업데이트할 상태 객체
* @param options 병합 옵션 { merge?: 'replace' | 'shallow' | 'deep' }
*/
set: (updates: Record<string, any>, options?: { merge?: 'replace' | 'shallow' | 'deep'; render?: boolean }) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.setGlobalState && templateApp?.getGlobalState) {
const mergeMode = options?.merge || 'deep';
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
let finalState: Record<string, any>;
if (mergeMode === 'replace') {
finalState = converted;
} else if (mergeMode === 'shallow') {
const currentState = templateApp.getGlobalState();
finalState = { ...currentState, ...converted };
} else {
// deep (기본값)
const currentState = templateApp.getGlobalState();
finalState = deepMerge(currentState, converted);
}
// engine-v1.42.0: render 옵션 전파
templateApp.setGlobalState(finalState, { render: options?.render });
} else {
logger.warn('G7Core.state.set: TemplateApp이 초기화되지 않았습니다.');
}
},
/**
* 컴포넌트 로컬 상태(_local)를 업데이트합니다.
*
* 커스텀 핸들러에서 컴포넌트의 로컬 상태를 직접 업데이트할 때 사용합니다.
* 이 메서드는 ActionDispatcher의 액션 실행 중에만 유효합니다.
*
* - 액션 컨텍스트가 있는 경우: 컴포넌트의 dynamicState를 직접 업데이트하여 즉시 UI에 반영
* - 액션 컨텍스트가 없는 경우: 전역 _local을 업데이트 (fallback)
*
* dot notation을 지원합니다.
* 예: `{ "filter.orderStatus": ["paid"] }` → `{ filter: { orderStatus: ["paid"] } }`
*
* @param updates 업데이트할 로컬 상태 객체
*
* @example
* ```ts
* // 커스텀 핸들러에서 사용
* export function myHandler(action: any, context: any): void {
* const G7Core = (window as any).G7Core;
*
* // 현재 로컬 상태 가져오기
* const currentLocal = G7Core.state.getLocal();
*
* // 로컬 상태 업데이트 (현재 컴포넌트의 상태)
* G7Core.state.setLocal({
* selectedItems: [...currentLocal.selectedItems, newItem],
* isLoading: false,
* });
*
* // dot notation 지원
* G7Core.state.setLocal({
* "filter.orderStatus": ["paid", "shipped"],
* });
*
* // scope 옵션으로 부모/루트 레이아웃에 상태 저장 (모달 등에서 유용)
* G7Core.state.setLocal({
* "form.fields": updatedFields,
* }, { scope: 'parent' });
* }
* ```
*/
setLocal: (updates: Record<string, any>, options?: { scope?: 'current' | 'parent' | 'root'; merge?: 'replace' | 'shallow' | 'deep'; debounce?: number; debounceKey?: string; render?: boolean; selfManaged?: boolean }) => {
const templateApp = (window as any).__templateApp;
// [engine-v1.43.0+] 이중 저장소 동기화 보호 — 자동바인딩 경로 자동 승격
//
// 배경: 엔진은 폼 데이터를 React localDynamicState(저장소 A)와 globalState._local(저장소 B)에
// 이중 저장한다. render:false 호출이 저장소 A에 자동바인딩된 경로를 건드리면 A가 갱신되지 않아
// Input이 stale 값을 표시한다. DynamicRenderer의 propsWithAutoBinding 주변 useEffect가 활성
// 자동바인딩 경로를 __g7AutoBindingPaths: Map<string, number>에 ref count로 등록한다.
//
// 규칙: setLocal({render:false})가 레지스트리에 등록된 경로를 업데이트하면 render:true로
// 강제 승격하여 A↔B 정합성 보장. 예외는 selfManaged:true를 명시한 호출 — CKEditor5 등
// 자체 DOM 관리 플러그인이 의도적으로 render:false를 유지하려 할 때 사용. 누락 시 엔진이
// 자동 승격하여 안전 확보 (safe-by-default).
//
// 자세한 설계 배경: DynamicRenderer.tsx의 performStateUpdate 상단 주석 참조.
if (options?.render === false && !options?.selfManaged) {
const registry = (window as any).__g7AutoBindingPaths as Map<string, number> | undefined;
if (registry && registry.size > 0) {
const leafPaths = flattenLeafPaths(convertDotNotationToObject(updates));
if (leafPaths.some((path) => registry.has(path))) {
logger.log(
'[setLocal] render:false + 자동바인딩 경로 겹침 감지 → render:true 자동 승격 (engine-v1.43.0)'
);
options = { ...options, render: true };
}
}
}
// engine-v1.41.0: debounce 옵션 처리
// ActionDispatcher의 debouncedCall을 사용하여 기존 타이머 인프라 활용
// 컴포넌트 언마운트 시 자동 정리 + flushPendingDebounceTimers 연동
if (options?.debounce) {
const actionDispatcher = templateApp?.getActionDispatcher?.();
if (actionDispatcher) {
const key = options.debounceKey || `setLocal-${Object.keys(updates).join(',')}`;
// 디바운스 타이머 fire 시 원래 setLocal 로직을 debounce 없이 재호출
const { debounce: _d, debounceKey: _k, ...restOptions } = options;
actionDispatcher.debouncedCall(key, options.debounce, () => {
G7Core.state.setLocal(updates, restOptions);
});
return;
}
}
const scope = options?.scope ?? 'current';
const mergeMode = options?.merge || 'deep';
// ActionDispatcher에서 설정한 현재 컴포넌트 컨텍스트 확인
const actionContext = (window as any).__g7ActionContext;
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
// scope가 'parent' 또는 'root'인 경우 레이아웃 컨텍스트 스택에서 타겟 컨텍스트 사용
if (scope === 'parent' || scope === 'root') {
const contextStack: Array<{ state: Record<string, any>; setState: (updates: any) => void }> =
(window as any).__g7LayoutContextStack || [];
if (contextStack.length > 0) {
// parent: 스택의 마지막 (바로 이전 컨텍스트)
// root: 스택의 첫 번째 (최상위 컨텍스트)
const targetContext = scope === 'parent'
? contextStack[contextStack.length - 1]
: contextStack[0];
if (targetContext?.setState) {
// 함수형 업데이트를 사용하여 항상 최신 상태와 병합
// React setState는 비동기이므로 스냅샷 대신 함수형 업데이트 필수
targetContext.setState((currentLocal: Record<string, any>) => {
if (mergeMode === 'replace') return converted;
if (mergeMode === 'shallow') return { ...(currentLocal || {}), ...converted };
return deepMerge(currentLocal || {}, converted);
});
logger.log(`[setLocal] scope=${scope}: 타겟 컨텍스트에 상태 업데이트 (mergeMode=${mergeMode})`, converted);
return;
}
}
// 스택에 컨텍스트가 없으면 경고 후 current로 폴백
logger.warn(`[setLocal] scope=${scope}: 레이아웃 컨텍스트 스택이 비어있습니다. current로 폴백합니다.`);
}
// scope: 'current' (기본값)
// engine-v1.17.7: actionContext 유무와 관계없이 항상 globalLocal을 업데이트
// - actionContext.setState()는 클릭된 컴포넌트(ChipCheckbox)의 상태만 업데이트
// - ChipCheckbox의 상태에는 form.label_assignments가 없으므로 잘못된 대상
// - templateApp.setGlobalState({ _local: ... })가 실제 _local 상태를 관리
// - _localInit에서도 동일한 메커니즘 사용 (setGlobalState({ _local: ... }))
const pendingState = (window as any).__g7PendingLocalState;
const globalLocal = templateApp?.getGlobalState?.()?._local || {};
// engine-v1.22.1: actionContext.state(=dynamicState)도 병합하여 전체 _local 반영
// ActionDispatcher setState(target: "_local")로 설정된 값은 localDynamicState에만 존재하고
// globalLocal(=templateApp.getGlobalState()._local)에는 없음.
// setLocal 후 openModal 시 __g7PendingLocalState가 $parent._local 스냅샷으로 사용되므로,
// dynamicState의 값(예: selectedProducts)이 누락되는 문제 발생.
//
// engine-v1.24.7: 모달 내부에서 호출 시 actionContext.state 병합 제외
//
// engine-v1.41.0: deepMerge(globalLocal, dynamicLocal)은 stale dynamicLocal의 빈 배열이
// globalLocal의 정상 배열을 교체하는 오염 경로를 생성함 (CKEditor + SPA 네비게이션 시 재현).
// dynamicLocal은 actionContext.state(=컴포넌트 렌더 시점 스냅샷)이므로
// init_actions 기본값(category_ids:[], options:[])을 포함할 수 있음.
// globalLocal은 최신 committed 상태이므로 dynamicLocal과 충돌 시 globalLocal이 정확함.
// → globalLocal을 기반으로 dynamicLocal에서 globalLocal에 없는 키만 추가하는
// addMissingLeafKeys 전략으로 변경. v1.22.1 목적(setState 전용 키 보존)은 유지하면서
// stale 배열 덮어쓰기 방지.
const layoutContextStack = (window as any).__g7LayoutContextStack || [];
const isInsideModal = layoutContextStack.length > 0;
const dynamicLocal = (!isInsideModal && actionContext?.state) ? actionContext.state : undefined;
const baseLocal = dynamicLocal
? addMissingLeafKeys(globalLocal, dynamicLocal)
: globalLocal;
const currentSnapshot = pendingState || baseLocal;
let mergedPending: Record<string, any>;
if (mergeMode === 'replace') {
mergedPending = converted;
} else if (mergeMode === 'shallow') {
mergedPending = { ...currentSnapshot, ...converted };
} else {
mergedPending = deepMerge(currentSnapshot, converted);
}
// 1. 전역 _local 상태 업데이트 (핵심!)
// engine-v1.42.0: render 옵션 전파 — render: false이면 값만 저장, React 렌더링 건너뜀
if (templateApp?.setGlobalState) {
templateApp.setGlobalState({ _local: mergedPending }, { render: options?.render });
logger.log(`[setLocal] globalLocal updated via setGlobalState (mergeMode=${mergeMode}, render=${options?.render ?? true}):`, converted);
}
// 2. pendingState도 업데이트 (같은 이벤트 루프 내 후속 getLocal() 호출용)
(window as any).__g7PendingLocalState = mergedPending;
logger.log('[setLocal] __g7PendingLocalState updated:', mergedPending);
// 2-1. sequence 내 커스텀 핸들러 동기화용 별도 변수 설정
// __g7PendingLocalState는 setGlobalState → updateTemplateData → root.render() →
// useLayoutEffect에서 null로 클리어될 수 있음 (await 해제 시 마이크로태스크 플러시)
// __g7SequenceLocalSync는 handleSequence에서만 읽고 초기화하므로 안전
(window as any).__g7SequenceLocalSync = mergedPending;
// 2-2. engine-v1.21.2: getLocal() await fallback용 스냅샷
// __g7PendingLocalState가 useLayoutEffect에서 클리어된 후에도 getLocal()이
// 최신 setLocal 값을 반환할 수 있도록 별도 스냅샷 유지.
// handleLocalSetState에서는 참조하지 않으므로 stale 오염 없음.
// dataContext._local이 갱신되면 클리어됨 (DynamicRenderer useLayoutEffect).
(window as any).__g7LastSetLocalSnapshot = mergedPending;
// 3. forcedLocalFields 업데이트 (dataKey 자동 바인딩 컴포넌트 충돌 방지)
if (mergeMode === 'replace') {
// replace 모드: 기존 forcedLocalFields 초기화 후 새 값으로 교체
(window as any).__g7ForcedLocalFields = converted;
} else {
const existingForced = (window as any).__g7ForcedLocalFields || {};
(window as any).__g7ForcedLocalFields = deepMerge(existingForced, converted);
}
logger.log('[setLocal] __g7ForcedLocalFields updated:', converted);
// engine-v1.17.8: setLocal이 업데이트한 키를 기록하여 ROOT의 localDynamicState에서 제거
// 폼 자동 바인딩이 localDynamicState에 기록한 stale 배열이 dataContext._local을 덮어쓰는 문제 방지
// setState(target: "local")가 아닌 setLocal만 기록 (setState는 localDynamicState를 직접 업데이트)
if (mergeMode === 'replace') {
(window as any).__g7SetLocalOverrideKeys = converted;
} else {
const existingKeys = (window as any).__g7SetLocalOverrideKeys || {};
(window as any).__g7SetLocalOverrideKeys = deepMerge(existingKeys, converted);
}
logger.log('[setLocal] __g7SetLocalOverrideKeys updated:', converted);
// 4. actionContext가 있으면 컴포넌트 로컬 상태도 업데이트 (리액티비티용)
if (actionContext?.setState) {
actionContext.setState((currentLocal: Record<string, any>) => {
if (mergeMode === 'replace') return converted;
if (mergeMode === 'shallow') return { ...(currentLocal || {}), ...converted };
return deepMerge(currentLocal || {}, converted);
});
}
// templateApp이 없는 경우 경고
if (!templateApp?.setGlobalState) {
logger.warn('G7Core.state.setLocal: TemplateApp이 없습니다.');
}
},
/**
* 현재 컴포넌트 로컬 상태를 반환합니다.
*
* 커스텀 핸들러에서 현재 컴포넌트의 로컬 상태를 조회할 때 사용합니다.
*
* @returns 현재 로컬 상태 객체
*
* @example
* ```ts
* // 현재 컴포넌트 로컬 상태
* const currentLocal = G7Core.state.getLocal();
* console.log(currentLocal.selectedItems);
* ```
*/
getLocal: (): Record<string, any> => {
const templateApp = (window as any).__templateApp;
// engine-v1.17.6: globalLocal + pendingState 2단계 병합
//
// 주의: componentState(actionContext.state)를 사용하면 안 됨!
// - actionContext는 클릭된 컴포넌트(예: ChipCheckbox)의 컨텍스트
// - ChipCheckbox의 state에는 form.label_assignments가 없거나 빈 배열
// - 이것이 globalLocal의 정확한 API 데이터를 덮어씀
//
// globalLocal은 _localInit에서 API 데이터가 적용된 후 동기화됨
// (DynamicRenderer의 _localInit effect가 setGlobalState({ _local: ... }) 호출)
//
// 참고: __g7CommittedLocalState(React 커밋 후 extendedDataContext._local)를
// 폴백으로 사용하면 다중 root-level DynamicRenderer(메인 + 모달) 환경에서
// 마지막으로 렌더된 모달의 _local이 메인 페이지 상태를 덮어쓰는 문제 발생.
// 비동기 콜백에서 최신 _local이 필요한 경우, 호출자가 직접
// handlerContext.state(버튼 클릭 시점의 componentContext.state)를 사용해야 함.
//
// ──────────────────────────────────────────────────────────────────
// [모달 scope 제한사항] ( 분석, 2026-03-25)
//
// 현재 getLocal()은 항상 페이지의 globalLocal을 반환한다.
// modals 섹션의 isolated scope 모달 내부 커스텀 핸들러에서 호출해도
// 모달의 _local이 아닌 페이지의 _local을 읽는다.
//
// 반면 layout JSON의 `setState target: "local"`은 ActionDispatcher가
// context.setState를 사용하여 모달 DynamicRenderer의 state를 직접 업데이트한다.
//
// 이 비대칭으로 인해:
// - 모달 onMount에서 setState로 모달 scope에 데이터를 복사하면,
// 이후 핸들러의 getLocal()은 페이지 scope를 읽어 scope 단절 발생
// - setLocal()도 globalLocal에 쓰므로 모달 UI에 반영되지 않음
// (단, actionContext.setState 호출은 있어 컴포넌트 레벨 업데이트는 가능)
//
// 현재 우회책: 모달에서 onMount setState를 사용하지 않고,
// 모달 열기 전 핸들러에서 setLocal()로 페이지 _local에 초기화 후 modal.open().
// 이렇게 하면 모달이 페이지 _local을 그대로 상속하여 getLocal/setLocal과 일관.
// (사용자 취소 모달 _modal_cancel.json, 관리자 취소 모달 _modal_cancel_order.json)
//
// [향후 리팩토링 방향]
// getLocal()/setLocal()을 모달-인식(modal-aware)으로 개선:
// 1. __g7ActionContext에 모달 루트 DynamicRenderer의 state/setState 추가
// (현재는 클릭된 리프 컴포넌트의 state만 있어 부분적임)
// 2. getLocal()에서 __g7ActionContext의 모달 루트 state를 우선 반환
// 3. setLocal()에서 모달 루트 setState를 우선 호출
//
// 주의 사항 (회귀 위험):
// - __g7PendingLocalState가 scope 무인식 전역 싱글톤이라
// setLocal→getLocal→dispatch 체인에서 scope mismatch 발생 가능
// - troubleshooting 사례 5,7(pendingState), 사례 3(SPA navigate),
// global 사례 4(sequence→refetch) 등 기존 수정과 충돌 가능
// - actionContext.state가 리프 컴포넌트의 부분 상태인 문제 해결 필요
// (모달 루트 DynamicRenderer의 전체 _local state 접근 경로 확보)
// ──────────────────────────────────────────────────────────────────
const globalState = templateApp?.getGlobalState?.() || {};
const globalLocal = globalState._local || {};
// pendingState: setLocal() 호출 후 아직 React에 커밋되지 않은 업데이트
const pendingState = (window as any).__g7PendingLocalState;
if (pendingState) {
return deepMerge(globalLocal, pendingState);
}
// engine-v1.21.2: __g7LastSetLocalSnapshot fallback
// __g7PendingLocalState는 useLayoutEffect에서 매 렌더마다 null로 클리어됨.
// 이는 handleLocalSetState에서의 stale pendingState 오염을 방지하기 위해 필요하지만,
// await 경계에서 React 18 마이크로태스크 배칭이 렌더를 플러시하면
// pendingState가 사라져 getLocal()이 stale한 globalLocal만 반환하는 문제 발생.
//
// __g7LastSetLocalSnapshot은 setLocal()에서 함께 설정되지만 useLayoutEffect에서
// 클리어되지 않으므로, await 이후에도 최신 setLocal 값을 유지함.
// handleLocalSetState에서는 참조하지 않으므로 stale 오염 없음.
// globalLocal과 동일 참조인 경우 이미 커밋된 것이므로 스킵.
const lastSnapshot = (window as any).__g7LastSetLocalSnapshot;
if (lastSnapshot && lastSnapshot !== globalLocal) {
return deepMerge(globalLocal, lastSnapshot);
}
return globalLocal;
},
/**
* 현재 상태를 기반으로 전역 상태를 업데이트합니다.
* React의 setState 함수형 업데이트와 유사한 패턴입니다.
* @param updater 현재 상태를 받아 새 상태를 반환하는 함수
*/
update: (updater: (prevState: Record<string, any>) => Record<string, any>) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.getGlobalState && templateApp?.setGlobalState) {
const currentState = templateApp.getGlobalState();
const newUpdates = updater(currentState);
templateApp.setGlobalState(newUpdates);
} else {
logger.warn('G7Core.state.update: TemplateApp이 초기화되지 않았습니다.');
}
},
/**
* 전역 상태 변경을 구독합니다.
* @param listener 상태 변경 시 호출될 콜백 함수
* @returns 구독 해제 함수
*/
subscribe: (listener: (state: Record<string, any>) => void) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.onGlobalStateChange) {
return templateApp.onGlobalStateChange(listener);
}
logger.warn('G7Core.state.subscribe: TemplateApp이 초기화되지 않았습니다.');
return () => {}; // no-op unsubscribe
},
/**
* 데이터 소스 값을 가져옵니다.
* @param dataSourceId 데이터 소스 ID (예: 'menus', 'users')
* @returns 데이터 소스 값 또는 undefined
*/
getDataSource: (dataSourceId: string) => {
const templateApp = (window as any).__templateApp;
return templateApp?.getDataSource?.(dataSourceId);
},
/**
* 격리된 상태(isolated state)를 scopeId로 조회합니다.
*
* 커스텀 핸들러에서 특정 격리 스코프의 상태를 조회할 때 사용합니다.
* 액션 컨텍스트가 있는 경우 해당 컨텍스트의 isolatedContext를 우선 사용합니다.
*
* @param scopeId 격리 스코프 ID (선택, 미지정 시 현재 액션 컨텍스트의 격리 상태 반환)
* @returns 격리된 상태 객체 또는 null
*
* @example
* ```ts
* // 현재 액션 컨텍스트의 격리 상태 조회
* const isolated = G7Core.state.getIsolated();
* console.log(isolated?.step);
*
* // 특정 scopeId로 조회 (레지스트리에서)
* const categoryState = G7Core.state.getIsolated('category-selector');
* console.log(categoryState?.selectedItems);
* ```
*/
getIsolated: (scopeId?: string): Record<string, any> | null => {
// 1. scopeId가 지정된 경우 레지스트리에서 조회
if (scopeId) {
const registry = (window as any).__g7IsolatedStates;
const scope = registry?.[scopeId];
return scope?.state ?? null;
}
// 2. scopeId가 없으면 현재 액션 컨텍스트의 isolatedContext 사용
const actionContext = (window as any).__g7ActionContext;
return actionContext?.isolatedContext?.state ?? null;
},
/**
* 격리된 상태(isolated state)를 업데이트합니다.
*
* 커스텀 핸들러에서 격리된 상태를 직접 업데이트할 때 사용합니다.
* scopeId가 지정된 경우 해당 스코프를, 미지정 시 현재 액션 컨텍스트의 격리 상태를 업데이트합니다.
*
* dot notation을 지원합니다.
* 예: `{ "form.email": "test@example.com" }` → `{ form: { email: "test@example.com" } }`
*
* @param scopeIdOrUpdates scopeId (문자열) 또는 업데이트 객체
* @param maybeUpdates scopeId가 지정된 경우 업데이트 객체
*
* @example
* ```ts
* // 현재 액션 컨텍스트의 격리 상태 업데이트
* G7Core.state.setIsolated({ step: 2, selectedItem: item });
*
* // 특정 scopeId로 업데이트
* G7Core.state.setIsolated('category-selector', { step: 2 });
*
* // dot notation 지원
* G7Core.state.setIsolated({ "form.email": "test@example.com" });
* ```
*/
setIsolated: (
scopeIdOrUpdates: string | Record<string, any>,
maybeUpdates?: Record<string, any>,
options?: { merge?: 'replace' | 'shallow' | 'deep' }
): void => {
let scopeId: string | undefined;
let updates: Record<string, any>;
let mergeOpts: { merge?: 'replace' | 'shallow' | 'deep' } | undefined;
// 오버로드 처리: (updates) 또는 (scopeId, updates) 또는 (updates, options)
if (typeof scopeIdOrUpdates === 'string') {
scopeId = scopeIdOrUpdates;
updates = maybeUpdates || {};
mergeOpts = options;
} else {
updates = scopeIdOrUpdates;
// 두 번째 인자가 options인지 확인
mergeOpts = maybeUpdates && typeof maybeUpdates === 'object' && ('merge' in maybeUpdates) ? maybeUpdates as any : options;
}
const mergeMode: 'replace' | 'shallow' | 'deep' = mergeOpts?.merge || 'deep';
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
// 1. scopeId가 지정된 경우 레지스트리에서 조회하여 업데이트
if (scopeId) {
const registry = (window as any).__g7IsolatedStates;
const scope = registry?.[scopeId];
if (scope?.mergeState) {
scope.mergeState(converted, mergeMode);
} else {
logger.warn(`G7Core.state.setIsolated: scopeId '${scopeId}'를 찾을 수 없습니다.`);
}
return;
}
// 2. scopeId가 없으면 현재 액션 컨텍스트의 isolatedContext 사용
const actionContext = (window as any).__g7ActionContext;
if (actionContext?.isolatedContext?.mergeState) {
actionContext.isolatedContext.mergeState(converted, mergeMode);
} else {
logger.warn('G7Core.state.setIsolated: 액션 컨텍스트에 isolatedContext가 없습니다.');
}
},
/**
* 부모 레이아웃의 컨텍스트를 반환합니다.
*
* 모달 등 자식 레이아웃에서 부모 레이아웃의 상태에 접근할 때 사용합니다.
* `__g7LayoutContextStack`에서 부모 컨텍스트를 가져옵니다.
*
* @returns 부모 컨텍스트 객체 또는 null
*
* @example
* ```ts
* // 커스텀 핸들러에서 부모 상태 조회
* const parentContext = G7Core.state.getParent();
* if (parentContext) {
* const parentLocal = parentContext._local;
* const parentGlobal = parentContext._global;
* console.log('부모 로컬 상태:', parentLocal);
* }
* ```
*
* @since engine-v1.16.0
*/
getParent: (): { _local: Record<string, any>; _global: Record<string, any>; setState: (updates: any) => void } | null => {
const contextStack: Array<{
state: Record<string, any>;
setState: (updates: any) => void;
dataContext?: Record<string, any>;
}> = (window as any).__g7LayoutContextStack || [];
if (contextStack.length === 0) {
logger.log('[getParent] 레이아웃 컨텍스트 스택이 비어있습니다.');
return null;
}
// 스택의 마지막 항목이 부모 컨텍스트
const parentEntry = contextStack[contextStack.length - 1];
if (!parentEntry) {
return null;
}
// dataContext가 있으면 $parent 바인딩에서 저장한 확장 컨텍스트 사용
const parentData = parentEntry.dataContext || {};
return {
_local: parentEntry.state || parentData._local || {},
_global: parentData._global || G7Core.state.get() || {},
setState: parentEntry.setState,
};
},
/**
* 부모 레이아웃의 로컬 상태(_local)를 업데이트합니다.
*
* 모달 등 자식 레이아웃에서 부모 레이아웃의 로컬 상태를 직접 수정할 때 사용합니다.
*
* dot notation을 지원합니다.
* 예: `{ "form.name": "value" }` → `{ form: { name: "value" } }`
*
* @param pathOrUpdates 경로 문자열 또는 업데이트 객체
* @param maybeValue 경로가 지정된 경우 설정할 값
*
* @example
* ```ts
* // 객체로 업데이트
* G7Core.state.setParentLocal({
* 'form.label_assignments': updatedAssignments,
* hasChanges: true,
* });
*
* // 경로와 값으로 업데이트
* G7Core.state.setParentLocal('form.name', 'newValue');
* ```
*
* @since engine-v1.16.0
*/
setParentLocal: (pathOrUpdates: string | Record<string, any>, maybeValue?: any, options?: { merge?: 'replace' | 'shallow' | 'deep' }): void => {
const contextStack: Array<{
state: Record<string, any>;
setState: (updates: any) => void;
dataContext?: Record<string, any>;
}> = (window as any).__g7LayoutContextStack || [];
if (contextStack.length === 0) {
logger.warn('[setParentLocal] 레이아웃 컨텍스트 스택이 비어있습니다.');
return;
}
const parentEntry = contextStack[contextStack.length - 1];
if (!parentEntry?.setState) {
logger.warn('[setParentLocal] 부모 컨텍스트에 setState가 없습니다.');
return;
}
let updates: Record<string, any>;
let mergeOpts: { merge?: 'replace' | 'shallow' | 'deep' } | undefined;
if (typeof pathOrUpdates === 'string') {
// 경로와 값으로 호출된 경우
updates = { [pathOrUpdates]: maybeValue };
mergeOpts = options;
} else {
updates = pathOrUpdates;
// 객체로 호출 시 두 번째 인자가 options일 수 있음
mergeOpts = maybeValue && typeof maybeValue === 'object' && ('merge' in maybeValue) ? maybeValue : options;
}
const mergeMode = mergeOpts?.merge || 'deep';
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
// parentEntry.state가 전체 데이터 컨텍스트(_local 포함)인지 확인
// _local이 있으면 그 안의 상태를 업데이트, 없으면 state 자체가 로컬 상태
const hasNestedLocal = parentEntry.state?._local !== undefined;
const currentLocal = hasNestedLocal ? parentEntry.state._local : (parentEntry.state || {});
let merged: Record<string, any>;
if (mergeMode === 'replace') {
merged = converted;
} else if (mergeMode === 'shallow') {
merged = { ...currentLocal, ...converted };
} else {
merged = deepMerge(currentLocal, converted);
}
// CRITICAL: __g7PendingLocalState를 setState 호출 전에 설정해야 함!
// React가 setState 배치를 처리할 때 handleLocalSetState 콜백이 실행되는데,
// 이 콜백에서 __g7PendingLocalState를 읽어 최신 상태를 effectivePrev로 사용함.
// 따라서 setState 호출 전에 설정해야 콜백이 최신 값을 읽을 수 있음.
(window as any).__g7PendingLocalState = merged;
// engine-v1.17.12: 전역 _local 상태도 업데이트 (setLocal과 동일)
// setParentLocal은 parentEntry.setState()로 React 상태를 업데이트하지만,
// getLocal()은 templateApp.getGlobalState()._local에서 읽음.
// __g7PendingLocalState가 렌더 후 클리어되면 getLocal()이 stale 데이터를 반환하므로,
// globalState._local도 함께 업데이트해야 후속 getLocal() 호출이 최신 값을 반환함.
const templateApp = (window as any).__templateApp;
if (templateApp?.setGlobalState) {
templateApp.setGlobalState({ _local: merged });
}
parentEntry.setState(merged);
// 스택의 state와 dataContext._local 모두 업데이트하여
// 다음 호출 시 currentLocal이 최신 값을 읽고,
// 모달에서 $parent._local 바인딩이 최신 값을 읽도록 함
if (hasNestedLocal) {
parentEntry.state._local = merged;
} else {
parentEntry.state = merged;
}
if (parentEntry.dataContext) {
parentEntry.dataContext._local = merged;
}
// __g7ForcedLocalFields 업데이트 (dataKey 자동 바인딩 컴포넌트 충돌 방지)
// setParentLocal로 업데이트된 필드가 Form 자동 바인딩에 의해 덮어쓰이지 않도록 함
if (mergeMode === 'replace') {
(window as any).__g7ForcedLocalFields = converted;
} else {
const existingForced = (window as any).__g7ForcedLocalFields || {};
(window as any).__g7ForcedLocalFields = deepMerge(existingForced, converted);
}
// ParentContextProvider를 통해 모달만 선택적으로 리렌더링
// 전체 앱 리렌더링 없이 모달만 업데이트됨
triggerModalParentUpdate();
},
/**
* 부모 레이아웃의 전역 상태(_global)를 업데이트합니다.
*
* 모달 등 자식 레이아웃에서 전역 상태를 직접 수정할 때 사용합니다.
* 참고: 전역 상태는 앱 전체에서 공유되므로, 부모/자식 구분 없이
* G7Core.state.set()과 동일하게 동작합니다. 이 메서드는 의미적 명확성을 위해 제공됩니다.
*
* dot notation을 지원합니다.
*
* @param pathOrUpdates 경로 문자열 또는 업데이트 객체
* @param maybeValue 경로가 지정된 경우 설정할 값
*
* @example
* ```ts
* // 객체로 업데이트
* G7Core.state.setParentGlobal({
* modalResult: { confirmed: true, data: selectedItems },
* });
*
* // 경로와 값으로 업데이트
* G7Core.state.setParentGlobal('modalResult.confirmed', true);
* ```
*
* @since engine-v1.16.0
*/
setParentGlobal: (pathOrUpdates: string | Record<string, any>, maybeValue?: any): void => {
let updates: Record<string, any>;
if (typeof pathOrUpdates === 'string') {
updates = { [pathOrUpdates]: maybeValue };
} else {
updates = pathOrUpdates;
}
// 전역 상태는 앱 전체에서 공유되므로 G7Core.state.set() 사용
G7Core.state.set(updates);
logger.log('[setParentGlobal] 전역 상태 업데이트:', updates);
},
};
// 격리 상태 레지스트리 초기화
if (!(window as any).__g7IsolatedStates) {
(window as any).__g7IsolatedStates = {};
}
// 별칭 메서드 추가 (하위 호환성)
G7Core.state.getGlobal = G7Core.state.get;
G7Core.state.setGlobal = G7Core.state.set;
logger.log('전역 객체 window.G7Core.state에 노출됨');
}
/**
* 데이터 소스 관리 API 초기화
*
* 데이터 소스의 조회, 설정, refetch 등을 관리합니다.
*/
function initDataSourceAPI(G7Core: any): void {
G7Core.dataSource = {
/**
* 데이터 소스 값을 가져옵니다.
* @param dataSourceId 데이터 소스 ID
* @returns 데이터 소스 값 또는 undefined
*/
get: (dataSourceId: string) => {
const templateApp = (window as any).__templateApp;
return templateApp?.getDataSource?.(dataSourceId);
},
/**
* 데이터 소스 값을 설정하고 UI를 리렌더링합니다.
*
* 서버 refetch 없이 클라이언트 측에서 데이터 소스를 직접 업데이트할 때 사용합니다.
*
* @param dataSourceId 데이터 소스 ID
* @param data 설정할 데이터
* @param options 옵션
* @param options.merge true면 기존 데이터와 병합 (기본값: false)
* @param options.sync true면 동기 업데이트 (기본값: false)
*
* @example
* // 데이터 전체 교체
* G7Core.dataSource.set('products', { data: updatedProducts, meta: {...} });
*
* // 기존 데이터와 병합
* G7Core.dataSource.set('products', { data: updatedProducts }, { merge: true });
*/
set: (
dataSourceId: string,
data: any,
options?: { merge?: boolean; sync?: boolean }
) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.setDataSource) {
templateApp.setDataSource(dataSourceId, data, options);
} else {
logger.warn('G7Core.dataSource.set: TemplateApp이 초기화되지 않았습니다.');
}
},
/**
* 데이터 소스를 서버에서 다시 가져옵니다.
* @param dataSourceId 데이터 소스 ID
* @param options 옵션
* @returns refetch 결과 데이터
*/
refetch: async (dataSourceId: string, options?: { skipCache?: boolean; sync?: boolean }) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.refetchDataSource) {
return templateApp.refetchDataSource(dataSourceId, options);
}
logger.warn('G7Core.dataSource.refetch: TemplateApp이 초기화되지 않았습니다.');
return undefined;
},
/**
* 데이터 소스 내 특정 배열 아이템만 업데이트합니다.
*
* 전체 데이터소스를 교체하지 않고 특정 아이템만 수정하여
* 불필요한 리렌더링을 방지합니다.
*
* @param dataSourceId 데이터 소스 ID
* @param itemPath 배열 경로 (예: "data.data", "data.data[0].options")
* @param itemId 업데이트할 아이템의 ID
* @param updates 업데이트할 필드들
* @param options 옵션
* @returns 성공 여부
*
* @example
* ```typescript
* // 상품 옵션 업데이트
* G7Core.dataSource.updateItem(
* 'products',
* 'data.data[0].options',
* 123, // optionId
* { selling_price: 15000, _modified: true }
* );
*
* // 사용자 정보 업데이트 (커스텀 ID 필드 사용)
* G7Core.dataSource.updateItem(
* 'users',
* 'data',
* 'user-456',
* { name: '홍길동' },
* { idField: 'uuid' }
* );
* ```
*/
updateItem: (
dataSourceId: string,
itemPath: string,
itemId: string | number,
updates: Record<string, any>,
options?: {
/** ID 필드명 (기본: "id") */
idField?: string;
/** 깊은 병합 여부 (기본: true) */
merge?: boolean;
/** 렌더링 스킵 여부 (기본: false) */
skipRender?: boolean;
}
): boolean => {
const templateApp = (window as any).__templateApp;
if (templateApp?.updateDataSourceItem) {
return templateApp.updateDataSourceItem(dataSourceId, itemPath, itemId, updates, options);
}
logger.warn('G7Core.dataSource.updateItem: TemplateApp이 초기화되지 않았습니다.');
return false;
},
/**
* 데이터 소스 내 특정 경로의 배열에 새 데이터를 병합합니다.
*
* 무한 스크롤 등에서 기존 데이터에 새 데이터를 추가할 때 사용합니다.
*
* @param dataSourceId 데이터 소스 ID
* @param dataPath 배열 경로 (예: "data", "data.items"). null이면 루트 배열
* @param newData 추가할 새 데이터 (배열)
* @param mode 병합 모드 - 'append': 끝에 추가, 'prepend': 앞에 추가 (기본: 'append')
* @returns 성공 여부
*
* @example
* ```typescript
* // 무한 스크롤: 기존 목록 뒤에 새 데이터 추가
* G7Core.dataSource.updateData('templates', 'data', newTemplates, 'append');
*
* // 새로고침: 새 데이터를 앞에 추가
* G7Core.dataSource.updateData('notifications', 'data', newNotifications, 'prepend');
* ```
*/
updateData: (
dataSourceId: string,
dataPath: string | null,
newData: any[],
mode: 'append' | 'prepend' = 'append'
): boolean => {
const templateApp = (window as any).__templateApp;
if (!templateApp?.getDataSource || !templateApp?.setDataSource) {
logger.warn('G7Core.dataSource.updateData: TemplateApp이 초기화되지 않았습니다.');
return false;
}
// 현재 데이터 소스 가져오기
const currentDataSource = templateApp.getDataSource(dataSourceId);
if (!currentDataSource) {
logger.warn(`G7Core.dataSource.updateData: 데이터 소스 '${dataSourceId}'를 찾을 수 없습니다.`);
return false;
}
// 경로에서 대상 배열 가져오기
let targetArray: any[];
let parentObj: any = currentDataSource;
let lastKey: string | null = null;
if (dataPath) {
const pathParts = dataPath.split('.');
lastKey = pathParts.pop()!;
for (const part of pathParts) {
if (parentObj && typeof parentObj === 'object' && part in parentObj) {
parentObj = parentObj[part];
} else {
logger.warn(`G7Core.dataSource.updateData: 경로 '${dataPath}'를 찾을 수 없습니다.`);
return false;
}
}
targetArray = parentObj[lastKey];
} else {
targetArray = currentDataSource;
}
if (!Array.isArray(targetArray)) {
logger.warn(`G7Core.dataSource.updateData: 대상 경로의 데이터가 배열이 아닙니다.`);
return false;
}
if (!Array.isArray(newData)) {
logger.warn(`G7Core.dataSource.updateData: newData가 배열이 아닙니다.`);
return false;
}
// 배열 병합
const mergedArray = mode === 'prepend'
? [...newData, ...targetArray]
: [...targetArray, ...newData];
// 업데이트된 데이터 설정
if (dataPath && lastKey) {
parentObj[lastKey] = mergedArray;
templateApp.setDataSource(dataSourceId, currentDataSource, { merge: false });
} else {
templateApp.setDataSource(dataSourceId, mergedArray, { merge: false });
}
logger.log(`G7Core.dataSource.updateData: '${dataSourceId}'에 ${newData.length}개 항목 ${mode === 'prepend' ? '앞에' : '뒤에'} 추가됨`);
return true;
},
};
logger.log('전역 객체 window.G7Core.dataSource에 노출됨');
}
/**
* 로케일 관리 API 초기화
*/
function initLocaleAPI(G7Core: any, deps: G7CoreDependencies): void {
G7Core.locale = {
/**
* 현재 로케일을 반환합니다.
*/
current: () => {
const templateApp = (window as any).__templateApp;
return templateApp?.getLocale?.() || 'ko';
},
/**
* 지원하는 로케일 목록을 반환합니다.
*/
supported: () => {
const templateApp = (window as any).__templateApp;
const systemLocales = templateApp?.globalState?.appConfig?.supportedLocales;
if (Array.isArray(systemLocales) && systemLocales.length > 0) {
return systemLocales;
}
const state = deps.getState();
return state.templateMetadata?.locales || ['ko', 'en'];
},
/**
* 로케일을 변경합니다.
* @param locale 변경할 로케일 (예: 'ko', 'en')
*/
change: async (locale: string) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.changeLocale) {
await templateApp.changeLocale(locale);
} else {
logger.warn('G7Core.locale.change: TemplateApp이 초기화되지 않았습니다.');
}
},
};
logger.log('전역 객체 window.G7Core.locale에 노출됨');
}
/**
* 토스트 알림 API 초기화
*/
function initToastAPI(G7Core: any): void {
G7Core.toast = {
/**
* 토스트 알림을 표시합니다.
* @param message 표시할 메시지
* @param options type: 'success'|'error'|'warning'|'info', duration: ms
*/
show: (message: string, options?: { type?: 'success' | 'error' | 'warning' | 'info'; duration?: number }) => {
G7Core.dispatch({
handler: 'toast',
params: {
message,
type: options?.type || 'info',
...(options?.duration && { duration: options.duration }),
},
});
},
success: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'success', duration });
},
error: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'error', duration });
},
warning: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'warning', duration });
},
info: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'info', duration });
},
};
logger.log('전역 객체 window.G7Core.toast에 노출됨');
}
/**
* 모달 관리 API 초기화
*/
function initModalAPI(G7Core: any): void {
G7Core.modal = {
/**
* 모달을 엽니다.
* @param modalId 열 모달의 ID
*/
open: (modalId: string) => {
G7Core.dispatch({
handler: 'openModal',
target: modalId,
});
},
/**
* 모달을 닫습니다.
* @param modalId 닫을 모달의 ID (생략 시 최상위 모달 닫기)
*/
close: (modalId?: string) => {
G7Core.dispatch({
handler: 'closeModal',
...(modalId && { target: modalId }),
});
},
/**
* 모든 모달을 닫습니다.
*/
closeAll: () => {
G7Core.dispatch({
handler: 'closeAllModals',
});
},
/**
* 특정 모달이 열려있는지 확인합니다.
* @param modalId 확인할 모달의 ID
*/
isOpen: (modalId: string) => {
const state = G7Core.state.get();
return state._global?.activeModal === modalId ||
state._global?.modalStack?.includes(modalId) ||
false;
},
/**
* 현재 열린 모달 스택을 반환합니다.
*/
getStack: () => {
const state = G7Core.state.get();
return state._global?.modalStack || [];
},
};
logger.log('전역 객체 window.G7Core.modal에 노출됨');
}
/**
* 스타일 헬퍼 API 초기화
*/
function initStyleAPI(G7Core: any): void {
G7Core.style = {
/**
* Tailwind CSS 클래스를 런타임에서 병합합니다.
* 같은 CSS 속성을 제어하는 클래스가 충돌하면 override 클래스를 우선 적용합니다.
*
* @example
* ```typescript
* G7Core.style.mergeClasses('justify-center items-center', 'justify-between')
* // 결과: 'items-center justify-between'
* ```
*/
mergeClasses,
/**
* 조건에 따라 클래스를 적용합니다.
*
* @example
* ```typescript
* G7Core.style.conditionalClass({
* 'bg-blue-500': isPrimary,
* 'opacity-50': isDisabled,
* })
* ```
*/
conditionalClass,
/**
* 여러 클래스 문자열을 하나로 합칩니다.
* falsy 값은 무시됩니다.
*
* @example
* ```typescript
* G7Core.style.joinClasses('flex', isActive && 'bg-blue-500', 'p-4')
* ```
*/
joinClasses,
};
logger.log('전역 객체 window.G7Core.style에 노출됨');
}
/**
* WebSocket 관리 API 초기화
*/
function initWebSocketAPI(G7Core: any, deps: G7CoreDependencies): void {
const { webSocketManager } = deps;
G7Core.websocket = {
/**
* WebSocketManager 인스턴스를 반환합니다.
*/
manager: webSocketManager,
/**
* WebSocket 채널을 구독합니다.
* @param channel 채널명 (예: 'admin.dashboard')
* @param event 이벤트명 (예: 'dashboard.stats.updated')
* @param callback 이벤트 수신 시 호출될 콜백
* @param options 구독 옵션 { channelType: 'public' | 'private' | 'presence' }
* @returns 구독 키 (구독 해제 시 사용)
*/
subscribe: (
channel: string,
event: string,
callback: (data: unknown) => void,
options?: { channelType?: 'public' | 'private' | 'presence' }
) => webSocketManager.subscribe(channel, event, callback, options),
/**
* WebSocket 구독을 해제합니다.
* @param subscriptionKey 구독 키
*/
unsubscribe: (subscriptionKey: string) => webSocketManager.unsubscribe(subscriptionKey),
/**
* 특정 채널의 모든 구독을 해제합니다.
* @param channel 채널명
*/
leaveChannel: (channel: string) => webSocketManager.leaveChannel(channel),
/**
* 모든 WebSocket 연결을 종료합니다.
*/
disconnect: () => webSocketManager.disconnect(),
/**
* WebSocket 초기화 여부를 반환합니다.
*/
isInitialized: () => webSocketManager.isInitialized(),
/**
* 현재 활성 구독 수를 반환합니다.
*/
getSubscriptionCount: () => webSocketManager.getSubscriptionCount(),
};
logger.log('전역 객체 window.G7Core.websocket에 노출됨');
}
/**
* 네비게이션 API 초기화
*/
function initNavigationAPI(G7Core: any, deps: G7CoreDependencies): void {
const { transitionManager } = deps;
G7Core.navigation = {
/**
* 현재 페이지 전환이 진행 중인지 확인합니다.
* @returns 전환 진행 중이면 true
*/
isPending: () => {
return transitionManager?.getIsPending?.() || false;
},
/**
* 다음 페이지 전환이 완료되면 콜백을 실행합니다.
* 전환이 시작(isPending=true)된 후 완료(isPending=false)될 때 콜백을 실행합니다.
* @param callback 전환 완료 시 실행할 콜백
* @returns 구독 해제 함수
*/
onComplete: (callback: () => void) => {
if (!transitionManager) {
// TransitionManager가 없으면 즉시 실행
callback();
return () => {};
}
// 전환 시작 여부 추적
let transitionStarted = transitionManager.getIsPending();
// 전환 상태 변화 감지 (true → false 순서로 감지되면 콜백 실행)
const unsubscribe = transitionManager.subscribe((isPending: boolean) => {
if (isPending) {
// 전환 시작됨
transitionStarted = true;
} else if (transitionStarted) {
// 전환이 시작된 후 완료됨
callback();
unsubscribe();
}
});
return unsubscribe;
},
};
logger.log('전역 객체 window.G7Core.navigation에 노출됨');
}
/**
* 플러그인 설정 접근 API 초기화
*
* 활성화된 플러그인의 환경설정 값에 접근합니다.
* admin.blade.php에서 G7Config.plugins로 주입된 값을 사용합니다.
*
* @example
* ```ts
* // 플러그인 전체 설정 조회
* const daumSettings = G7Core.plugin.getSettings('sirsoft-daum_postcode');
* // { display_mode: 'layer', popup_width: 900, ... }
*
* // 특정 설정 값 조회
* const displayMode = G7Core.plugin.get('sirsoft-daum_postcode', 'display_mode');
* // 'layer'
*
* // 기본값과 함께 조회
* const width = G7Core.plugin.get('sirsoft-daum_postcode', 'popup_width', 500);
* ```
*/
function initPluginAPI(G7Core: any): void {
G7Core.plugin = {
/**
* 플러그인의 전체 설정을 조회합니다.
* @param identifier 플러그인 식별자
* @returns 플러그인 설정 객체 또는 undefined
*/
getSettings: (identifier: string): Record<string, any> | undefined => {
return (window as any).G7Config?.plugins?.[identifier];
},
/**
* 플러그인의 특정 설정 값을 조회합니다.
* @param identifier 플러그인 식별자
* @param key 설정 키
* @param defaultValue 기본값
* @returns 설정 값 또는 기본값
*/
get: (identifier: string, key: string, defaultValue?: any): any => {
const settings = (window as any).G7Config?.plugins?.[identifier];
return settings?.[key] ?? defaultValue;
},
/**
* 모든 활성화된 플러그인 설정을 조회합니다.
* @returns 플러그인 식별자를 키로 하는 설정 객체
*/
getAll: (): Record<string, Record<string, any>> => {
return (window as any).G7Config?.plugins ?? {};
},
};
logger.log('전역 객체 window.G7Core.plugin에 노출됨');
}
/**
* 모듈 설정 접근 API 초기화
*
* 활성화된 모듈의 환경설정 값에 접근합니다.
* admin.blade.php에서 G7Config.modules로 주입된 값을 사용합니다.
*
* @example
* ```ts
* // 모듈 전체 설정 조회
* const ecommerceSettings = G7Core.module.getSettings('sirsoft-ecommerce');
*
* // 특정 설정 값 조회
* const currency = G7Core.module.get('sirsoft-ecommerce', 'default_currency', 'KRW');
* ```
*/
function initModuleAPI(G7Core: any): void {
G7Core.module = {
/**
* 모듈의 전체 설정을 조회합니다.
* @param identifier 모듈 식별자
* @returns 모듈 설정 객체 또는 undefined
*/
getSettings: (identifier: string): Record<string, any> | undefined => {
return (window as any).G7Config?.modules?.[identifier];
},
/**
* 모듈의 특정 설정 값을 조회합니다.
* @param identifier 모듈 식별자
* @param key 설정 키
* @param defaultValue 기본값
* @returns 설정 값 또는 기본값
*/
get: (identifier: string, key: string, defaultValue?: any): any => {
const settings = (window as any).G7Config?.modules?.[identifier];
return settings?.[key] ?? defaultValue;
},
/**
* 모든 활성화된 모듈 설정을 조회합니다.
* @returns 모듈 식별자를 키로 하는 설정 객체
*/
getAll: (): Record<string, Record<string, any>> => {
return (window as any).G7Config?.modules ?? {};
},
};
logger.log('전역 객체 window.G7Core.module에 노출됨');
}
/**
* 코어 API 초기화 (AuthManager, ApiClient, 훅 등)
*/
function initCoreAPIs(G7Core: any, deps: G7CoreDependencies): void {
const { transitionManager, responsiveManager } = deps;
// AuthManager 노출 (템플릿 컴포넌트에서 사용자 정보 접근용)
G7Core.AuthManager = AuthManager;
// ApiClient 노출 (템플릿 컴포넌트에서 API 호출용)
G7Core.api = getApiClient();
// TransitionManager 노출 (템플릿 컴포넌트에서 페이지 전환 상태 접근용)
// 동기적으로 노출하여 컴포넌트 마운트 시점에 바로 사용 가능하도록 함
G7Core.TransitionManager = transitionManager;
// useTransitionState 훅 노출 (템플릿 컴포넌트에서 전환 상태 구독용)
G7Core.useTransitionState = useTransitionState;
// ResponsiveManager 노출 (템플릿 컴포넌트에서 반응형 상태 접근용)
G7Core.ResponsiveManager = responsiveManager;
// useResponsive 훅 노출 (템플릿 컴포넌트에서 반응형 상태 구독용)
G7Core.useResponsive = useResponsive;
// useControllableState 훅 노출 (Phase 1-4)
// Controlled/Uncontrolled 상태 패턴을 단순화하는 훅
G7Core.useControllableState = useControllableState;
G7Core.shallowArrayEqual = shallowArrayEqual;
G7Core.shallowObjectEqual = shallowObjectEqual;
// updateQueryParams 노출 (같은 페이지 내 쿼리 변경 시 컴포넌트 리마운트 없이 데이터 갱신)
// navigate 핸들러에서 replace: true 사용 시 자동 호출됨
// options.transitionOverlayTarget: 호출별 transition_overlay.target 동적 override (@since engine-v1.36.0)
G7Core.updateQueryParams = async (
newPath: string,
options?: { transitionOverlayTarget?: string }
) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.updateQueryParams) {
return templateApp.updateQueryParams(newPath, options);
}
logger.warn('G7Core.updateQueryParams: TemplateApp이 초기화되지 않았습니다.');
};
logger.log('전역 객체 window.G7Core.AuthManager에 노출됨');
logger.log('전역 객체 window.G7Core.api에 노출됨');
logger.log('전역 객체 window.G7Core.ResponsiveManager에 노출됨');
logger.log('전역 객체 window.G7Core.useResponsive에 노출됨');
logger.log('전역 객체 window.G7Core.useControllableState에 노출됨');
logger.log('전역 객체 window.G7Core.updateQueryParams에 노출됨');
}
/**
* 슬롯 시스템 API 초기화
*
* SlotContext와 DynamicRenderer를 외부(템플릿 컴포넌트)에서 접근할 수 있도록 합니다.
* SlotContainer 컴포넌트에서 이 API를 사용하여 슬롯에 등록된 컴포넌트를 렌더링합니다.
*
* @example
* ```ts
* // SlotContainer.tsx에서 사용
* const slotContext = G7Core.getSlotContext();
* const DynamicRenderer = G7Core.getDynamicRenderer();
* const registry = G7Core.getComponentRegistry();
* ```
*/
function initSlotAPI(G7Core: any, deps: G7CoreDependencies): void {
/**
* SlotContext 값을 가져옵니다.
* SlotProvider 내부에서만 유효한 값을 반환합니다.
*
* SlotProvider가 마운트되면 window.__slotContextValue에 값이 설정됩니다.
*/
G7Core.getSlotContext = () => {
// SlotProvider에서 설정한 전역 값 참조
return (window as any).__slotContextValue ?? null;
};
/**
* DynamicRenderer 컴포넌트를 가져옵니다.
* SlotContainer에서 슬롯에 등록된 컴포넌트를 렌더링할 때 사용합니다.
*
* DynamicRenderer가 로드되면 window.__DynamicRenderer에 값이 설정됩니다.
*/
G7Core.getDynamicRenderer = () => {
// DynamicRenderer 모듈에서 설정한 전역 값 참조
return (window as any).__DynamicRenderer ?? null;
};
/**
* ComponentRegistry를 가져옵니다.
*/
G7Core.getComponentRegistry = () => {
return deps.getComponentRegistry?.() ?? ComponentRegistry.getInstance();
};
/**
* DataBindingEngine을 가져옵니다.
*/
G7Core.getDataBindingEngine = () => {
return deps.getDataBindingEngine?.() ?? deps.getState().bindingEngine;
};
/**
* TranslationEngine을 가져옵니다.
*/
G7Core.getTranslationEngine = () => {
return deps.getTranslationEngine?.() ?? deps.getState().translationEngine;
};
/**
* ActionDispatcher를 가져옵니다.
*/
G7Core.getActionDispatcher = () => {
return deps.getActionDispatcher?.() ?? deps.getState().actionDispatcher;
};
logger.log('전역 객체 window.G7Core 슬롯 API에 노출됨 (getSlotContext, getDynamicRenderer 등)');
}
/**
* G7Core.identity 인터페이스 초기화 (engine-v1.46.0+)
*
* 본인인증(IDV) 인터셉터를 템플릿/플러그인이 동일 인스턴스로 공유하기 위한 글로벌 진입점입니다.
* 템플릿 번들이 IIFE 로 빌드되면서 코어 모듈을 외부화하지 않으므로, 직접 import 대신
* 이 namespace 를 통해 코어의 단일 IdentityGuardInterceptor 정적 클래스에 접근해야 합니다.
*
* 노출되는 진입점:
* - `setLauncher(launcher)` — 템플릿 부트스트랩에서 모달 launcher 등록
* - `redirectExternally(payload)` — external_redirect 흐름 helper
* - `createDeferred()` / `resolveDeferred(result)` — launcher 가 모달 결과를 await 하기 위한 deferred
* - `hasLauncher()` / `reset()` — 진단/테스트용
* - `redirectStashKey` — sessionStorage stash 키 상수
*
* @param G7Core - window.G7Core 네임스페이스
*/
function initIdentityAPI(G7Core: any): void {
G7Core.identity = {
/** 모달 launcher 등록 (템플릿 부트스트랩에서 호출) */
setLauncher: IdentityGuardInterceptor.setLauncher.bind(IdentityGuardInterceptor),
/**
* 428 응답을 처리합니다 (challenge 모달 launcher 호출 후 성공 시 원 요청 재실행).
*
* ActionDispatcher.handleApiCall 의 자동 인터셉트와 동일한 launcher 플로우를,
* 직접 fetch/G7Core.api 로 요청하는 모듈 JS 핸들러(예: 입금확인)가 재사용하기 위한 진입점.
* verify 성공 시 재실행 Response 를, 취소/실패/return_request 부재 시 null 을 반환한다.
*/
handle: IdentityGuardInterceptor.handle.bind(IdentityGuardInterceptor),
/** 응답이 428 IDV 요구 응답인지 판별 */
isIdentityRequired: IdentityGuardInterceptor.isIdentityRequired.bind(IdentityGuardInterceptor),
/** 등록된 launcher 가 있는지 여부 */
hasLauncher: IdentityGuardInterceptor.hasLauncher.bind(IdentityGuardInterceptor),
/**
* challenge 가 자기 고유의 도메인 안내(성인인증 실패 등)를 사용자에게 표출했음을 표시.
* 호출 시 코어 toast 핸들러가 동일 사이클의 generic IDV 가드 토스트("본인 확인이 필요합니다") 1건을 skip.
* provider 플러그인이 "본인확인 성공 + 부가목적 미달" 실패를 안내한 직후 호출한다.
*/
markDomainNoticeShown: IdentityGuardInterceptor.markDomainNoticeShown.bind(IdentityGuardInterceptor),
/** external_redirect 흐름 — sessionStorage stash + window.location 이동 */
redirectExternally: IdentityGuardInterceptor.redirectExternally.bind(IdentityGuardInterceptor),
/** launcher 가 모달 결과를 await 하기 위한 deferred Promise 생성 */
createDeferred: IdentityGuardInterceptor.createDeferred.bind(IdentityGuardInterceptor),
/** 모달/풀페이지가 verify 결과를 deferred resolver 에 통보 (resolveIdentityChallenge 핸들러 내부에서 사용) */
resolveDeferred: IdentityGuardInterceptor.resolveDeferred.bind(IdentityGuardInterceptor),
/** 테스트/진단용 — launcher 와 deferred 슬롯 초기화 */
reset: IdentityGuardInterceptor.reset.bind(IdentityGuardInterceptor),
/** sessionStorage stash 키 상수 */
redirectStashKey: IDENTITY_REDIRECT_STASH_KEY,
};
logger.log('전역 객체 window.G7Core 본인인증(IDV) API에 노출됨 (identity)');
}
/**
* G7Core 전역 객체를 초기화합니다.
*
* 템플릿 컴포넌트에서 사용할 수 있는 모든 전역 API를 window.G7Core에 노출합니다.
*
* @param deps - 초기화에 필요한 의존성 객체
*/
export function initializeG7CoreGlobals(deps: G7CoreDependencies): void {
if (typeof window === 'undefined') {
return;
}
// React 전역 노출
initReactGlobals();
// G7Core 네임스페이스가 없으면 생성 (TemplateApp.ts에서 이미 생성했을 수 있음)
if (!(window as any).G7Core) {
(window as any).G7Core = {};
}
const G7Core = (window as any).G7Core;
// 코어 런타임 표면 노출 (편집기 lazy 번들이 __runtime 재export shim 으로 공유)
initCoreRuntimeExports(G7Core);
// 각 API 그룹 초기화
initComponentEventSystem(G7Core);
// 레이아웃 편집기 확장점 예약 접수함(편집기 lazy 로드 전 템플릿 등록을 큐에 보존)
initLayoutEditorStub(G7Core);
initCoreAPIs(G7Core, deps);
initTranslationAPI(G7Core, deps);
initHelperAPIs(G7Core, deps);
initDispatchAPI(G7Core);
initStateAPI(G7Core);
initDataSourceAPI(G7Core);
initLocaleAPI(G7Core, deps);
initToastAPI(G7Core);
initModalAPI(G7Core);
initStyleAPI(G7Core);
initWebSocketAPI(G7Core, deps);
initNavigationAPI(G7Core, deps);
initPluginAPI(G7Core);
initModuleAPI(G7Core);
initSlotAPI(G7Core, deps);
initIdentityAPI(G7Core);
logger.log('G7Core 전역 객체 초기화 완료');
// DevTools API는 initTemplateApp 이후에 초기화됨 (debug 옵션 확인 필요)
// initDevToolsAPI는 reinitializeDevTools()를 통해 호출됨
}
/**
* G7Core.devTools 인터페이스 초기화
*
* G7DevToolsCore.getInstance() 직접 호출 대신 통합 인터페이스를 통해 추적 기능에 접근합니다.
* 템플릿 컴포넌트, 헬퍼 함수, 모듈에서 일관된 방식으로 DevTools에 접근할 수 있습니다.
*
* @example
* ```ts
* // renderItemChildren 등에서 사용
* const G7Core = (window as any).G7Core;
* if (G7Core?.devTools?.isEnabled()) {
* G7Core.devTools.trackIteration(id, source, itemVar, indexVar, itemCount);
* }
* ```
*/
export function initDevToolsInterface(): void {
const G7Core = (window as any).G7Core;
if (!G7Core) {
logger.warn('G7Core가 초기화되지 않았습니다.');
return;
}
const getDevTools = () => G7DevToolsCore.getInstance();
const devToolsInterface: G7DevToolsInterface = {
isEnabled: () => {
try {
return getDevTools().isEnabled();
} catch {
return false;
}
},
trackRender: (componentName: string) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackRender(componentName);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackIteration: (id, source, itemVar, indexVar, itemCount) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackIteration(id, {
source,
itemVar,
indexVar,
sourceLength: itemCount,
});
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackIfCondition: (id, condition, result, componentName) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackIfCondition(id, condition, result);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackMount: (componentId, info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackMount(componentId, { ...info, id: componentId });
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackUnmount: (componentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackUnmount(componentId);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackExpressionEval: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackExpressionEval(info);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackBindingEval: (expression?: string) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackBindingEval(expression);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
recordCacheHit: () => {
try {
if (getDevTools().isEnabled()) {
getDevTools().recordCacheHit();
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
recordCacheMiss: () => {
try {
if (getDevTools().isEnabled()) {
getDevTools().recordCacheMiss();
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackHandlerRegistration: (name, category, description, source) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackHandlerRegistration(name, category, description, source);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackHandlerUnregistration: (name) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackHandlerUnregistration(name);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
logAction: (action) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().logAction(action);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackRequest: (url, method) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().trackRequest(url, method);
}
return '';
} catch (e) {
return '';
}
},
completeRequest: (requestId, statusCode, response) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().completeRequest(requestId, statusCode, response);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
failRequest: (requestId, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().failRequest(requestId, error);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceDefinition: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceDefinition(info);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceLoading: (id) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceLoading(id);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceLoaded: (id, data, dataPath) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceLoaded(id, data, dataPath);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceError: (id, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceError(id, error);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackForm: (id, dataKey, inputs) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackForm(id, dataKey, inputs);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
untrackForm: (id) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().untrackForm(id);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
startStateChange: (statePath, oldValue, newValue, trigger) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().startStateChange(statePath, oldValue, newValue, trigger);
}
return '';
} catch (e) {
return '';
}
},
completeStateChange: (setStateId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().completeStateChange(setStateId);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackComponentRender: (componentId, componentName, renderDuration, accessedStatePaths, evaluatedBindings, parentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComponentRender(
componentId,
componentName,
renderDuration,
accessedStatePaths,
evaluatedBindings,
parentId
);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
updateLocalState: (localState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().updateLocalState(localState);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
updateComputedState: (computedState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().updateComputedState(computedState);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
updateParentContext: (parentContext) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().updateParentContext(parentContext);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackComponentStateSource: (componentId, componentName, stateSource, stateProvider) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComponentStateSource(componentId, componentName, stateSource, stateProvider);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDynamicState: (componentId, dynamicState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDynamicState(componentId, dynamicState);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackContextFlow: (componentId, componentName, contextReceived, passedToChildren, usedInRender, parentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackContextFlow(componentId, componentName, contextReceived, passedToChildren, usedInRender, parentId);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackComponentStyle: (componentId, componentName, classes, computedStyles) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComponentStyle(componentId, componentName, classes, computedStyles);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackAuthEvent: (type, success, error, details) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackAuthEvent(type, success, error, details);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackAuthHeader: (url, hasAuthHeader, headerType, tokenValid, responseStatus) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackAuthHeader(url, hasAuthHeader, headerType, tokenValid, responseStatus);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackLog: (level, prefix, args) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackLog(level, prefix, args);
}
} catch {
// DevTools 추적 실패해도 로깅은 계속
}
},
trackAction: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackAction?.(info);
}
} catch (e) {
// DevTools 추적 실패해도 액션 실행은 계속
}
},
trackDataSourceUpdate: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceUpdate?.(info);
}
} catch (e) {
// DevTools 추적 실패해도 업데이트는 계속
}
},
// ============================================
// Sequence 실행 추적 메서드
// ============================================
startSequenceExecution: (trigger) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().startSequenceExecution(trigger);
}
return '';
} catch (e) {
return '';
}
},
captureSequenceActionBefore: (sequenceId, actionIndex, handler, params, currentState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().captureSequenceActionBefore(sequenceId, actionIndex, handler, params, currentState);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
captureSequenceActionAfter: (sequenceId, actionIndex, currentState, duration, result, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().captureSequenceActionAfter(sequenceId, actionIndex, currentState, duration, result, error);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
endSequenceExecution: (sequenceId, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().endSequenceExecution(sequenceId, error);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Stale Closure 감지 메서드
// ============================================
registerStateCaptureForHandler: (handlerId, statePaths, stateValues) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().registerStateCaptureForHandler(handlerId, statePaths, stateValues);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
detectStaleClosure: (handlerId, location, currentState, warningType, actionId) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().detectStaleClosure(handlerId, location, currentState, warningType, actionId);
}
return [];
} catch (e) {
return [];
}
},
trackStaleClosureWarning: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackStaleClosureWarning(info);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Modal State Scope 추적 메서드
// ============================================
trackModalOpen: (options) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackModalOpen(options);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackModalClose: (modalId, finalState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackModalClose(modalId, finalState);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackModalStateChange: (options) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackModalStateChange(options);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Nested Context 추적 메서드
// ============================================
trackNestedContext: (info) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().trackNestedContext(info);
}
return '';
} catch (e) {
return '';
}
},
trackNestedContextAccess: (contextId, access) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackNestedContextAccess(contextId, access);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Computed 추적 메서드
// ============================================
trackComputedProperty: (name, expression, dependencies, value, computationTime, componentId, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComputedProperty(name, expression, dependencies as any, value, computationTime, componentId, error);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackComputedRecalc: (computedName, trigger, previousValue, newValue, computationTime, triggeredBy, componentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComputedRecalc(computedName, trigger as any, previousValue, newValue, computationTime, triggeredBy as any, componentId);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
setNamedActionDefinitions: (definitions) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().setNamedActionDefinitions(definitions);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackNamedActionRef: (log) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackNamedActionRef(log);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
};
// G7Core.devTools에 인터페이스 할당
G7Core.devTools = devToolsInterface;
logger.log('G7Core.devTools 인터페이스 초기화 완료');
}
/**
* G7 DevTools API 초기화
*
* 디버깅 및 개발 도구 API를 window.G7DevTools에 노출합니다.
* 환경설정의 debug_mode가 활성화된 경우에만 전체 기능이 활성화됩니다.
*
* initTemplateApp에서 debug 옵션 설정 후 호출해야 합니다.
*
* @example
* ```ts
* // 상태 조회
* G7DevTools.state.get()
*
* // 액션 이력 조회
* G7DevTools.actions.getHistory()
*
* // 자동 진단
* G7DevTools.diagnose.analyze(['이전 값이 전송됨'])
*
* // 서버로 상태 덤프
* G7DevTools.server.dumpState()
* ```
*/
/**
* DevTools 디버그 전용 모듈(패널 UI/진단엔진/서버커넥터/스타일추적기)의 lazy 번들 타입.
* @since engine-v1.51.0
*/
interface DevToolsBundle {
DiagnosticEngine: any;
getServerConnector: () => any;
getStyleTracker: () => any;
DevToolsPanel: any;
}
let devToolsBundleLoadPromise: Promise<DevToolsBundle> | null = null;
/**
* DevTools lazy 번들(devtools.min.js)을 로드하고 디버그 전용 모듈 4종을 반환한다.
*
* @since engine-v1.51.0
*
* 디버그 모드에서만 호출된다. 이미 로드됨(멱등)/진행 중(in-flight 병합)/미로드(1회 주입)
* 를 구분한다.
*
* @returns DevTools 디버그 전용 모듈 묶음
* @throws {Error} 스크립트 로드 실패 또는 모듈 미노출 시
*/
export function loadDevToolsBundle(): Promise<DevToolsBundle> {
const G7Core = (window as any).G7Core;
if (G7Core?.__devtools) {
return Promise.resolve(G7Core.__devtools as DevToolsBundle);
}
if (devToolsBundleLoadPromise) {
return devToolsBundleLoadPromise;
}
devToolsBundleLoadPromise = new Promise<DevToolsBundle>((resolve, reject) => {
const src = (window as any).G7Config?.coreDevToolsAsset || '/build/core/devtools.min.js';
const scriptId = 'g7-devtools-bundle';
const onReady = () => {
const bundle = (window as any).G7Core?.__devtools;
if (bundle) {
resolve(bundle as DevToolsBundle);
} else {
reject(new Error('DevTools 번들 로드됨 — 그러나 __devtools 미노출'));
}
};
(window as any).G7Core = (window as any).G7Core || {};
(window as any).G7Core.__onDevToolsReady = onReady;
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener('load', onReady, { once: true });
existing.addEventListener(
'error',
() => reject(new Error(`DevTools 번들 로드 실패: ${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',
() => {
devToolsBundleLoadPromise = null;
reject(new Error(`DevTools 번들 로드 실패: ${src}`));
},
{ once: true }
);
document.head.appendChild(script);
});
return devToolsBundleLoadPromise;
}
export function initDevToolsAPI(): void {
const G7Core = (window as any).G7Core;
if (!G7Core) {
logger.warn('G7Core가 초기화되지 않았습니다.');
return;
}
const devToolsCore = G7DevToolsCore.getInstance();
devToolsCore.initialize();
// DevTools가 비활성화된 경우 최소 API만 노출 (devtools.min.js 미로드 — 초기 payload 절감)
if (!devToolsCore.isEnabled()) {
(window as any).G7DevTools = {
isEnabled: () => false,
enable: () => {
logger.log('환경설정 > 고급 설정 > 디버그 모드를 켜세요');
},
};
// 비활성화 상태에서도 버퍼 정리 (보안)
flushEarlyLogs();
logger.log('G7DevTools 비활성화됨 (디버그 모드 꺼짐)');
return;
}
// 디버그 모드 — DevTools lazy 번들 로드 후 전체 API 활성화 (fire-and-forget)
void setupDevToolsWhenEnabled(G7Core, devToolsCore);
}
/**
* 디버그 모드에서 DevTools lazy 번들을 로드하고 전체 API 를 활성화한다.
*
* @since engine-v1.51.0
*
* @param G7Core - window.G7Core 전역 객체
* @param devToolsCore - G7DevToolsCore 싱글톤 인스턴스
*/
async function setupDevToolsWhenEnabled(G7Core: any, devToolsCore: any): Promise<void> {
let bundle: DevToolsBundle;
try {
bundle = await loadDevToolsBundle();
} catch (error) {
logger.warn('DevTools 번들 로드 실패 — 최소 API 로 폴백:', error);
(window as any).G7DevTools = { isEnabled: () => false, enable: () => {} };
flushEarlyLogs();
return;
}
const { DiagnosticEngine, getServerConnector, getStyleTracker } = bundle;
// G7Core.devTools 인터페이스 초기화 (renderItemChildren 등에서 사용)
initDevToolsInterface();
// 초기화 전에 버퍼링된 에러/경고 로그 flush
flushEarlyLogs();
// StyleTracker 활성화 (DOM 스타일 이상 감지)
try {
const styleTracker = getStyleTracker();
styleTracker.enable();
logger.log('StyleTracker 활성화됨');
} catch (error) {
logger.warn('StyleTracker 활성화 실패:', error);
}
const diagnosticEngine = new DiagnosticEngine();
const serverConnector = getServerConnector();
const G7DevTools = {
// === 활성화 상태 ===
isEnabled: () => devToolsCore.isEnabled(),
// === 상태 관리 ===
state: {
/**
* 현재 상태를 조회합니다.
* @returns { _global, _local, _computed }
*/
get: () => devToolsCore.getState(),
/**
* 상태 변경 이력을 조회합니다.
* @returns StateSnapshot[]
*/
getHistory: () => devToolsCore.getStateHistory(),
/**
* 상태 변경을 구독합니다.
* @param path 감시할 경로 ('*'는 모든 변경)
* @param callback 변경 시 호출될 콜백
* @returns 구독 해제 함수
*/
watch: (path: string, callback: (newVal: any, oldVal: any) => void) =>
devToolsCore.watchState(path, callback),
},
// === 액션 추적 ===
actions: {
/**
* 액션 실행 이력을 조회합니다.
* @returns ActionLog[]
*/
getHistory: () => devToolsCore.getActionHistory(),
/**
* 액션 실행을 구독합니다.
* @param callback 액션 실행 시 호출될 콜백
* @returns 구독 해제 함수
*/
watch: (callback: (action: any) => void) => devToolsCore.watchActions(callback),
/**
* 액션 실행 메트릭을 조회합니다.
* @returns ActionMetrics
*/
getMetrics: () => devToolsCore.getActionMetrics(),
},
// === 바인딩 & 캐시 ===
binding: {
/**
* 표현식을 평가합니다.
* @param expr 평가할 표현식
* @returns 평가 결과
*/
evaluate: (expr: string) => {
const engine = G7Core.getDataBindingEngine?.();
if (!engine) {
logger.warn('DataBindingEngine이 없습니다.');
return undefined;
}
return engine.evaluateExpression(expr, devToolsCore.getState());
},
/**
* 캐시 통계를 조회합니다.
* @returns CacheStats
*/
getCacheStats: () => devToolsCore.getCacheStats(),
/**
* 캐시 통계를 초기화합니다.
*/
clearStats: () => {
devToolsCore.resetCacheStats();
logger.log('캐시 통계 초기화됨');
},
},
// === 자동 진단 ===
diagnose: {
/**
* 증상을 기반으로 문제를 진단합니다.
* @param symptoms 증상 배열
* @returns DiagnosisResult[]
*/
analyze: (symptoms: string[]) => diagnosticEngine.analyze(symptoms),
/**
* 진단 결과에 대한 수정 제안을 반환합니다.
* @param diagnosis 진단 결과
* @returns FixSuggestion
*/
suggestFix: (diagnosis: any) => diagnosticEngine.suggestFix(diagnosis),
/**
* 자주 발생하는 문제 목록을 반환합니다.
* @returns CommonIssue[]
*/
getCommonIssues: () => diagnosticEngine.getCommonIssues(),
/**
* 카테고리별 진단 규칙을 조회합니다.
* @param category 카테고리
* @returns DiagnosticRule[]
*/
getRulesByCategory: (category: DiagnosticCategory) => diagnosticEngine.getRulesByCategory(category),
},
// === 서버 통합 ===
server: {
/**
* 현재 상태를 서버로 덤프합니다.
* @param saveHistory 이력 파일도 저장할지 여부
* @returns 저장된 파일 경로
*/
dumpState: (saveHistory?: boolean) => serverConnector.dumpState(saveHistory),
/**
* 디버그 로그를 서버로 전송합니다.
* @param data 로그 데이터
*/
sendLog: (data: any) => serverConnector.sendLog(data),
/**
* 에러 정보를 서버로 전송합니다.
* @param error 에러 객체
* @param context 추가 컨텍스트
*/
sendError: (error: Error, context?: Record<string, any>) =>
serverConnector.sendError(error, context),
/**
* 서버 연결 상태를 확인합니다.
* @returns 연결 상태
*/
isConnected: () => serverConnector.isConnected(),
/**
* 서버 연결을 테스트합니다.
* @returns 연결 성공 여부
*/
testConnection: () => serverConnector.testConnection(),
},
// === 설정 ===
config: {
/**
* 디버그 모드 상태를 확인합니다.
* @returns 디버그 모드 활성화 여부
*/
isDebugMode: () => devToolsCore.isEnabled(),
/**
* 로그 레벨을 설정합니다.
* @param level 로그 레벨
*/
setLogLevel: (level: 'debug' | 'info' | 'warn' | 'error') =>
devToolsCore.setLogLevel(level),
/**
* 최대 이력 개수를 설정합니다.
* @param count 최대 이력 개수
*/
setMaxHistory: (count: number) => devToolsCore.setMaxHistory(count),
},
// === 라이프사이클 ===
lifecycle: {
/**
* 마운트된 컴포넌트 목록을 조회합니다.
* @returns ComponentInfo[]
*/
getMountedComponents: () => devToolsCore.getLifecycleInfo().mountedComponents,
/**
* 정리되지 않은 이벤트 리스너를 조회합니다.
* @returns ListenerInfo[]
*/
getOrphanedListeners: () => devToolsCore.getLifecycleInfo().orphanedListeners,
},
// === 성능 ===
performance: {
/**
* 렌더링 횟수를 조회합니다.
* @returns Map<string, number>
*/
getRenderCount: () => devToolsCore.getPerformanceInfo().renderCounts,
/**
* 바인딩 평가 횟수를 조회합니다.
* @returns number
*/
getBindingEvalCount: () => devToolsCore.getPerformanceInfo().bindingEvalCount,
/**
* 메모리 경고를 조회합니다.
* @returns MemoryWarning[]
*/
getMemoryWarnings: () => devToolsCore.getPerformanceInfo().memoryWarnings,
/**
* 프로파일링을 시작합니다.
*/
startProfiling: () => devToolsCore.startProfiling(),
/**
* 프로파일링을 중지하고 결과를 반환합니다.
* @returns ProfileReport
*/
stopProfiling: () => devToolsCore.stopProfiling(),
},
// === 네트워크 ===
network: {
/**
* 진행 중인 요청을 조회합니다.
* @returns RequestInfo[]
*/
getActiveRequests: () => devToolsCore.getNetworkInfo().activeRequests,
/**
* 요청 이력을 조회합니다.
* @returns RequestLog[]
*/
getRequestHistory: () => devToolsCore.getNetworkInfo().requestHistory,
/**
* 대기 중인 데이터소스를 조회합니다.
* @returns string[]
*/
getPendingDataSources: () => devToolsCore.getNetworkInfo().pendingDataSources,
},
// === 조건부 렌더링 ===
conditional: {
/**
* if 조건 목록을 조회합니다.
* @returns ConditionInfo[]
*/
getIfConditions: () => devToolsCore.getConditionalInfo().ifConditions,
/**
* iteration 목록을 조회합니다.
* @returns IterationInfo[]
*/
getIterations: () => devToolsCore.getConditionalInfo().iterations,
},
// === Form ===
form: {
/**
* 추적된 Form 목록을 조회합니다.
* @returns FormInfo[]
*/
getForms: () => devToolsCore.getFormInfo(),
},
// === WebSocket ===
websocket: {
/**
* WebSocket 정보를 조회합니다.
* @returns WebSocketInfo
*/
getInfo: () => devToolsCore.getWebSocketInfo(),
},
};
// 전역 노출
(window as any).G7DevTools = G7DevTools;
// G7Core에도 DevTools 참조 추가
G7Core.devtools = G7DevTools;
logger.log('G7DevTools 전역 객체 초기화 완료 (window.G7DevTools)');
// DevToolsPanel UI 렌더링 (lazy 번들에서 로드한 컴포넌트 전달)
renderDevToolsPanel(bundle.DevToolsPanel);
}
/**
* DevToolsPanel UI를 DOM에 렌더링합니다.
*
* 별도의 DOM 컨테이너를 생성하여 메인 앱과 독립적으로 렌더링합니다.
* 디버그 모드가 활성화된 경우에만 렌더링됩니다.
*
* @param DevToolsPanel - lazy 번들(devtools.min.js)에서 로드한 패널 컴포넌트
*/
function renderDevToolsPanel(DevToolsPanel: any): void {
// 이미 렌더링되어 있으면 스킵
if (document.getElementById('g7-devtools-root')) {
logger.log('DevToolsPanel 이미 렌더링됨');
return;
}
// DOM 컨테이너 생성
const container = document.createElement('div');
container.id = 'g7-devtools-root';
document.body.appendChild(container);
// React root 생성 및 DevToolsPanel 렌더링
try {
const root = ReactDOM.createRoot(container);
root.render(React.createElement(DevToolsPanel));
logger.log('DevToolsPanel UI 렌더링 완료');
} catch (error) {
logger.error('DevToolsPanel UI 렌더링 실패:', error);
}
}