자동바인딩은 키입력마다 __g7PendingLocalState 에 저장소 A 기반 전체 스냅샷을 대입해 왔다. 그 base(parentFormContext.state)는 extendedDataContext useMemo 의 결과라, selfManaged 플러그인이 저장소 B 에만 쓴 뒤 memo 가 재계산되지 않은 구간에서는 편집 이전 스냅샷으로 고정된다. 이어지는 setLocal 이 currentSnapshot = pendingState || baseLocal 로 그것을 채택하면 저장소 B 가 통째 교체되어 편집분이 사라진다. pending 은 getLocal 이 읽는 "화면과 같은 전체 스냅샷" 이므로, 렌더러가 _local 을 만드는 순서(dataContext._local → dynamicState → __g7ForcedLocalFields)를 그대로 따르게 합성한다. 방금 입력한 경로는 마지막에 다시 얹어 오버레이의 직전 값이 입력을 되돌리지 못하게 했다. 저장소 A 경로와 setLocal 의 base 우선순위는 건드리지 않았다. 전자는 2026-04-22 에 로그인 폼 email 손실로 철회된 자리이고, 후자는 _localInit 이 초기 데이터를 pending 에만 싣는 구간을 깨뜨린다. engine-v1.63.3이 저장 클릭 경로를 고쳤다면 이번 수정은 그 앞의 키입력 경로를 고친다. 성립 조건에는 memo deps 와 무관한 리렌더가 선행해야 하며, 리사이즈 없는 대조군은 수정 전에도 정상이다(브라우저 실측). 트러블슈팅 246 사례 전수 판정: 영향 있음 0 · 개선 3 · 불변 10.
4315 lines
187 KiB
TypeScript
4315 lines
187 KiB
TypeScript
/**
|
|
* NOTE: @since versions (engine-v1.x.x) refer to internal template engine
|
|
* development iterations, not G7 platform release versions.
|
|
*/
|
|
/**
|
|
* DynamicRenderer.tsx
|
|
*
|
|
* 그누보드7 템플릿 엔진의 동적 렌더링 컴포넌트
|
|
*
|
|
* 주요 기능:
|
|
* - 재귀적 컴포넌트 렌더링 (children 지원)
|
|
* - 반복 렌더링 (iteration 속성)
|
|
* - 조건부 렌더링 (if 속성)
|
|
* - ComponentRegistry 통합
|
|
* - DataBindingEngine 통합
|
|
* - TranslationEngine 통합
|
|
* - ActionDispatcher 통합
|
|
* - React.memo를 통한 성능 최적화
|
|
* - ErrorBoundary를 통한 컴포넌트 레벨 에러 처리
|
|
*/
|
|
|
|
import React, { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef, Component, ErrorInfo } from 'react';
|
|
import { ComponentRegistry } from './ComponentRegistry';
|
|
import { DataBindingEngine } from './DataBindingEngine';
|
|
import { TranslationEngine, TranslationContext } from './TranslationEngine';
|
|
import { ActionDispatcher, ActionDefinition } from './ActionDispatcher';
|
|
import { resolveIterationSource, evaluateIfCondition, evaluateRenderCondition, bindComponentActions, resolveClassMap } from './helpers/RenderHelpers';
|
|
import { hasPipes } from './PipeRegistry';
|
|
import {
|
|
extractSingleBinding as canonicalExtractSingleBinding,
|
|
isComplexExpression as canonicalIsComplexExpression,
|
|
} from './BindingShape';
|
|
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from './rawMarkers';
|
|
import type { ConditionsProperty } from './helpers/ConditionEvaluator';
|
|
import { useTransitionState } from './TransitionContext';
|
|
import { getLocalInitTracking, markLocalInitConsumed, resolveLocalInitAction } from './localInitSlot';
|
|
import { useResponsive } from './ResponsiveContext';
|
|
import { createLogger } from '../utils/Logger';
|
|
import { shallowObjectEqual } from '../hooks/useControllableState';
|
|
import type { G7DevToolsInterface } from './G7CoreGlobals';
|
|
|
|
const logger = createLogger('DynamicRenderer');
|
|
|
|
/**
|
|
* G7Core.devTools 인터페이스 가져오기
|
|
*
|
|
* G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
|
* DevTools가 비활성화되거나 초기화되지 않은 경우 안전하게 undefined 반환
|
|
*/
|
|
function getDevTools(): G7DevToolsInterface | undefined {
|
|
try {
|
|
const G7Core = (window as any).G7Core;
|
|
return G7Core?.devTools;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 값(객체/배열/문자열) 내부의 `$t:` 토큰을 재귀적으로 해석한다.
|
|
*
|
|
* `extensionPointProps` 처럼 일반 props 와 별도로 평가되어 자식 컴포넌트의
|
|
* 핸들러로 전달되는 값에서 `$t:board.form.content_placeholder` 같은 평문 i18n
|
|
* 키를 풀어준다. 일반 props 의 `resolveTranslationsDeep` 와 동일 의미.
|
|
*
|
|
* @since engine-v1.50.0
|
|
*/
|
|
function resolveTranslationsInValue(
|
|
value: any,
|
|
translationEngine: TranslationEngine,
|
|
translationContext: TranslationContext,
|
|
dataContext: Record<string, any>,
|
|
): any {
|
|
if (typeof value === 'string') {
|
|
if (/\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*/.test(value)) {
|
|
try {
|
|
return translationEngine.resolveTranslations(value, translationContext, dataContext);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => resolveTranslationsInValue(item, translationEngine, translationContext, dataContext));
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const result: Record<string, any> = {};
|
|
for (const [k, v] of Object.entries(value)) {
|
|
result[k] = resolveTranslationsInValue(v, translationEngine, translationContext, dataContext);
|
|
}
|
|
return result;
|
|
}
|
|
return value;
|
|
}
|
|
import { responsiveManager } from './ResponsiveManager';
|
|
import { FormProvider, FormContextValue, useFormContext, getNestedValue, setNestedValue } from './FormContext';
|
|
import { SlotProvider } from './SlotContext';
|
|
import { IsolatedStateProvider, useIsolatedState } from './IsolatedStateContext';
|
|
import { SortableContainer, SortableItemWrapper, useSortableContext, type SortableStrategy } from './sortable';
|
|
import { useParentContext } from './ParentContextProvider';
|
|
|
|
/**
|
|
* 중첩 객체에 대해 깊은 병합을 수행하는 헬퍼 함수
|
|
*
|
|
* replaceOnlyKeys에 포함된 키(예: 'errors')는 깊은 병합 대신 완전히 교체됩니다.
|
|
* 이를 통해 validation 에러가 누적되지 않고 최신 에러로 갱신됩니다.
|
|
*
|
|
* @param target 기존 상태 객체
|
|
* @param source 업데이트할 객체
|
|
* @returns 깊은 병합된 새 객체
|
|
*
|
|
* @example 일반 객체는 깊은 병합
|
|
* // target: { filter: { searchField: 'all', salesStatus: ['on_sale'] } }
|
|
* // source: { filter: { dateQuick: 'today' } }
|
|
* // result: { filter: { searchField: 'all', salesStatus: ['on_sale'], dateQuick: 'today' } }
|
|
*
|
|
* @example errors는 완전 교체
|
|
* // target: { errors: { name: ["필수"], email: ["필수"] } }
|
|
* // source: { errors: { email: ["필수"] } }
|
|
* // result: { errors: { email: ["필수"] } } -- name 에러 제거됨
|
|
*/
|
|
/**
|
|
* 객체의 모든 키가 숫자인지 확인합니다.
|
|
* convertDotNotationToObject가 배열 인덱스를 객체 키로 변환한 경우를 감지합니다.
|
|
*/
|
|
function hasOnlyNumericKeys(obj: Record<string, any>): boolean {
|
|
const keys = Object.keys(obj);
|
|
return keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
|
|
}
|
|
|
|
export const deepMergeState = (
|
|
target: Record<string, any>,
|
|
source: Record<string, any>
|
|
): Record<string, any> => {
|
|
// 깊은 병합에서 제외할 키 목록 (완전 교체되어야 하는 필드들)
|
|
// validation errors 등은 병합하면 이전 오류가 남아있게 됨
|
|
const replaceOnlyKeys = ['errors'];
|
|
|
|
const result: Record<string, any> = { ...target };
|
|
|
|
for (const key of Object.keys(source)) {
|
|
const sourceValue = source[key];
|
|
const targetValue = target[key];
|
|
|
|
// source 값이 null이면 그대로 설정
|
|
if (sourceValue === null) {
|
|
result[key] = null;
|
|
continue;
|
|
}
|
|
|
|
// errors 등 특정 키는 항상 완전히 교체 (validation 에러 등은 병합하면 안됨)
|
|
if (replaceOnlyKeys.includes(key)) {
|
|
result[key] = sourceValue;
|
|
continue;
|
|
}
|
|
|
|
// source 값이 배열이면 배열로 교체 (배열은 병합하지 않음)
|
|
if (Array.isArray(sourceValue)) {
|
|
result[key] = sourceValue;
|
|
continue;
|
|
}
|
|
|
|
// 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우
|
|
// convertDotNotationToObject가 "optionInputs.0.values" 같은 경로를
|
|
// { optionInputs: { "0": { values: ... } } } 로 변환하므로,
|
|
// 이를 배열 인덱스로 해석하여 해당 위치의 요소를 병합
|
|
if (
|
|
Array.isArray(targetValue) &&
|
|
sourceValue !== null &&
|
|
typeof sourceValue === 'object' &&
|
|
!Array.isArray(sourceValue) &&
|
|
hasOnlyNumericKeys(sourceValue)
|
|
) {
|
|
const numericKeys = Object.keys(sourceValue).map((k) => parseInt(k, 10));
|
|
const maxKey = numericKeys.length > 0 ? Math.max(...numericKeys) : 0;
|
|
|
|
// Sparse array 방지: source 키가 배열 범위를 크게 초과하면
|
|
// 배열 인덱스가 아닌 ID/키 매핑으로 간주하여 객체로 교체
|
|
if (maxKey >= targetValue.length + numericKeys.length + 10) {
|
|
result[key] = { ...sourceValue };
|
|
} else {
|
|
const arrResult = [...targetValue];
|
|
for (const [srcKey, srcVal] of Object.entries(sourceValue)) {
|
|
const index = parseInt(srcKey, 10);
|
|
if (index >= 0 && index < arrResult.length) {
|
|
if (
|
|
srcVal !== null &&
|
|
typeof srcVal === 'object' &&
|
|
!Array.isArray(srcVal) &&
|
|
arrResult[index] !== null &&
|
|
typeof arrResult[index] === 'object'
|
|
) {
|
|
arrResult[index] = deepMergeState(arrResult[index], srcVal);
|
|
} else {
|
|
arrResult[index] = srcVal;
|
|
}
|
|
} else if (index >= arrResult.length) {
|
|
arrResult[index] = srcVal;
|
|
}
|
|
}
|
|
result[key] = arrResult;
|
|
}
|
|
} else if (
|
|
// 둘 다 일반 객체인 경우 재귀적으로 깊은 병합
|
|
sourceValue !== null &&
|
|
typeof sourceValue === 'object' &&
|
|
targetValue !== null &&
|
|
typeof targetValue === 'object' &&
|
|
!Array.isArray(targetValue)
|
|
) {
|
|
result[key] = deepMergeState(targetValue, sourceValue);
|
|
} else {
|
|
// 그 외의 경우 덮어쓰기
|
|
result[key] = sourceValue;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* 자동바인딩이 `__g7PendingLocalState` 에 실을 스냅샷을 합성합니다.
|
|
*
|
|
* 자동바인딩의 base 는 `parentFormContext.state`(= `extendedDataContext._local`)다.
|
|
* 그 값은 `useMemo` 결과라, memo 가 재계산되지 않은 구간에서는 **입력 이전 스냅샷으로 고정**된다.
|
|
* `setLocal({ render:false, selfManaged:true })`(CKEditor5 등 자체 DOM 관리 플러그인)이
|
|
* 저장소 B 에만 쓰면 React 렌더가 0회라 memo 가 갱신되지 않고, 그 뒤 memo deps 와 무관한
|
|
* 리렌더(폭 변경 등)가 `__g7PendingLocalState` 를 null 로 지우면 base 가 stale 인 채 남는다.
|
|
*
|
|
* 그 stale 스냅샷을 그대로 pending 에 실으면, 이어지는 `setLocal` 이
|
|
* `currentSnapshot = pendingState || baseLocal`(G7CoreGlobals) 로 그것을 채택해
|
|
* 저장소 B 를 통째 교체한다 — 편집기 본문이 예외도 콘솔 에러도 없이 사라진다.
|
|
*
|
|
* 그래서 pending 은 **렌더러가 화면에 쓰는 것과 같은 순서**로 합성한다.
|
|
* `extendedDataContext._local` 이 `dataContext._local → dynamicState → __g7ForcedLocalFields`
|
|
* 순으로 합성하는 것과 동형이며, pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷"
|
|
* 이어야 하므로 이 정합이 맞다.
|
|
*
|
|
* `__g7ForcedLocalFields` 는 `setLocal` 이 쓴 필드만 담고(engine-v1.17.4 · 사례 11),
|
|
* 자동바인딩의 매 키입력이 자기 경로로 그것을 갱신하므로 stale 이 되지 않는다.
|
|
* 방금 입력한 경로만 마지막에 다시 얹어, forced 의 직전 값이 입력을 되돌리지 못하게 한다.
|
|
*
|
|
* 저장소 A 경로(`parentFormContext.setState`)에는 적용하지 않는다 — 그쪽 base 를 바꾸는 것이
|
|
* 2026-04-22 에 로그인 폼 email 손실로 철회된 수정이다(사례 36 이력).
|
|
*
|
|
* @param update 저장소 A 기반 전체 스냅샷 (`setNestedValue(parentFormContext.state, path, value)`)
|
|
* @param forced `__g7ForcedLocalFields` — setLocal 이 소유한 필드만 담긴 오버레이
|
|
* @param fullPath 방금 입력된 필드 경로 (`${dataKey}.${name}`)
|
|
* @param newValue 방금 입력된 값
|
|
* @returns pending 에 실을 스냅샷 (인자를 변이하지 않음)
|
|
*
|
|
* @since engine-v1.63.4
|
|
*/
|
|
export const composeAutoBindingPendingSnapshot = (
|
|
update: Record<string, any>,
|
|
forced: unknown,
|
|
fullPath: string,
|
|
newValue: any
|
|
): Record<string, any> => {
|
|
if (!forced || typeof forced !== 'object' || Array.isArray(forced)) return update;
|
|
const merged = deepMergeState(update, forced as Record<string, any>);
|
|
return setNestedValue(merged, fullPath, newValue);
|
|
};
|
|
|
|
/**
|
|
* setLocal()이 업데이트한 키를 localDynamicState에서 재귀적으로 제거
|
|
*
|
|
* setLocal()은 dataContext._local을 직접 업데이트하므로,
|
|
* localDynamicState에 남은 stale 값이 deepMergeState에서 우선 적용되는 것을 방지합니다.
|
|
* 객체 키는 재귀적으로 처리하고, 배열/원시값은 해당 키를 삭제합니다.
|
|
*
|
|
* 제거된 것이 하나도 없으면 **원본 참조를 그대로 반환**합니다 (@since engine-v1.54.8).
|
|
* 호출부가 참조 비교로 "실제 제거 여부" 를 판정하기 때문입니다 — 내용이 같은데 사본을
|
|
* 새로 만들면 그 판정이 거짓 양성이 되어 불필요한 상태 갱신·캐시 무효화가 상시 발생합니다.
|
|
* 제거가 일어난 경우에도 변경이 없는 형제 가지는 참조를 보존합니다.
|
|
*
|
|
* @param target localDynamicState (제거 대상)
|
|
* @param keysToRemove setLocal이 업데이트한 키 구조
|
|
* @returns 해당 키가 제거된 새 객체 (제거가 없으면 target 원본 참조)
|
|
*/
|
|
export const removeMatchingLeafKeys = (
|
|
target: Record<string, any>,
|
|
keysToRemove: Record<string, any>
|
|
): Record<string, any> => {
|
|
let result: Record<string, any> | null = null;
|
|
const ensureCopy = (): Record<string, any> => {
|
|
if (result === null) result = { ...target };
|
|
|
|
return result;
|
|
};
|
|
|
|
for (const key of Object.keys(keysToRemove)) {
|
|
if (!(key in target)) continue;
|
|
|
|
const removeValue = keysToRemove[key];
|
|
const targetValue = target[key];
|
|
|
|
if (
|
|
removeValue !== null &&
|
|
typeof removeValue === 'object' &&
|
|
!Array.isArray(removeValue) &&
|
|
targetValue !== null &&
|
|
typeof targetValue === 'object' &&
|
|
!Array.isArray(targetValue)
|
|
) {
|
|
// 양쪽 모두 객체: 재귀적으로 처리
|
|
const cleaned = removeMatchingLeafKeys(targetValue, removeValue);
|
|
// 하위에서 제거된 것이 없으면(참조 동일) 이 가지는 손대지 않는다.
|
|
if (cleaned === targetValue) continue;
|
|
|
|
if (Object.keys(cleaned).length === 0) {
|
|
delete ensureCopy()[key];
|
|
} else {
|
|
ensureCopy()[key] = cleaned;
|
|
}
|
|
} else {
|
|
// 리프 값 또는 배열: 해당 키 삭제
|
|
delete ensureCopy()[key];
|
|
}
|
|
}
|
|
|
|
return result ?? target;
|
|
};
|
|
|
|
/**
|
|
* CSS 문자열을 React style 객체로 변환
|
|
*
|
|
* React에서 style prop은 객체여야 하므로, 레이아웃에서 문자열로 정의된
|
|
* CSS 스타일을 자동으로 객체로 변환합니다.
|
|
*
|
|
* @param cssString CSS 문자열 (예: "background-color: #84CC16; color: red")
|
|
* @returns React.CSSProperties 객체
|
|
*
|
|
* @example
|
|
* parseCSSStringToStyleObject("background-color: #84CC16; color: red")
|
|
* // { backgroundColor: '#84CC16', color: 'red' }
|
|
*/
|
|
const parseCSSStringToStyleObject = (cssString: string): React.CSSProperties => {
|
|
const style: Record<string, string> = {};
|
|
|
|
// 세미콜론으로 분리하여 각 선언 처리
|
|
const declarations = cssString.split(';').filter(d => d.trim());
|
|
|
|
for (const declaration of declarations) {
|
|
const colonIndex = declaration.indexOf(':');
|
|
if (colonIndex === -1) continue;
|
|
|
|
const property = declaration.substring(0, colonIndex).trim();
|
|
const value = declaration.substring(colonIndex + 1).trim();
|
|
|
|
if (property && value) {
|
|
// CSS 속성명을 camelCase로 변환 (background-color -> backgroundColor)
|
|
const camelCaseProperty = property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
style[camelCaseProperty] = value;
|
|
}
|
|
}
|
|
|
|
return style as React.CSSProperties;
|
|
};
|
|
|
|
/**
|
|
* ErrorBoundary Props
|
|
*/
|
|
interface ErrorBoundaryProps {
|
|
children: React.ReactNode;
|
|
componentId?: string;
|
|
componentName?: string;
|
|
fallback?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* ErrorBoundary State
|
|
*/
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean;
|
|
error: Error | null;
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 레벨 에러를 처리하는 ErrorBoundary
|
|
*
|
|
* 렌더링 중 발생하는 에러를 캐치하여 에러 UI를 표시합니다.
|
|
* 전체 페이지가 깨지는 것을 방지하고, 해당 컴포넌트만 에러 상태로 표시합니다.
|
|
*/
|
|
class ComponentErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
constructor(props: ErrorBoundaryProps) {
|
|
super(props);
|
|
this.state = { hasError: false, error: null };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
logger.error(
|
|
`컴포넌트 렌더링 에러 (ID: ${this.props.componentId}, Name: ${this.props.componentName}):`,
|
|
error,
|
|
errorInfo
|
|
);
|
|
}
|
|
|
|
render(): React.ReactNode {
|
|
if (this.state.hasError) {
|
|
// 커스텀 fallback이 있으면 사용
|
|
if (this.props.fallback) {
|
|
return this.props.fallback;
|
|
}
|
|
|
|
// 기본 에러 UI - 컴포넌트 로드 실패 메시지
|
|
return (
|
|
<div className="p-4 my-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-400">
|
|
<div className="font-semibold mb-1">
|
|
컴포넌트 로드 실패
|
|
</div>
|
|
<div className="text-xs opacity-80">
|
|
{this.props.componentName && `[${this.props.componentName}] `}
|
|
데이터를 표시할 수 없습니다.
|
|
</div>
|
|
{this.state.error && (
|
|
<details className="mt-2">
|
|
<summary className="cursor-pointer text-xs opacity-70 hover:opacity-100">
|
|
상세 정보
|
|
</summary>
|
|
<div className="mt-1 p-2 bg-red-100 dark:bg-red-900/30 rounded text-xs font-mono whitespace-pre-wrap break-all">
|
|
{this.state.error.message}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 정의 인터페이스
|
|
*/
|
|
export interface ComponentDefinition {
|
|
/** 컴포넌트 고유 ID */
|
|
id: string;
|
|
|
|
/** 컴포넌트 타입 (basic, composite, layout, extension_point) */
|
|
type: 'basic' | 'composite' | 'layout' | 'extension_point';
|
|
|
|
/** 컴포넌트 이름 (ComponentRegistry에 등록된 이름 또는 extension_point 이름) */
|
|
name: string;
|
|
|
|
/** 컴포넌트에 전달할 props */
|
|
props?: Record<string, any>;
|
|
|
|
/** 데이터 바인딩 정의 */
|
|
data_binding?: Record<string, string>;
|
|
|
|
/** 인라인 스타일 */
|
|
style?: React.CSSProperties;
|
|
|
|
/** 이벤트 액션 정의 */
|
|
actions?: any[];
|
|
|
|
/** 자식 컴포넌트 (컴포넌트 정의 또는 텍스트 노드) */
|
|
children?: (ComponentDefinition | string)[];
|
|
|
|
/** 간편 텍스트 속성 (children 대신 사용 가능) */
|
|
text?: string;
|
|
|
|
/** 반복 렌더링 설정 */
|
|
iteration?: {
|
|
/** 데이터 소스 경로 (예: "products.data") */
|
|
source: string;
|
|
/** 반복 아이템 변수명 (예: "product") */
|
|
item_var: string;
|
|
/** 인덱스 변수명 (선택, 예: "index") */
|
|
index_var?: string;
|
|
};
|
|
|
|
/**
|
|
* base 컴포넌트 마킹 (extends 기반 레이아웃에서 LayoutService가 자동 설정)
|
|
*
|
|
* true이면 SPA 네비게이션 시 stable key → 컴포넌트 보존(update)
|
|
* 슬롯 children(페이지 고유 컴포넌트)에는 미설정 → layout_name key → remount
|
|
*
|
|
* @since engine-v1.24.8
|
|
*/
|
|
_fromBase?: boolean;
|
|
|
|
/** 조건부 렌더링 표현식 (예: "{{user.isAdmin}}") */
|
|
if?: string;
|
|
|
|
/**
|
|
* 조건부 렌더링 (if의 별칭 — 컴포넌트 내부 children에서 관례적 사용)
|
|
* 엔진 prop 사전 해석으로 boolean이 될 수 있음
|
|
* @since engine-v1.21.1
|
|
*/
|
|
condition?: string | boolean;
|
|
|
|
/**
|
|
* 복합 조건부 렌더링 (if의 상위 호환)
|
|
*
|
|
* 지원 형식:
|
|
* - 문자열: 기존 if와 동일 (예: "{{user.isAdmin}}")
|
|
* - AND 그룹: { and: ["{{cond1}}", "{{cond2}}"] } - 모든 조건 true일 때 렌더링
|
|
* - OR 그룹: { or: ["{{cond1}}", "{{cond2}}"] } - 하나라도 true면 렌더링
|
|
* - if/else 체인: [{ if: "{{cond1}}" }, { if: "{{cond2}}" }] - 하나라도 true면 렌더링
|
|
*
|
|
* @since engine-v1.10.0
|
|
*/
|
|
conditions?: ConditionsProperty;
|
|
|
|
/**
|
|
* 데이터 로딩 중 blur 효과 적용 설정
|
|
*
|
|
* 지원 형식:
|
|
* - boolean: true면 모든 데이터 소스 로딩 완료까지 blur
|
|
* - string: 표현식 (예: "{{_global.isSaving}}")이 truthy일 때 blur
|
|
* - object: 세부 설정
|
|
* - enabled: blur 활성화 여부
|
|
* - data_sources: 특정 데이터 소스만 체크 (문자열 또는 배열)
|
|
*/
|
|
blur_until_loaded?: boolean | string | {
|
|
enabled: boolean;
|
|
data_sources?: string | string[];
|
|
};
|
|
|
|
/**
|
|
* 폼 자동 바인딩을 위한 데이터 키
|
|
*
|
|
* 이 속성이 설정되면 자식 Input/Select/Textarea 컴포넌트에서
|
|
* name prop을 기반으로 자동 value/onChange 바인딩이 활성화됩니다.
|
|
*
|
|
* 예: dataKey="form" + name="email" → _local.form.email에 바인딩
|
|
*/
|
|
dataKey?: string;
|
|
|
|
/**
|
|
* 변경 사항 추적 여부 (dataKey와 함께 사용)
|
|
*
|
|
* true이면 입력 시 _local.hasChanges를 자동으로 true로 설정
|
|
*/
|
|
trackChanges?: boolean;
|
|
|
|
/**
|
|
* 자동 바인딩 debounce 설정 (dataKey와 함께 사용)
|
|
*
|
|
* 밀리초 단위로 설정하면 onChange 핸들러가 debounce됩니다.
|
|
* 빈번한 입력 시 성능 최적화에 사용됩니다.
|
|
*
|
|
* @example
|
|
* { "dataKey": "form", "debounce": 300 }
|
|
*/
|
|
debounce?: number;
|
|
|
|
/**
|
|
* 컴포넌트 생명주기 핸들러
|
|
*
|
|
* 이벤트 기반 actions와 달리, 컴포넌트의 마운트/언마운트 시점에 실행됩니다.
|
|
* form, _local 등 컴포넌트 상태에 접근할 수 있습니다.
|
|
*/
|
|
lifecycle?: {
|
|
/** 컴포넌트 마운트 시 실행할 액션 목록 */
|
|
onMount?: ActionDefinition[];
|
|
/** 컴포넌트 언마운트 시 실행할 액션 목록 */
|
|
onUnmount?: ActionDefinition[];
|
|
};
|
|
|
|
/**
|
|
* 컴포넌트 이벤트 구독 설정
|
|
*
|
|
* 다른 컴포넌트에서 emitEvent로 발생한 이벤트를 구독합니다.
|
|
* 컴포넌트 마운트 시 자동 구독, 언마운트 시 자동 해제됩니다.
|
|
*
|
|
* 이벤트 데이터는 핸들러에서 {{_eventData}}로 접근할 수 있습니다.
|
|
*
|
|
* @since engine-v1.11.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "onComponentEvent": [
|
|
* {
|
|
* "event": "upload:site_logo",
|
|
* "handler": "refetchDataSource",
|
|
* "params": { "id": "site_settings" }
|
|
* },
|
|
* {
|
|
* "event": "form:submitted",
|
|
* "handler": "setState",
|
|
* "params": {
|
|
* "target": "_local",
|
|
* "value": { "showSuccess": true }
|
|
* }
|
|
* }
|
|
* ]
|
|
* }
|
|
* ```
|
|
*/
|
|
onComponentEvent?: Array<{
|
|
/** 구독할 이벤트 이름 (예: "upload:site_logo", "form:submitted") */
|
|
event: string;
|
|
/** 이벤트 발생 시 실행할 핸들러 */
|
|
handler: string;
|
|
/** 핸들러에 전달할 파라미터 */
|
|
params?: Record<string, any>;
|
|
/** 이벤트 핸들러 성공 시 실행할 액션 (선택적) */
|
|
onSuccess?: ActionDefinition | ActionDefinition[];
|
|
/** 이벤트 핸들러 실패 시 실행할 액션 (선택적) */
|
|
onError?: ActionDefinition | ActionDefinition[];
|
|
}>;
|
|
|
|
/**
|
|
* 반응형 오버라이드 설정
|
|
*
|
|
* Breakpoint별로 props, children, text, if, iteration을 오버라이드할 수 있습니다.
|
|
*
|
|
* 프리셋:
|
|
* - mobile: 0-767px
|
|
* - tablet: 768-1023px
|
|
* - desktop: 1024px+
|
|
*
|
|
* 커스텀 범위:
|
|
* - "0-599": 0px ~ 599px
|
|
* - "600-899": 600px ~ 899px
|
|
* - "1200-": 1200px 이상
|
|
* - "-599": 599px 이하
|
|
*/
|
|
responsive?: {
|
|
[breakpoint: string]: {
|
|
/** 오버라이드할 props (얕은 머지: 지정한 속성만 대체) */
|
|
props?: Record<string, any>;
|
|
/** 완전 교체할 children */
|
|
children?: (ComponentDefinition | string)[];
|
|
/** 완전 교체할 text */
|
|
text?: string;
|
|
/** 완전 교체할 조건부 렌더링 표현식 */
|
|
if?: string;
|
|
/** 완전 교체할 반복 렌더링 설정 */
|
|
iteration?: {
|
|
source: string;
|
|
item_var: string;
|
|
index_var?: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 동적 슬롯 ID
|
|
*
|
|
* 컴포넌트를 원래 위치 대신 지정된 SlotContainer로 렌더링합니다.
|
|
* 표현식을 지원하여 동적으로 슬롯을 결정할 수 있습니다.
|
|
*
|
|
* @since engine-v1.10.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "slot": "{{(_local.visibleFilters || []).includes('category') ? 'basic_filters' : 'detail_filters'}}"
|
|
* }
|
|
* ```
|
|
*/
|
|
slot?: string;
|
|
|
|
/**
|
|
* 슬롯 내 정렬 순서
|
|
*
|
|
* 같은 슬롯에 여러 컴포넌트가 등록된 경우 정렬 순서를 결정합니다.
|
|
* 낮은 값이 먼저 렌더링됩니다. (기본값: 0)
|
|
*
|
|
* @since engine-v1.10.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "slot": "basic_filters",
|
|
* "slotOrder": 1
|
|
* }
|
|
* ```
|
|
*/
|
|
slotOrder?: number;
|
|
|
|
/**
|
|
* 컴포넌트 전용 커스텀 레이아웃 정의
|
|
*
|
|
* 컴포넌트 내부에서 커스텀 렌더링이 필요한 영역을 정의합니다.
|
|
* 부모-자식 페이지 레이아웃을 위한 slots와는 별개의 용도입니다.
|
|
*
|
|
* 각 키는 컴포넌트에서 정의한 렌더링 영역 이름이며,
|
|
* 값은 해당 영역에 렌더링할 컴포넌트 정의 배열입니다.
|
|
*
|
|
* DynamicRenderer에서 이 속성을 처리하여 컴포넌트 props로 변환합니다.
|
|
* 예: component_layout.item → children prop (item 컨텍스트와 함께 렌더링)
|
|
*
|
|
* @since engine-v1.11.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "type": "composite",
|
|
* "name": "RichSelect",
|
|
* "props": { "options": "{{files}}" },
|
|
* "component_layout": {
|
|
* "item": [
|
|
* { "type": "basic", "name": "Div", "children": [...] }
|
|
* ],
|
|
* "selected": [
|
|
* { "type": "basic", "name": "Span", "text": "{{item.name}}" }
|
|
* ]
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
component_layout?: Record<string, (ComponentDefinition | string)[]>;
|
|
|
|
/**
|
|
* 확장 자식 컴포넌트 정의 (DataGrid 등에서 사용)
|
|
*
|
|
* DataGrid의 행 확장 영역에서 렌더링할 컴포넌트 정의 배열입니다.
|
|
* 이 속성이 있는 컴포넌트에는 __componentContext prop이 자동 전달되어
|
|
* 확장 영역 내 액션에서 부모 컴포넌트의 상태를 업데이트할 수 있습니다.
|
|
*
|
|
* @since engine-v1.12.0
|
|
*/
|
|
expandChildren?: (ComponentDefinition | string)[];
|
|
|
|
/**
|
|
* 조건부 스타일 매핑 (classMap)
|
|
*
|
|
* 동적 값에 따라 다른 CSS 클래스를 적용합니다.
|
|
* 중첩 삼항 연산자를 사용하는 복잡한 className 표현식을 대체합니다.
|
|
*
|
|
* @since engine-v1.13.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "classMap": {
|
|
* "base": "px-2 py-1 rounded-full text-xs font-medium",
|
|
* "variants": {
|
|
* "success": "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400",
|
|
* "danger": "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400",
|
|
* "warning": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"
|
|
* },
|
|
* "key": "{{row.status}}",
|
|
* "default": "bg-gray-100 text-gray-600"
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
classMap?: {
|
|
/** 항상 적용되는 기본 클래스 */
|
|
base?: string;
|
|
/** 키 값에 따른 클래스 변형 */
|
|
variants: Record<string, string>;
|
|
/** 변형을 결정하는 키 표현식 (예: "{{row.status}}") */
|
|
key: string;
|
|
/** 매칭되는 변형이 없을 때 사용할 기본 클래스 */
|
|
default?: string;
|
|
};
|
|
|
|
/**
|
|
* 격리된 상태 스코프 정의
|
|
*
|
|
* 이 속성이 있으면 컴포넌트가 IsolatedStateProvider로 래핑되어
|
|
* 독립적인 상태 스코프를 가집니다. 상태 변경 시 해당 스코프 내의
|
|
* 컴포넌트만 리렌더링됩니다.
|
|
*
|
|
* {{_isolated.xxx}} 바인딩으로 격리된 상태에 접근할 수 있습니다.
|
|
*
|
|
* @since engine-v1.12.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "type": "basic",
|
|
* "name": "Div",
|
|
* "isolatedState": {
|
|
* "selectedCategories": [null, null, null, null],
|
|
* "categoryStep": 1
|
|
* },
|
|
* "children": [...]
|
|
* }
|
|
* ```
|
|
*/
|
|
isolatedState?: Record<string, any>;
|
|
|
|
/**
|
|
* 격리된 상태 스코프 ID (DevTools 식별용)
|
|
*
|
|
* 지정하지 않으면 자동 생성됩니다.
|
|
* DevTools에서 격리된 상태를 식별하는 데 사용됩니다.
|
|
*
|
|
* @since engine-v1.12.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "isolatedState": { "selected": null },
|
|
* "isolatedScopeId": "category-selector"
|
|
* }
|
|
* ```
|
|
*/
|
|
isolatedScopeId?: string;
|
|
|
|
/**
|
|
* 정렬 가능한 리스트 렌더링 설정
|
|
*
|
|
* @dnd-kit 기반의 드래그앤드롭 정렬 기능을 제공합니다.
|
|
* iteration과 유사하게 배열 데이터를 렌더링하되, 드래그로 순서 변경이 가능합니다.
|
|
*
|
|
* @since engine-v1.14.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "sortable": {
|
|
* "source": "{{_local.form.additional_options}}",
|
|
* "itemKey": "id",
|
|
* "strategy": "verticalList",
|
|
* "handle": "[data-drag-handle]"
|
|
* },
|
|
* "itemTemplate": {
|
|
* "type": "basic",
|
|
* "name": "Div",
|
|
* "children": [...]
|
|
* },
|
|
* "actions": [{
|
|
* "event": "onSortEnd",
|
|
* "handler": "setState",
|
|
* "params": {
|
|
* "target": "local",
|
|
* "form.additional_options": "{{$sortedItems}}"
|
|
* }
|
|
* }]
|
|
* }
|
|
* ```
|
|
*/
|
|
sortable?: {
|
|
/** 데이터 소스 (배열 바인딩 표현식, 예: "{{_local.form.items}}") */
|
|
source: string;
|
|
/** 아이템 고유 키 필드명 (기본값: 'id') */
|
|
itemKey?: string;
|
|
/** 정렬 전략: verticalList | horizontalList | rectSorting (기본값: 'verticalList') */
|
|
strategy?: 'verticalList' | 'horizontalList' | 'rectSorting';
|
|
/** 드래그 핸들 CSS 선택자 (없으면 전체 아이템이 드래그 가능) */
|
|
handle?: string;
|
|
/** 아이템 변수명 (기본값: '$item') */
|
|
itemVar?: string;
|
|
/** 인덱스 변수명 (기본값: '$index') */
|
|
indexVar?: string;
|
|
};
|
|
|
|
/**
|
|
* sortable 사용 시 각 아이템의 템플릿
|
|
*
|
|
* sortable과 함께 사용됩니다.
|
|
* 아이템 컨텍스트 변수 ($item, $index)를 사용하여 각 아이템을 렌더링합니다.
|
|
*
|
|
* @since engine-v1.14.0
|
|
*/
|
|
itemTemplate?: ComponentDefinition;
|
|
|
|
/**
|
|
* Extension Point에서 전달받은 props
|
|
*
|
|
* 백엔드 LayoutExtensionService에서 extension_point 컴포넌트의 props를
|
|
* 주입된 컴포넌트에 전달할 때 사용됩니다.
|
|
* 데이터 바인딩에서 {{extensionPointProps.xxx}}로 접근할 수 있습니다.
|
|
*
|
|
* @since engine-v1.15.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* // extension_point 정의 (_tab_basic_info.json)
|
|
* {
|
|
* "type": "extension_point",
|
|
* "name": "address_search_slot",
|
|
* "props": {
|
|
* "onAddressSelect": {
|
|
* "handler": "setState",
|
|
* "params": { "form.zipcode": "{{$event.zonecode}}" }
|
|
* }
|
|
* }
|
|
* }
|
|
*
|
|
* // 플러그인 extension JSON에서 사용
|
|
* {
|
|
* "callbackAction": "{{extensionPointProps.onAddressSelect}}"
|
|
* }
|
|
* ```
|
|
*/
|
|
extensionPointProps?: Record<string, any>;
|
|
|
|
/**
|
|
* Extension Point에서 전달받은 콜백 액션 객체
|
|
*
|
|
* 백엔드 LayoutExtensionService에서 extension_point 컴포넌트의 callbacks를
|
|
* 주입된 컴포넌트에 전달할 때 사용됩니다.
|
|
* extensionPointProps와 달리 표현식 평가 없이 그대로 전달됩니다.
|
|
* (ActionDispatcher가 실행 시점에 평가)
|
|
*
|
|
* 데이터 바인딩에서 {{extensionPointCallbacks.xxx}}로 접근할 수 있습니다.
|
|
*
|
|
* @since engine-v1.28.0
|
|
*/
|
|
extensionPointCallbacks?: Record<string, any>;
|
|
}
|
|
|
|
/**
|
|
* DynamicRenderer Props
|
|
*/
|
|
export interface DynamicRendererProps {
|
|
/** 렌더링할 컴포넌트 정의 */
|
|
componentDef: ComponentDefinition;
|
|
|
|
/** 데이터 컨텍스트 (바인딩에 사용) */
|
|
dataContext: Record<string, any>;
|
|
|
|
/** 번역 컨텍스트 */
|
|
translationContext: TranslationContext;
|
|
|
|
/** ComponentRegistry 인스턴스 */
|
|
registry: ComponentRegistry;
|
|
|
|
/** DataBindingEngine 인스턴스 */
|
|
bindingEngine: DataBindingEngine;
|
|
|
|
/** TranslationEngine 인스턴스 */
|
|
translationEngine: TranslationEngine;
|
|
|
|
/** ActionDispatcher 인스턴스 */
|
|
actionDispatcher: ActionDispatcher;
|
|
|
|
/**
|
|
* 부모 컴포넌트 컨텍스트 (state, setState)
|
|
*
|
|
* 레이아웃 수준에서 상태를 공유하기 위해 사용됩니다.
|
|
* 전달되면 자체 상태 대신 부모의 상태를 사용합니다.
|
|
*/
|
|
parentComponentContext?: {
|
|
state: Record<string, any>;
|
|
setState: (updates: Record<string, any>) => void;
|
|
};
|
|
|
|
/**
|
|
* 부모 폼 컨텍스트 (prop으로 전달)
|
|
*
|
|
* React Context 대신 prop으로 전달하여 useMemo 타이밍 문제를 해결합니다.
|
|
* FormProvider로 감싸기 전에 자식이 렌더링되는 문제를 우회합니다.
|
|
*/
|
|
parentFormContextProp?: FormContextValue;
|
|
|
|
/**
|
|
* 부모 데이터 컨텍스트 ($parent 바인딩용)
|
|
*
|
|
* 모달 등 자식 레이아웃에서 부모의 상태(_local, _global, _computed)에
|
|
* 접근할 수 있도록 부모의 extendedDataContext를 전달합니다.
|
|
*
|
|
* 레이아웃 JSON에서 {{$parent._local.xxx}} 형태로 사용합니다.
|
|
*
|
|
* @since engine-v1.16.0
|
|
*/
|
|
parentDataContext?: Record<string, any>;
|
|
|
|
/**
|
|
* 루트 렌더러 여부
|
|
*
|
|
* true이면 SlotProvider로 래핑합니다. (레이아웃 최상위에서만 true)
|
|
* 자식 DynamicRenderer에서는 false로 전달됩니다.
|
|
*/
|
|
isRootRenderer?: boolean;
|
|
|
|
/**
|
|
* iteration 컨텍스트 내부 여부
|
|
*
|
|
* true이면 props 바인딩 해석 시 캐시를 사용하지 않습니다.
|
|
* iteration 내에서 같은 표현식이 다른 컨텍스트로 평가되어야 하므로
|
|
* 캐시를 사용하면 첫 번째 아이템의 값이 재사용되는 버그가 발생합니다.
|
|
*
|
|
* @since engine-v1.15.0
|
|
*/
|
|
isInsideIteration?: boolean;
|
|
|
|
/**
|
|
* 위지윅 편집 모드 여부
|
|
*
|
|
* true이면 컴포넌트에 data-editor-id 속성을 추가하고,
|
|
* 편집기 오버레이가 컴포넌트를 식별할 수 있게 합니다.
|
|
*
|
|
* @since engine-v1.11.0
|
|
*/
|
|
isEditMode?: boolean;
|
|
|
|
/**
|
|
* 편집 모드에서 컴포넌트 선택 핸들러
|
|
*
|
|
* 컴포넌트가 클릭되면 호출됩니다.
|
|
* 편집기에서 해당 컴포넌트를 선택 상태로 표시합니다.
|
|
*
|
|
* @since engine-v1.11.0
|
|
*/
|
|
onComponentSelect?: (componentId: string, event: React.MouseEvent) => void;
|
|
|
|
/**
|
|
* 편집 모드에서 컴포넌트 호버 핸들러
|
|
*
|
|
* 마우스가 컴포넌트 위로 이동하면 호출됩니다.
|
|
* 편집기에서 해당 컴포넌트를 하이라이트합니다.
|
|
*
|
|
* @since engine-v1.11.0
|
|
*/
|
|
onComponentHover?: (componentId: string | null, event: React.MouseEvent) => void;
|
|
|
|
/**
|
|
* 편집 모드에서 드래그 시작 핸들러
|
|
*
|
|
* 컴포넌트가 드래그되기 시작할 때 호출됩니다.
|
|
* 프리뷰 캔버스에서 컴포넌트를 드래그하여 이동할 수 있게 합니다.
|
|
*
|
|
* @since engine-v1.12.0
|
|
*/
|
|
onDragStart?: (componentId: string, event: React.DragEvent) => void;
|
|
|
|
/**
|
|
* 편집 모드에서 드래그 종료 핸들러
|
|
*
|
|
* 드래그가 종료되면 호출됩니다.
|
|
*
|
|
* @since engine-v1.12.0
|
|
*/
|
|
onDragEnd?: (event: React.DragEvent) => void;
|
|
|
|
/**
|
|
* 컴포넌트 인덱스 경로 (WYSIWYG 편집기용)
|
|
*
|
|
* 레이아웃 트리에서 컴포넌트의 위치를 나타내는 경로입니다.
|
|
* 예: "0", "0.children.2", "0.children.2.children.0"
|
|
*
|
|
* ID가 없는 컴포넌트를 편집할 때 경로 기반으로 식별합니다.
|
|
*
|
|
* @since engine-v1.13.0
|
|
*/
|
|
componentPath?: string;
|
|
|
|
/**
|
|
* 레이아웃 key (SPA 네비게이션 시 children remount용)
|
|
*
|
|
* extends 기반 레이아웃에서 _fromBase가 아닌 children의 key에
|
|
* suffix로 포함되어 페이지 전환 시 remount를 보장합니다.
|
|
*
|
|
* @since engine-v1.24.8
|
|
*/
|
|
layoutKey?: string;
|
|
}
|
|
|
|
/**
|
|
* DynamicRenderer 에러 클래스
|
|
*/
|
|
export class DynamicRendererError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public componentId?: string,
|
|
public componentName?: string
|
|
) {
|
|
super(message);
|
|
this.name = 'DynamicRendererError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DynamicRenderer 컴포넌트
|
|
*
|
|
* JSON 정의를 기반으로 React 컴포넌트를 동적으로 렌더링합니다.
|
|
* 재귀적 구조, 반복, 조건부 렌더링을 지원합니다.
|
|
*/
|
|
const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
|
|
({
|
|
componentDef,
|
|
dataContext,
|
|
translationContext,
|
|
registry,
|
|
bindingEngine,
|
|
translationEngine,
|
|
actionDispatcher,
|
|
parentComponentContext,
|
|
parentFormContextProp,
|
|
parentDataContext,
|
|
isRootRenderer = false,
|
|
isInsideIteration = false,
|
|
isEditMode = false,
|
|
onComponentSelect,
|
|
onComponentHover,
|
|
onDragStart,
|
|
onDragEnd,
|
|
componentPath,
|
|
layoutKey,
|
|
}) => {
|
|
// 슬롯 컨텍스트는 window.__slotContextValue를 직접 사용
|
|
// React Context 타이밍 이슈로 useSlotContext() 대신 전역 변수 사용
|
|
|
|
// 컴포넌트 동적 상태 관리 (loadingActions, apiError, 기타 동적 속성)
|
|
const [localDynamicState, setLocalDynamicState] = useState<Record<string, any>>({
|
|
loadingActions: {},
|
|
});
|
|
|
|
/**
|
|
* 렌더 사이클 캐시 초기화 (루트 컴포넌트에서만)
|
|
*
|
|
* 렌더링 시작 시 DataBindingEngine의 렌더 사이클 캐시를 클리어합니다.
|
|
* 이를 통해:
|
|
* - 같은 렌더 사이클 내에서 동일 표현식 재평가 방지 (성능 최적화)
|
|
* - 새 렌더 시 최신 상태값으로 평가 보장
|
|
*
|
|
* engine-v1.17.6: __g7ForcedLocalFields도 렌더 사이클 시작 시 클리어
|
|
* - __g7ForcedLocalFields는 setState/setLocal 호출 시 설정됨
|
|
* - 같은 렌더 사이클 내에서는 누적되어 dynamicState보다 우선 적용
|
|
* - 새 렌더 사이클 시작 시 클리어하여 이전 렌더의 stale 값 제거
|
|
* - 예: validation 실패 후 필드 수정 시 이전 값이 우선 적용되는 문제 방지
|
|
*
|
|
* 루트 컴포넌트 판별: parentComponentContext가 없으면 루트급 (root + modals)
|
|
* engine-v1.17.8: __g7ForcedLocalFields 클리어 + __g7SetLocalOverrideKeys 처리 모두 isRootRenderer 조건 안에서만
|
|
* - 모달도 parentComponentContext 없이 렌더되므로, 모달이 ROOT보다 먼저 렌더링 시
|
|
* forcedLocalFields를 클리어하면 ROOT가 비동기 setLocal 데이터를 받을 수 없는 문제 방지
|
|
*/
|
|
useLayoutEffect(() => {
|
|
if (!parentComponentContext) {
|
|
bindingEngine.startRenderCycle();
|
|
|
|
// engine-v1.17.11: 모든 루트급 컴포넌트에서 __g7ForcedLocalFields, __g7PendingLocalState 클리어
|
|
//
|
|
// engine-v1.17.8에서 isRootRenderer(index===0) 조건으로 제한했으나, 이로 인해:
|
|
// - global_toast(index=0)만 클리어, user_layout_root(index=2)는 클리어 안 함
|
|
// - global_toast는 user_layout_root 상태 변경 시 리렌더 안 되므로 클리어 기회 없음
|
|
// - __g7ForcedLocalFields에 stale 값이 영구 잔존 → 커스텀 핸들러의 setState가 무시됨
|
|
//
|
|
// useLayoutEffect는 같은 React commit 내에서 모든 render phase 완료 후 실행되므로,
|
|
// 모달이 먼저 클리어해도 ROOT는 이미 render phase에서 forcedLocalFields를 읽은 상태.
|
|
// React 18+는 모든 상태 업데이트를 자동 배치하므로 동시 리렌더 보장됨.
|
|
//
|
|
// engine-v1.18.2: __g7ForcedLocalFields 조건부 클리어
|
|
// - __g7SetLocalOverrideKeys 존재 = 최근 setLocal() 호출 → forcedFields 유지
|
|
// (dataContext._local 갱신까지 setLocal 값을 forcedFields로 보호하여 stale 중간 렌더 방지)
|
|
// - __g7SetLocalOverrideKeys 없음 = handleSetState 등의 forcedFields → 즉시 클리어
|
|
if (!(window as any).__g7SetLocalOverrideKeys) {
|
|
(window as any).__g7ForcedLocalFields = undefined;
|
|
}
|
|
|
|
// engine-v1.17.10: __g7PendingLocalState 클리어
|
|
// - handleSetState에서 설정한 예상 상태가 렌더 후에도 남아있으면
|
|
// 다음 handleLocalSetState 호출 시 effectivePrev = deepMergeState(prev, pendingState)에서
|
|
// stale pendingState의 값이 현재 상태를 오염시킴
|
|
// - 렌더 완료 후에는 localDynamicState가 최신 상태이므로 pendingState 불필요
|
|
(window as any).__g7PendingLocalState = null;
|
|
|
|
// engine-v1.18.1: __g7SetLocalOverrideKeys 처리는 별도 useLayoutEffect([dataContext._local])로 이동
|
|
// (아래 참조) — dynamicState 클리어를 dataContext._local 갱신 시점까지 지연
|
|
}
|
|
});
|
|
|
|
/**
|
|
* engine-v1.27.0: _localInit 데이터로 __g7PendingLocalState/__g7SetLocalOverrideKeys 사전 설정
|
|
*
|
|
* useLayoutEffect는 모든 useEffect보다 먼저 실행됨 (React 보장).
|
|
* _localInit(useEffect)이 localDynamicState를 갱신하기 전에 자식 useEffect가 먼저 실행되면
|
|
* ActionDispatcher가 stale context.state 기반으로 상태 스냅샷을 생성하는 문제 방지.
|
|
* ref 해시 추적으로 동일 데이터 재설정 방지 (사용자 입력 덮어쓰기 방지).
|
|
*/
|
|
const localInitPendingSyncRef = useRef<string | null>(null);
|
|
useLayoutEffect(() => {
|
|
if (!parentComponentContext && dataContext._localInit && typeof dataContext._localInit === 'object') {
|
|
const { _forceLocalInit, _merge: initMergeMode, ...initData } = dataContext._localInit as Record<string, any>;
|
|
const syncKey = `${JSON.stringify(initData)}:${_forceLocalInit || 'no-force'}`;
|
|
|
|
if (localInitPendingSyncRef.current !== syncKey) {
|
|
localInitPendingSyncRef.current = syncKey;
|
|
const mergeStrategy = initMergeMode || 'shallow';
|
|
// engine-v1.49.2: pending baseline 으로 fresh globalState._local + 기존 pending 사용.
|
|
// useLayoutEffect 클리어 직후 발화 시 pending 이 null 이면 빈 baseline 이 되어
|
|
// globalState 의 직전 setState 결과가 흡수되지 않음. 이로 인해 후속 setLocal 의
|
|
// mergedPending 계산에서 stale pending 우선 사용으로 globalState._local 통째 교체 시
|
|
// 다른 키 손실. fresh globalState 위에 기존 pending 머지로 baseline 정합성 보장.
|
|
const G7Core = (window as any).G7Core;
|
|
const freshGlobalLocal = G7Core?.state?.get?.()?._local || {};
|
|
const pendingState = (window as any).__g7PendingLocalState || {};
|
|
const currentPending = { ...freshGlobalLocal, ...pendingState };
|
|
|
|
if (mergeStrategy === 'replace') {
|
|
(window as any).__g7PendingLocalState = {
|
|
loadingActions: currentPending.loadingActions || {},
|
|
...initData,
|
|
hasChanges: false,
|
|
};
|
|
} else if (mergeStrategy === 'deep') {
|
|
(window as any).__g7PendingLocalState = deepMergeState(currentPending, {
|
|
...initData,
|
|
hasChanges: false,
|
|
});
|
|
} else {
|
|
(window as any).__g7PendingLocalState = {
|
|
...currentPending,
|
|
...initData,
|
|
hasChanges: false,
|
|
};
|
|
}
|
|
|
|
// handleLocalSetState에서 pendingToMerge = setLocalOverrides || pendingState 선택 시
|
|
// stale overrides가 pendingState(API 데이터)를 가로채지 않도록 갱신
|
|
const existingOverrides = (window as any).__g7SetLocalOverrideKeys;
|
|
if (existingOverrides && typeof existingOverrides === 'object') {
|
|
if (mergeStrategy === 'replace') {
|
|
(window as any).__g7SetLocalOverrideKeys = { ...initData, hasChanges: false };
|
|
} else {
|
|
(window as any).__g7SetLocalOverrideKeys = deepMergeState(existingOverrides, {
|
|
...initData,
|
|
hasChanges: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* setLocal() 키 정리: dataContext._local 변경 시점으로 지연 (engine-v1.18.1)
|
|
*
|
|
* 근본 원인:
|
|
* - setLocal()은 dataContext._local(root.render, startTransition LOW priority)과
|
|
* dynamicState(actionContext.setState, NORMAL priority)를 모두 업데이트
|
|
* - React가 priority 차이로 별도 배치 처리 시:
|
|
* 1. setState 처리 → dynamicState 갱신 + __g7ForcedLocalFields → 정확한 렌더
|
|
* 2. useLayoutEffect: forcedFields 클리어 + (기존) removeMatchingLeafKeys → dynamicState도 클리어
|
|
* 3. 중간 렌더: forcedFields 없음, dynamicState 클리어, dataContext._local 미갱신 → stale 값 노출 (플리커!)
|
|
* 4. root.render 처리 → dataContext._local 갱신 → 정확한 렌더
|
|
*
|
|
* 수정:
|
|
* - removeMatchingLeafKeys를 dataContext._local 변경 감지 시점으로 지연
|
|
* - dynamicState가 dataContext._local 갱신까지 정확한 값을 유지 → 중간 stale 렌더 방지
|
|
* - 기존 동작 보존: dataContext._local 갱신 후에는 dynamicState에서 해당 키 제거 (중복 방지)
|
|
*
|
|
* engine-v1.18.3: 전역 플래그 클리어를 queueMicrotask로 지연
|
|
* - 복수의 root DynamicRenderer가 존재하는 경우 (global_toast, admin_layout_root 등)
|
|
* 모든 root의 useLayoutEffect가 동일 commit 내에서 순차 실행됨
|
|
* - 이전: 첫 번째 root가 __g7SetLocalOverrideKeys를 즉시 클리어 → 나머지 root는 처리 불가
|
|
* → _localInit에서 설정된 stale dynamicState가 정리되지 않아 모달 열기 시 이전 값 노출
|
|
* - 수정: queueMicrotask로 클리어를 지연하여 모든 root가 독립적으로 removeMatchingLeafKeys 실행
|
|
* → 모든 root의 stale dynamicState가 올바르게 정리됨
|
|
*/
|
|
useLayoutEffect(() => {
|
|
if (!parentComponentContext) {
|
|
const setLocalKeys = (window as any).__g7SetLocalOverrideKeys;
|
|
if (setLocalKeys) {
|
|
setLocalDynamicState(prev => removeMatchingLeafKeys(prev, setLocalKeys));
|
|
// engine-v1.18.3: 전역 플래그 클리어를 queueMicrotask로 지연
|
|
// 모든 root DynamicRenderer의 useLayoutEffect는 동일 commit phase 내에서
|
|
// 순차적·동기적으로 실행됨. queueMicrotask는 이 동기 블록 이후에 실행되므로
|
|
// 모든 root가 setLocalKeys를 읽고 removeMatchingLeafKeys를 완료한 뒤 클리어됨.
|
|
// 참조 비교로 새 setLocal 호출에 의한 덮어쓰기를 보호함.
|
|
const capturedKeys = setLocalKeys;
|
|
const capturedForced = (window as any).__g7ForcedLocalFields;
|
|
// engine-v1.21.2: getLocal() fallback 스냅샷도 함께 클리어
|
|
const capturedSnapshot = (window as any).__g7LastSetLocalSnapshot;
|
|
queueMicrotask(() => {
|
|
if ((window as any).__g7SetLocalOverrideKeys === capturedKeys) {
|
|
(window as any).__g7SetLocalOverrideKeys = undefined;
|
|
}
|
|
if ((window as any).__g7ForcedLocalFields === capturedForced) {
|
|
(window as any).__g7ForcedLocalFields = undefined;
|
|
}
|
|
// dataContext._local이 갱신되었으므로 setLocal 스냅샷 불필요
|
|
// 참조 비교로 새 setLocal 호출에 의한 덮어쓰기를 보호
|
|
if ((window as any).__g7LastSetLocalSnapshot === capturedSnapshot) {
|
|
(window as any).__g7LastSetLocalSnapshot = undefined;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}, [dataContext._local]);
|
|
|
|
/**
|
|
* @since engine-v1.24.8 _fromBase 컴포넌트의 localDynamicState 초기화
|
|
*
|
|
* _fromBase: true인 컴포넌트는 stable key로 SPA 네비게이션 시에도 보존(unmount/remount 없음).
|
|
* 하지만 이전 페이지에서 설정된 localDynamicState(visibleFilters, hasChanges 등)가
|
|
* 잔존하여 componentContext.state를 통해 모든 하위 컴포넌트에 전파되는 문제 발생.
|
|
*
|
|
* layoutKey(= layout_name)가 변경되면 보존된 컴포넌트의 localDynamicState를 초기화하여
|
|
* 이전 페이지 상태가 새 페이지에 오염되는 것을 방지.
|
|
*
|
|
* - sidebar 메뉴 open/close: React 컴포넌트 내부 useState → localDynamicState와 무관 → 보존됨
|
|
* - Form 자동 바인딩 값: localDynamicState에 저장 → 초기화됨 (새 페이지에서 재설정)
|
|
*/
|
|
const prevLayoutKeyRef = useRef(layoutKey);
|
|
useEffect(() => {
|
|
if (componentDef._fromBase && layoutKey && prevLayoutKeyRef.current !== layoutKey) {
|
|
prevLayoutKeyRef.current = layoutKey;
|
|
setLocalDynamicState({ loadingActions: {} });
|
|
}
|
|
}, [layoutKey, componentDef._fromBase]);
|
|
|
|
// _localInit 데이터의 해시값 추적 (데이터 변경 감지용)
|
|
const localInitHashRef = useRef<string>('');
|
|
|
|
// _forceLocalInit 강제 초기화 적용 여부 추적 (중복 실행 방지용)
|
|
const forceInitAppliedRef = useRef<boolean>(false);
|
|
|
|
// @since engine-v1.41.0: _localInit useEffect가 처리 완료한 dataContext._localInit 참조를 추적
|
|
// useMemo(렌더 단계)에서 참조 비교(O(1))로 _localInit 미적용 상태를 감지하여
|
|
// stale dynamicState(init_actions 기본값의 빈 배열 등)가 dataContext._local의
|
|
// API 데이터를 덮어쓰는 것을 방지 (플러그인 onMount setLocal 경합 해소)
|
|
const lastProcessedInitRef = useRef<any>(null);
|
|
|
|
// @since engine-v1.54.7: 이 인스턴스가 마지막으로 처리한 _localInit 추적 키
|
|
// 전역 추적(__g7LocalInitTracking)은 "payload 를 처음 관측했는가"만 판정하는데,
|
|
// 리셋 대상인 localDynamicState(저장소 A)는 인스턴스별이다. 전역 1회 소비만으로 끝내면
|
|
// 나머지 루트 렌더러의 저장소 A 에 stale leaf 가 영구 잔존한다 → resolveLocalInitAction 참조
|
|
const localInitHandledKeyRef = useRef<string | null>(null);
|
|
|
|
// 최신 _local 상태를 참조하는 ref (expandChildren 상태 동기화용)
|
|
// useCallback으로 캐싱된 componentContext에서도 최신 상태에 접근 가능하도록 함
|
|
const latestLocalStateRef = useRef<Record<string, any>>({});
|
|
|
|
// 최신 _computed 상태를 참조하는 ref (expandChildren 상태 동기화용)
|
|
// _computed는 _local 기반으로 계산되므로 _local 변경 시 함께 업데이트
|
|
const latestComputedRef = useRef<Record<string, any>>({});
|
|
|
|
// 최신 부모 데이터 컨텍스트를 참조하는 ref ($parent 바인딩 stale closure 방지)
|
|
// 모달 등에서 부모 상태 접근 시 항상 최신 값을 반환하도록 함
|
|
const parentDataContextRef = useRef<Record<string, any> | undefined>(parentDataContext);
|
|
|
|
// 격리된 상태 컨텍스트 (IsolatedStateProvider 내부에서만 유효)
|
|
const isolatedContext = useIsolatedState();
|
|
|
|
// 부모 컨텍스트 훅 (ParentContextProvider 내부에서만 유효)
|
|
// 모달에서 $parent._local 변경 시 선택적 리렌더링을 위해 사용
|
|
const parentContextHook = useParentContext();
|
|
// 훅에서 최신 부모 컨텍스트를 가져오거나, 없으면 prop 사용
|
|
const effectiveParentDataContext = parentContextHook?.getParentDataContext() ?? parentDataContext;
|
|
|
|
/**
|
|
* _localInit 처리
|
|
*
|
|
* TemplateApp에서 data_sources의 initLocal 옵션으로 전달된 초기 데이터를
|
|
* 로컬 상태에 병합합니다. 폼 편집 시 API로 받아온 데이터를 _local에 자동 복사합니다.
|
|
*
|
|
* 동작:
|
|
* - dataContext._localInit가 있으면 localDynamicState에 병합
|
|
* - 데이터 내용이 변경되면 (다른 리소스로 이동 시) 새 데이터로 덮어씀
|
|
* - 동일한 데이터는 중복 적용하지 않음 (사용자 수정 데이터 보존)
|
|
*
|
|
* 페이지 전환 시 데이터 갱신:
|
|
* - 211 유저 편집 -> 212 유저 편집 시 _localInit 데이터 해시가 변경됨
|
|
* - 해시가 변경되면 새 데이터로 로컬 상태 초기화
|
|
*
|
|
* refetchOnMount 지원:
|
|
* - _forceLocalInit 타임스탬프가 있으면 해시가 항상 달라짐
|
|
* - 같은 리소스로 재진입해도 강제로 로컬 상태 초기화
|
|
* - _forceLocalInit 플래그는 로컬 상태에 저장하지 않음
|
|
*/
|
|
useEffect(() => {
|
|
// _localInit 처리: 데이터가 있으면 컴포넌트 계층 구조와 무관하게 처리
|
|
// 중복 방지는 전역 추적 메커니즘 (__g7LocalInitTracking)으로 처리
|
|
//
|
|
// 배경:
|
|
// - extends 기반 레이아웃에서는 베이스 레이아웃의 루트와 자식 레이아웃이 분리됨
|
|
// - 데이터소스는 자식 레이아웃에 정의되므로 _localInit이 자식 컴포넌트에 전달됨
|
|
// - 이전에는 parentDataContext나 컴포넌트 ID로 판별했으나, extends 레이아웃에서 실패
|
|
//
|
|
// 해결:
|
|
// - _localInit이 있으면 무조건 처리 시도
|
|
// - 전역 해시 추적으로 동일 데이터 중복 적용 방지
|
|
// - componentId 기반 키로 컴포넌트별 독립 추적
|
|
|
|
if (dataContext._localInit && typeof dataContext._localInit === 'object') {
|
|
// _forceLocalInit 플래그를 제외한 데이터만 로컬 상태에 병합
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { _forceLocalInit, ...localInitData } = dataContext._localInit;
|
|
|
|
// 해시 계산 시 _forceLocalInit 제외 (데이터 내용만 비교)
|
|
// _forceLocalInit 타임스탬프는 refetchOnMount 시 강제 초기화를 위해 사용되지만,
|
|
// 해시 비교에 포함하면 매 렌더링마다 다른 해시가 생성되어 무한 루프 발생
|
|
const currentHash = JSON.stringify(localInitData);
|
|
const hasForceFlag = _forceLocalInit !== undefined;
|
|
|
|
// 전역 추적: 컴포넌트와 무관하게 데이터 해시로만 추적 (같은 데이터는 한 번만 처리)
|
|
// - extends 레이아웃에서 여러 컴포넌트가 동일한 _localInit을 받을 수 있음
|
|
// - 컴포넌트 ID 기반 추적은 각 컴포넌트가 독립적으로 처리하여 중복 적용됨
|
|
// - 데이터 해시만으로 추적하여, 같은 데이터는 어떤 컴포넌트에서든 한 번만 처리
|
|
const tracking = getLocalInitTracking();
|
|
|
|
// 추적 키는 데이터 해시 + 타임스탬프 (컴포넌트 ID 제외)
|
|
// refetchOnMount 시 _forceLocalInit 타임스탬프가 변경되면 재적용됨
|
|
const trackingKey = `${currentHash}:${_forceLocalInit || 'no-force'}`;
|
|
|
|
// @since engine-v1.54.7: 적용 판정을 전역 해시 단독에서 (전역 해시 + 인스턴스 기록) 으로 확장
|
|
// - apply: 아무도 적용하지 않은 payload → 종전과 동일 (저장소 A + B + 캐시 무효화)
|
|
// - prune: 다른 인스턴스가 이미 적용 → 이 인스턴스의 저장소 A 에서 payload 키 공간만 제거
|
|
// - skip : 이 인스턴스가 이미 처리 → 무동작
|
|
const localInitAction = resolveLocalInitAction({
|
|
globalTrackedKey: tracking.hash,
|
|
instanceHandledKey: localInitHandledKeyRef.current,
|
|
trackingKey,
|
|
});
|
|
const shouldApply = localInitAction === 'apply';
|
|
|
|
if (shouldApply) {
|
|
localInitHandledKeyRef.current = trackingKey;
|
|
// 전역 추적 업데이트
|
|
tracking.hash = trackingKey;
|
|
tracking.timestamp = _forceLocalInit;
|
|
|
|
// 로컬 ref도 업데이트 (같은 인스턴스 내 중복 방지)
|
|
localInitHashRef.current = currentHash;
|
|
if (hasForceFlag) {
|
|
forceInitAppliedRef.current = true;
|
|
}
|
|
|
|
// _merge 메타데이터 추출 (병합 전략: "replace" | "shallow" | "deep", 기본값: "shallow")
|
|
const { _merge: initMergeMode, ...initDataWithoutMeta } = localInitData as Record<string, any>;
|
|
const mergeStrategy = initMergeMode || 'shallow';
|
|
|
|
// 새 데이터로 로컬 상태 병합 (기존 loadingActions 등 시스템 상태는 유지)
|
|
// hasChanges를 false로 초기화 (폼 dirty 추적 리셋)
|
|
setLocalDynamicState(prev => {
|
|
if (mergeStrategy === 'replace') {
|
|
// replace: 기존 상태 완전 교체 (loadingActions만 보존)
|
|
return {
|
|
loadingActions: prev.loadingActions || {},
|
|
...initDataWithoutMeta,
|
|
hasChanges: false,
|
|
};
|
|
} else if (mergeStrategy === 'deep') {
|
|
// deep: 중첩 객체 보존하면서 병합
|
|
return deepMergeState(prev, {
|
|
...initDataWithoutMeta,
|
|
hasChanges: false,
|
|
});
|
|
} else {
|
|
// shallow (기본값): 최상위 키만 덮어쓰기 (기존 키 보존)
|
|
return {
|
|
...prev,
|
|
...initDataWithoutMeta,
|
|
hasChanges: false,
|
|
};
|
|
}
|
|
});
|
|
|
|
// 전역 상태도 함께 업데이트 (컴포넌트 로컬 상태와 전역 상태 동기화)
|
|
// G7Core.state.get()._local에서도 데이터를 조회할 수 있도록 함
|
|
if (dataContext._globalSetState) {
|
|
// @since engine-v1.54.5: 병합 base 는 렌더 시점 스냅샷(dataContext._global._local)이 아니라
|
|
// **쓰기 시점의 canonical _local(prev._local)** 이어야 한다.
|
|
//
|
|
// setGlobalState 는 updates 를 얕게 펼치므로 `{ _local: X }` 는 저장소 B 를 통째로 교체한다.
|
|
// 이 effect 는 마운트 후(렌더 커밋 뒤)에 실행되는데, 그 사이에 init_actions 가 URL query 로
|
|
// _local 을 시드해 두었다면(목록 화면의 필터·정렬 컨트롤) 렌더 시점 스냅샷을 base 로 쓴 교체가
|
|
// 그 시드를 통째로 되돌린다 — 뒤로가기 복귀 시 URL·목록은 필터가 걸린 상태인데 필터 컨트롤만
|
|
// 기본값으로 표시되는 결함(#492 D-20). 함수형 업데이트로 prev 를 읽어 그 역전을 제거한다.
|
|
dataContext._globalSetState((prev: Record<string, any>) => {
|
|
const currentGlobalLocal = (prev as any)?._local || {};
|
|
let globalLocalUpdate: Record<string, any>;
|
|
if (mergeStrategy === 'replace') {
|
|
globalLocalUpdate = { ...initDataWithoutMeta, hasChanges: false };
|
|
} else if (mergeStrategy === 'deep') {
|
|
globalLocalUpdate = deepMergeState(currentGlobalLocal, { ...initDataWithoutMeta, hasChanges: false });
|
|
} else {
|
|
globalLocalUpdate = { ...currentGlobalLocal, ...initDataWithoutMeta, hasChanges: false };
|
|
}
|
|
return { ...prev, _local: globalLocalUpdate };
|
|
});
|
|
}
|
|
|
|
// _localInit 데이터가 변경되면 DataBindingEngine 캐시 무효화
|
|
// 이전 페이지의 _local 값이 캐시에 남아있으면 새 데이터가 아닌 캐시된 값을 반환하는 버그 방지
|
|
// 예: Guest 역할 편집 → Admin 역할 편집 시 permission_ids가 캐시된 빈 배열로 반환되는 문제
|
|
bindingEngine.invalidateCacheByKeys(['_local']);
|
|
|
|
logger.log('_localInit applied (data changed):', Object.keys(localInitData));
|
|
} else if (localInitAction === 'prune') {
|
|
// @since engine-v1.54.7
|
|
// 다른 인스턴스가 이 payload 를 이미 적용했다. 저장소 B(globalState._local)는 갱신되어
|
|
// 있으나, 이 인스턴스의 저장소 A(localDynamicState)에는 자동바인딩이 남긴 stale leaf 가
|
|
// 그대로다. 병합은 A 우선(`deepMergeState(dataContext._local, dynamicState)`)이므로
|
|
// 그대로 두면 신선한 B 를 stale A 가 덮는다 — 화면만 옛 값이 되는 조용한 결함.
|
|
//
|
|
// 값을 다시 쓰지 않고 payload 키 공간만 제거한다. 재적용을 택하면 늦게 마운트된
|
|
// 인스턴스가 소비된 과거 payload 를 B 에 되쓰면서 그 사이의 사용자 편집을 되돌린다
|
|
// (localInitSlot.ts 의 `consumed → 교체` 규칙이 막고 있는 회귀).
|
|
localInitHandledKeyRef.current = trackingKey;
|
|
|
|
const { _merge: _pruneMergeMode, ...pruneKeys } = localInitData as Record<string, any>;
|
|
|
|
// 상태 갱신은 updater 로 넘긴다 (@since engine-v1.54.8) — 같은 commit 에서
|
|
// useLayoutEffect 가 큐에 넣은 제거가 아직 반영되기 전일 수 있으므로,
|
|
// 커밋 시점 스냅샷을 직접 쓰면 그 제거를 되살릴 수 있다.
|
|
// removeMatchingLeafKeys 는 제거할 것이 없으면 원본 참조를 그대로 돌려주므로,
|
|
// no-op 일 때 React 가 바로 bail out 한다 (불필요한 리렌더 없음).
|
|
setLocalDynamicState(prev => removeMatchingLeafKeys(prev, pruneKeys));
|
|
|
|
// 캐시 무효화 여부는 커밋된 현재 값 기준으로 동기 판정한다 — updater 안에서
|
|
// 플래그를 세우면 StrictMode 이중 호출과 배치 지연 때문에 신뢰할 수 없다.
|
|
// 헬퍼가 no-op 시 원본 참조를 반환하므로 참조 비교가 곧 "실제 제거 여부" 다.
|
|
// (내용 비교나 얕은 비교로 판정하면 중첩 경로에서 사본이 새로 생겨 거짓 양성이 된다.)
|
|
const didPrune = removeMatchingLeafKeys(localDynamicState, pruneKeys) !== localDynamicState;
|
|
|
|
if (didPrune) {
|
|
// 병합 결과가 바뀌므로 _local 경로 캐시를 무효화한다. 단순 경로 바인딩
|
|
// (`{{_local.form.x}}` 등)은 캐시 대상이라, 무효화하지 않으면 제거 후에도
|
|
// 캐시된 옛 값이 반환되어 화면이 갱신되지 않는다 (troubleshooting-cache 사례 8·9).
|
|
bindingEngine.invalidateCacheByKeys(['_local']);
|
|
logger.log('_localInit pruned (applied by another renderer):', Object.keys(pruneKeys));
|
|
}
|
|
}
|
|
|
|
// engine-v1.41.0: _localInit 처리 완료 후 ref 갱신
|
|
// hash tracking에 의해 shouldApply가 false(skip)된 경우에도 갱신하여
|
|
// extendedDataContext useMemo가 정상 병합(deepMergeState)을 재개하도록 함
|
|
lastProcessedInitRef.current = dataContext._localInit;
|
|
|
|
// @since engine-v1.52.2: 이 슬롯을 관측했음을 생산부(updateTemplateData)에 알림.
|
|
// 생산부는 아직 관측되지 않은 슬롯만 누적 병합하고, 관측된 슬롯은 교체한다.
|
|
// skip(동일 해시)된 경우에도 그 payload는 이미 _local에 반영되어 있으므로 소비된 것이다.
|
|
markLocalInitConsumed(dataContext._localInit);
|
|
}
|
|
}, [dataContext._localInit, parentDataContext, bindingEngine]);
|
|
|
|
// 페이지 전환 상태 구독 (blur_until_loaded 지원)
|
|
const { isTransitioning } = useTransitionState();
|
|
|
|
// 부모의 Form 컨텍스트 (자동 바인딩용)
|
|
// prop으로 전달된 것이 있으면 우선 사용 (useMemo 타이밍 문제 해결)
|
|
const contextFormContext = useFormContext();
|
|
const parentFormContext = parentFormContextProp ?? contextFormContext;
|
|
|
|
// 반응형 상태 구독
|
|
const { width: responsiveWidth } = useResponsive();
|
|
|
|
// Sortable 컨텍스트 (drag handle 자동 지원용)
|
|
const sortableContext = useSortableContext();
|
|
|
|
/**
|
|
* 반응형 오버라이드가 적용된 컴포넌트 정의
|
|
*
|
|
* responsive 속성이 있으면 현재 화면 너비에 맞는 오버라이드를 적용합니다.
|
|
* 처리 순서: iterator 변환 → responsive 오버라이드 → 데이터 바인딩 → 다국어 → 액션 바인딩
|
|
*/
|
|
const effectiveComponentDef = useMemo(() => {
|
|
// type: "iterator"를 iteration 속성으로 변환
|
|
// JSON에서 { type: "iterator", data: "...", itemName: "..." } 형태로 정의된 경우 처리
|
|
let baseDef = componentDef;
|
|
if ((componentDef as any).type === 'iterator' && (componentDef as any).data) {
|
|
const { data, itemName, indexName, ...rest } = componentDef as any;
|
|
baseDef = {
|
|
...rest,
|
|
iteration: {
|
|
source: data,
|
|
item_var: itemName || 'item',
|
|
index_var: indexName,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (!baseDef.responsive) {
|
|
return baseDef;
|
|
}
|
|
|
|
const matchedKey = responsiveManager.getMatchingKey(baseDef.responsive, responsiveWidth);
|
|
if (!matchedKey) {
|
|
return baseDef;
|
|
}
|
|
|
|
const override = baseDef.responsive[matchedKey];
|
|
return {
|
|
...baseDef,
|
|
// props는 얕은 머지 (지정한 속성만 대체, 미지정 속성 유지)
|
|
props: { ...baseDef.props, ...override.props },
|
|
// children, text, if, iteration은 완전 교체
|
|
children: override.children ?? baseDef.children,
|
|
text: override.text ?? baseDef.text,
|
|
if: override.if ?? baseDef.if,
|
|
iteration: override.iteration ?? baseDef.iteration,
|
|
// 편집기 path 발급용 — 자식이 **분기 children 으로 교체**된 경우에만 분기 키를
|
|
// 남긴다(자식 path 에 `.responsive.{key}` prefix 를 끼워 base children 과 구분).
|
|
// props-만 오버라이드(override.children 부재)는 base children 그대로라 base path
|
|
// 유지.
|
|
__responsiveChildrenKey: override.children !== undefined ? matchedKey : undefined,
|
|
};
|
|
}, [componentDef, responsiveWidth]);
|
|
|
|
/**
|
|
* 로컬 상태 업데이트 핸들러
|
|
*
|
|
* setState 호출 시 전달된 모든 속성을 동적으로 병합합니다.
|
|
* 객체 또는 함수형 업데이트(prev => newState)를 지원합니다.
|
|
*
|
|
* 병합 방식:
|
|
* - 기본값: 깊은 병합 (deepMergeState 사용)
|
|
* - __mergeMode: 'shallow'가 포함된 경우: 얕은 병합 (프리셋 적용, 필터 초기화 등)
|
|
*
|
|
* ActionDispatcher의 setState에서 merge: "shallow" 옵션을 사용하면
|
|
* __mergeMode: 'shallow'가 payload에 포함됩니다.
|
|
*
|
|
* 예: setState({ apiError: '...' }), setState(prev => ({ ...prev, count: prev.count + 1 }))
|
|
*/
|
|
const handleLocalSetState = useCallback((updates: any) => {
|
|
// __setStateId 추출 (DevTools 렌더링 추적용)
|
|
const setStateId = updates?.__setStateId;
|
|
|
|
setLocalDynamicState((prev) => {
|
|
// __g7PendingLocalState가 있으면 prev와 병합하여 최신 상태 반영
|
|
// setParentLocal 호출 직후 함수형 업데이트가 호출되면 prev에는 아직 반영 안됨
|
|
//
|
|
// CRITICAL: setLocal()이 호출된 경우 __g7SetLocalOverrideKeys가 존재함.
|
|
// 이 경우 __g7PendingLocalState는 globalState._local 기반의 전체 스냅샷이므로
|
|
// prev에 있는 사용자 설정값(expandedRows 등 배열)을 초기값으로 덮어쓰는 문제 발생.
|
|
// → setLocal 변경분(__g7SetLocalOverrideKeys)만 병합하여 다른 키 보존.
|
|
// setParentLocal()은 __g7SetLocalOverrideKeys를 설정하지 않으므로 기존 동작 유지.
|
|
const pendingState = (window as any).__g7PendingLocalState;
|
|
const setLocalOverrides = (window as any).__g7SetLocalOverrideKeys;
|
|
const pendingToMerge = setLocalOverrides || pendingState;
|
|
const effectivePrev = pendingToMerge ? deepMergeState(prev, pendingToMerge) : prev;
|
|
|
|
// 함수형 업데이트 지원: updates가 함수이면 effectivePrev를 전달하여 호출
|
|
// 함수 결과도 깊은 병합하여 기존 상태 보존 (Form 자동 바인딩 시 다른 필드 유지)
|
|
if (typeof updates === 'function') {
|
|
const funcResult = updates(effectivePrev);
|
|
const merged = deepMergeState(effectivePrev, funcResult);
|
|
return merged;
|
|
}
|
|
|
|
// __mergeMode, __setStateId 확인: 'replace' | 'shallow' | 'deep' (기본값)
|
|
const { __mergeMode, __setStateId: _, ...payload } = updates;
|
|
|
|
let result;
|
|
if (__mergeMode === 'replace') {
|
|
// 완전 교체: 기존 상태 무시, payload로 완전 교체
|
|
// 폼 초기화, 서버 데이터로 전체 교체 시 사용
|
|
result = payload;
|
|
} else if (__mergeMode === 'shallow') {
|
|
// 얕은 병합: 프리셋 적용, 필터 초기화 등에서 사용
|
|
// 기존 상태의 최상위 키만 덮어씀
|
|
result = { ...effectivePrev, ...payload };
|
|
} else {
|
|
// 깊은 병합 (기본값): 중첩 객체의 기존 필드를 유지하면서 업데이트
|
|
result = deepMergeState(effectivePrev, payload);
|
|
}
|
|
|
|
// engine-v1.17.9: __mergeMode가 명시된 호출(커스텀 핸들러의 명시적 setState)일 때
|
|
// __g7ForcedLocalFields도 payload로 업데이트하여 최신 값이 우선되도록 함
|
|
// - sequence 내 setState가 설정한 forcedLocalFields를 커스텀 핸들러가 덮어쓸 수 있게 함
|
|
// - __mergeMode 없는 호출(deep 기본값 = handleSetState 내부 또는 Form 자동 바인딩)은 건너뜀
|
|
// (handleSetState는 이미 자체적으로 forcedLocalFields를 설정함)
|
|
if (__mergeMode !== undefined) {
|
|
const existingForced = (window as any).__g7ForcedLocalFields;
|
|
if (existingForced) {
|
|
(window as any).__g7ForcedLocalFields = { ...existingForced, ...payload };
|
|
}
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
// DevTools: 상태 변경 완료 알림 (비동기로 렌더링 후 호출)
|
|
if (setStateId) {
|
|
const devTools = getDevTools();
|
|
if (devTools?.isEnabled()) {
|
|
// 컴포넌트 렌더링 추적
|
|
const componentId = effectiveComponentDef.id || 'unknown';
|
|
const componentName = effectiveComponentDef.name || 'Unknown';
|
|
|
|
// 다음 틱에서 completeStateChange 호출 (렌더링 완료 후)
|
|
setTimeout(() => {
|
|
devTools.trackComponentRender?.(
|
|
componentId,
|
|
componentName,
|
|
0, // 렌더링 시간은 측정하기 어려움
|
|
['_local'], // 접근한 상태 경로
|
|
[] // 바인딩 표현식
|
|
);
|
|
devTools.completeStateChange?.(setStateId);
|
|
}, 0);
|
|
}
|
|
}
|
|
}, [effectiveComponentDef.id, effectiveComponentDef.name]);
|
|
|
|
/**
|
|
* 실제 사용할 상태와 setState
|
|
*
|
|
* parentComponentContext가 전달되면 부모의 상태를 사용하고,
|
|
* 그렇지 않으면 자체 로컬 상태를 사용합니다.
|
|
*/
|
|
const dynamicState = parentComponentContext?.state ?? localDynamicState;
|
|
const handleSetState = parentComponentContext?.setState ?? handleLocalSetState;
|
|
|
|
/**
|
|
* _local을 포함한 확장된 데이터 컨텍스트
|
|
*
|
|
* 레이아웃 JSON에서 {{_local.xxx}} 바인딩을 사용할 수 있도록 합니다.
|
|
* dataContext._local이 있으면 (init_actions에서 설정된 경우) 병합하여 사용합니다.
|
|
*
|
|
* _computedDefinitions가 있으면 최신 _local을 기반으로 _computed를 재계산합니다.
|
|
* 이를 통해 _local 상태 변경 시 _computed 값도 자동으로 갱신됩니다.
|
|
*/
|
|
const extendedDataContext = useMemo(() => {
|
|
// dataContext._local이 있으면 dynamicState와 깊은 병합 (init_actions 초기값 + 컴포넌트 상태)
|
|
// 얕은 병합({ ...a, ...b })은 form 같은 중첩 객체를 완전히 대체하여 필드 손실 발생
|
|
// 깊은 병합(deepMergeState)은 중첩 객체의 기존 필드를 유지하면서 업데이트
|
|
//
|
|
// 병합 순서: dataContext._local → dynamicState → __g7ForcedLocalFields
|
|
// 1. dataContext._local: 전역 상태에서 전달된 _local (init_actions, 일반 setLocal)
|
|
// 2. dynamicState: 컴포넌트 로컬 상태 (Form 자동 바인딩 - 사용자 입력 우선)
|
|
// 3. __g7ForcedLocalFields: 비동기 콜백에서 setLocal fallback으로 설정된 특정 필드만 (최우선)
|
|
//
|
|
// engine-v1.17.4: 비동기 콜백에서 setLocal 호출 시 dynamicState가 stale 값을 가질 수 있음
|
|
// __g7ForcedLocalFields는 업데이트된 필드만 저장하여, 해당 필드만 우선 적용
|
|
// 다른 필드의 사용자 입력은 dynamicState에서 보존됨
|
|
// engine-v1.41.0: _localInit이 존재하지만 아직 localDynamicState에 반영되지 않은 경우
|
|
// stale dynamicState(init_actions 기본값의 빈 배열 등)가 dataContext._local의
|
|
// API 데이터를 덮어쓰는 것을 방지 (CKEditor 등 플러그인 onMount setLocal 경합 해소)
|
|
//
|
|
// 첫 렌더 시 dynamicState는:
|
|
// - 신규 컴포넌트: { loadingActions: {} } (useState 초기값) → 건너뛰어도 결과 동일
|
|
// - _fromBase 컴포넌트: 이전 페이지 stale 데이터 → 건너뛰는 것이 정확
|
|
// dataContext._local은 handleRouteChange에서 init_actions + API 데이터가
|
|
// 완전히 머지된 상태이므로 단독 사용 시 불완전하지 않음
|
|
const hasUnappliedInit = dataContext._localInit
|
|
&& dataContext._localInit !== lastProcessedInitRef.current;
|
|
let localState: Record<string, any>;
|
|
if (hasUnappliedInit && dataContext._local) {
|
|
localState = dataContext._local;
|
|
} else {
|
|
localState = dataContext._local
|
|
? deepMergeState(dataContext._local, dynamicState)
|
|
: dynamicState;
|
|
}
|
|
|
|
// __g7ForcedLocalFields가 있으면 해당 필드만 최우선으로 병합 (비동기 setLocal fallback 지원)
|
|
const forcedLocalFields = (window as any).__g7ForcedLocalFields;
|
|
if (forcedLocalFields) {
|
|
localState = deepMergeState(localState, forcedLocalFields);
|
|
}
|
|
|
|
const result: Record<string, any> = {
|
|
...dataContext,
|
|
_local: localState,
|
|
};
|
|
|
|
// _computedDefinitions가 있으면 최신 _local로 _computed 재계산
|
|
const computedDefinitions = (dataContext as any)._computedDefinitions;
|
|
if (computedDefinitions && Object.keys(computedDefinitions).length > 0) {
|
|
const bindingEngine = new DataBindingEngine();
|
|
// 재계산용 컨텍스트 (최신 _local, _isolated 포함)
|
|
const computedContext = {
|
|
...result,
|
|
...(isolatedContext ? { _isolated: isolatedContext.state } : {}),
|
|
};
|
|
const newComputed: Record<string, any> = {};
|
|
|
|
// DevTools: 성능 최적화 - 활성화 여부를 루프 밖에서 한 번만 확인
|
|
const devToolsForComputed = getDevTools();
|
|
const isDevToolsEnabled = devToolsForComputed?.isEnabled() ?? false;
|
|
|
|
for (const [key, expression] of Object.entries(computedDefinitions)) {
|
|
// DevTools: 성능 최적화 - 활성화된 경우에만 시간 측정
|
|
const computeStartTime = isDevToolsEnabled ? Date.now() : 0;
|
|
const previousValue = isDevToolsEnabled ? (dataContext as any)._computed?.[key] : undefined;
|
|
let computedError: string | undefined;
|
|
|
|
try {
|
|
if (typeof expression === 'string') {
|
|
if (expression.startsWith('{{') && expression.endsWith('}}')) {
|
|
// {{...}} 형태의 표현식 평가
|
|
const innerExpression = (expression as string).slice(2, -2).trim();
|
|
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
|
|
// `|` 를 JS 비트 OR 로 보므로 인자 있는 파이프는 예외로 아래 catch 에 걸려
|
|
// computed 가 이전 값에 고정된다(값이 바뀌지 않는 조용한 실패).
|
|
// @since engine-v1.54.10
|
|
const result = hasPipes(innerExpression)
|
|
? bindingEngine.evaluatePipeExpression(innerExpression, computedContext, { skipCache: true })
|
|
: bindingEngine.evaluateExpression(innerExpression, computedContext, { skipCache: true });
|
|
newComputed[key] = result;
|
|
} else {
|
|
// 순수 문자열은 그대로 사용
|
|
newComputed[key] = expression;
|
|
}
|
|
} else if (expression && typeof expression === 'object' && '$switch' in expression) {
|
|
// $switch 형태 처리
|
|
const result = bindingEngine.resolveSwitch(expression as any, computedContext, { skipCache: true });
|
|
newComputed[key] = result;
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`Failed to recalculate computed value: ${key}`, error);
|
|
// 에러 시 기존 값 유지
|
|
newComputed[key] = (dataContext as any)._computed?.[key];
|
|
computedError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
// DevTools: Computed 추적 - 값이 변경되었거나 에러가 발생한 경우에만 추적
|
|
if (isDevToolsEnabled && (computedError || previousValue !== newComputed[key])) {
|
|
const computeEndTime = Date.now();
|
|
const duration = computeEndTime - computeStartTime;
|
|
|
|
devToolsForComputed.trackComputedProperty?.(
|
|
key,
|
|
typeof expression === 'string' ? expression : JSON.stringify(expression),
|
|
[{ type: '_local', path: '_local', value: result._local }],
|
|
newComputed[key],
|
|
duration,
|
|
effectiveComponentDef.id,
|
|
computedError
|
|
);
|
|
|
|
// 값이 변경되었으면 재계산 추적
|
|
if (previousValue !== newComputed[key]) {
|
|
devToolsForComputed.trackComputedRecalc?.(
|
|
key,
|
|
'dependency-change',
|
|
previousValue,
|
|
newComputed[key],
|
|
duration,
|
|
{ type: '_local', path: '_local' },
|
|
effectiveComponentDef.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
result._computed = newComputed;
|
|
}
|
|
|
|
// isolatedContext가 있으면 _isolated 네임스페이스 추가
|
|
if (isolatedContext) {
|
|
result._isolated = isolatedContext.state;
|
|
}
|
|
|
|
// effectiveParentDataContext가 있으면 $parent 네임스페이스 추가
|
|
// 모달 등에서 {{$parent._local.xxx}} 형태로 부모 상태에 접근 가능
|
|
// ParentContextProvider 내부에서는 훅에서 최신 값을 가져옴
|
|
if (effectiveParentDataContext) {
|
|
result.$parent = effectiveParentDataContext;
|
|
}
|
|
|
|
// extensionPointProps가 있으면 데이터 컨텍스트에 추가 (표현식 평가)
|
|
// extension_point 컴포넌트의 props가 주입된 컴포넌트에서 {{extensionPointProps.xxx}}로 접근 가능
|
|
// LayoutExtensionService에서 extension_point의 props를 injectedComponent.extensionPointProps에 추가
|
|
// @since engine-v1.28.0: resolveObject()로 표현식 재귀 평가 (일반 props와 동일 수준)
|
|
// @since engine-v1.50.0: **편집 모드 전용** `$t:` 키 2차
|
|
// 해석 — `extensionPointProps.placeholder: "$t:board.form.content_placeholder"`
|
|
// 같은 평문 i18n 키를 핸들러(예: ckeditor5 initEditor) 가 받기 전에 풀어
|
|
// 편집기 캔버스에서 raw 키로 노출되지 않도록 한다. 일반 사이트 렌더는
|
|
// `isEditMode === false` 라 본 분기 미진입 → 동작 100% 보존.
|
|
if (componentDef.extensionPointProps) {
|
|
const resolved = bindingEngine.resolveObject(
|
|
componentDef.extensionPointProps,
|
|
result,
|
|
isInsideIteration ? { skipCache: true } : undefined
|
|
);
|
|
result.extensionPointProps = isEditMode && translationEngine
|
|
? resolveTranslationsInValue(resolved, translationEngine, translationContext, result)
|
|
: resolved;
|
|
}
|
|
|
|
// extensionPointCallbacks가 있으면 데이터 컨텍스트에 추가 (평가 없이 그대로 전달)
|
|
// 액션 객체는 ActionDispatcher가 실행 시점에 표현식을 평가하므로 사전 평가하면 안 됨
|
|
// @since engine-v1.28.0
|
|
if (componentDef.extensionPointCallbacks) {
|
|
result.extensionPointCallbacks = componentDef.extensionPointCallbacks;
|
|
}
|
|
|
|
// **편집 모드 전용** $templateId / $locale 자동 주입 — `{{삼항 ? '$t:A' : '$t:B'}}`
|
|
// 같은 표현식의 `$t()` 함수가 templateId 폴백을 위해
|
|
// `window.__templateApp.getConfig()` 를 호출하지 않고 컨텍스트에서 바로 가져올
|
|
// 수 있도록 보장.
|
|
//
|
|
// 동기: 편집기 캔버스의 격리 `__templateApp` façade 에는 `getConfig` 가 없어
|
|
// 폴백이 빈 templateId 를 반환 → 사전 조회 실패 → raw key 노출. dataContext 에
|
|
// `$templateId` 가 미리 있으면 그 값을 우선 사용한다(DataBindingEngine 의
|
|
// `$t` 함수 line 1145). 일반 사이트 렌더는 `isEditMode === false` 이므로 본
|
|
// 분기 미진입 → 호스트 `window.__templateApp.getConfig()` fallback 그대로 사용.
|
|
//
|
|
// @since engine-v1.50.0 (라우트 10/12 글쓰기/글수정 i18n
|
|
// `$t:board.write` raw 키 노출 결함 수정. 호스트 사이트 영향 0)
|
|
if (isEditMode) {
|
|
if (result.$templateId === undefined && translationContext?.templateId) {
|
|
result.$templateId = translationContext.templateId;
|
|
}
|
|
if (result.$locale === undefined && translationContext?.locale) {
|
|
result.$locale = translationContext.locale;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
// parentContextHook?.version: $parent._local 변경 시 useMemo 재계산 트리거
|
|
// effectiveParentDataContext는 동일한 객체 참조를 반환할 수 있으므로
|
|
// version 변경으로 강제 재계산
|
|
// componentDef.extensionPointProps: extension_point props 변경 시 재계산
|
|
// componentDef.extensionPointCallbacks: extension_point callbacks 변경 시 재계산
|
|
}, [dataContext, dynamicState, isolatedContext?.state, effectiveParentDataContext, parentContextHook?.version, componentDef.extensionPointProps, componentDef.extensionPointCallbacks]);
|
|
|
|
// 매 렌더링마다 최신 _local 상태를 ref에 업데이트
|
|
// useCallback으로 캐싱된 componentContext에서도 stateRef.current로 최신 상태 접근 가능
|
|
latestLocalStateRef.current = extendedDataContext._local;
|
|
|
|
// 매 렌더링마다 최신 _computed 상태를 ref에 업데이트
|
|
// expandChildren에서 _computed 기반 표현식 평가 시 최신 값 접근 가능
|
|
latestComputedRef.current = (extendedDataContext as any)._computed || {};
|
|
|
|
// 매 렌더링마다 최신 부모 데이터 컨텍스트를 ref에 업데이트
|
|
// 모달 내 useCallback 등에서 stale closure 없이 최신 부모 상태 접근 가능
|
|
parentDataContextRef.current = effectiveParentDataContext;
|
|
|
|
/**
|
|
* DevTools에 _local/_computed 상태 업데이트
|
|
*
|
|
* 모든 DynamicRenderer에서 DevTools에 상태를 등록합니다.
|
|
* 마지막으로 업데이트된 상태가 DevTools에 표시됩니다.
|
|
* 이를 통해 DevTools 패널에서 _local과 _computed 상태를 실시간으로 확인할 수 있습니다.
|
|
*/
|
|
useEffect(() => {
|
|
const devTools = getDevTools();
|
|
if (!devTools?.isEnabled()) return;
|
|
|
|
// _local 상태 업데이트 (extendedDataContext._local에 모든 로컬 상태가 병합되어 있음)
|
|
const localState = extendedDataContext._local;
|
|
if (localState) {
|
|
devTools.updateLocalState(localState);
|
|
}
|
|
|
|
// _computed 상태 업데이트 (extendedDataContext에서 재계산된 값 사용)
|
|
const computedState = (extendedDataContext as any)._computed;
|
|
if (computedState) {
|
|
devTools.updateComputedState(computedState);
|
|
}
|
|
|
|
// $parent 컨텍스트 업데이트 (모달/자식 레이아웃에서 부모 상태 접근용)
|
|
// DevTools에서 {{$parent._local.xxx}} 형태의 바인딩 디버깅 가능
|
|
// effectiveParentDataContext가 있는 경우에만 업데이트 (최상위 레이아웃이 덮어쓰지 않도록)
|
|
if (effectiveParentDataContext) {
|
|
devTools.updateParentContext?.(effectiveParentDataContext);
|
|
}
|
|
|
|
// 상태 계층 추적 (g7-state-hierarchy용)
|
|
const componentId = effectiveComponentDef.id || `comp-${Date.now()}`;
|
|
const componentName = effectiveComponentDef.name || 'Unknown';
|
|
|
|
// 컴포넌트가 사용하는 상태 소스 추적
|
|
const stateSource = {
|
|
global: Object.keys(dataContext._global || {}),
|
|
local: Object.keys(localState || {}),
|
|
context: parentComponentContext ? Object.keys(parentComponentContext.state || {}) : [],
|
|
};
|
|
|
|
const stateProvider = {
|
|
type: parentComponentContext ? 'componentContext' as const : 'dynamicState' as const,
|
|
hasComponentContext: !!parentComponentContext,
|
|
parentId: parentComponentContext ? 'parent' : undefined,
|
|
};
|
|
|
|
devTools.trackComponentStateSource?.(componentId, componentName, stateSource, stateProvider);
|
|
|
|
// dynamicState 추적
|
|
if (dynamicState && Object.keys(dynamicState).length > 0) {
|
|
devTools.trackDynamicState?.(componentId, dynamicState);
|
|
}
|
|
|
|
// 컨텍스트 플로우 추적 (g7-context-flow용)
|
|
const hasExpandChildren = !!(effectiveComponentDef.expandChildren || effectiveComponentDef.props?.expandChildren);
|
|
devTools.trackContextFlow?.(
|
|
componentId,
|
|
componentName,
|
|
!!parentComponentContext, // contextReceived
|
|
hasExpandChildren, // passedToChildren
|
|
true // usedInRender (렌더링되었으므로 true)
|
|
);
|
|
}, [extendedDataContext._local, dataContext, effectiveComponentDef.id, effectiveComponentDef.name, parentComponentContext, dynamicState, effectiveComponentDef.expandChildren, effectiveComponentDef.props?.expandChildren, effectiveParentDataContext]);
|
|
|
|
/**
|
|
* 컴포넌트 컨텍스트 (state, setState, stateRef)
|
|
*
|
|
* ActionDispatcher에서 deepMergeWithState 수행 시 사용되는 상태입니다.
|
|
* extendedDataContext._local을 사용하여 init_actions에서 설정한 값과
|
|
* 컴포넌트 로컬 상태가 모두 포함되도록 합니다.
|
|
*
|
|
* 이전 문제: dynamicState만 사용 → 첫 클릭 시 init_actions 값 손실
|
|
* 해결: extendedDataContext._local 사용 → init_actions 값 + 컴포넌트 상태 병합
|
|
*
|
|
* localDynamicState 의존성 추가: handleLocalSetState가 호출되면 useMemo가 재실행되어
|
|
* 새 componentContext 객체가 생성됨. 이를 통해 자식 컴포넌트(DataGrid 등)의
|
|
* useCallback 의존성(__componentContext?.state)이 변경을 감지할 수 있음.
|
|
*
|
|
* 스프레드 연산자로 state를 새 객체로 생성하여 참조 변경 보장
|
|
*
|
|
* stateRef 추가: DataGrid의 useCallback이 componentContext를 캐싱해도
|
|
* stateRef.current를 통해 항상 최신 _local 상태에 접근 가능.
|
|
* renderExpandContent에서 expandChildren 렌더링 시 최신 상태 반영.
|
|
*
|
|
* computedRef 추가: _computed 값도 _local 기반으로 계산되므로
|
|
* expandChildren에서 _computed 표현식 평가 시 최신 값 접근 가능.
|
|
*/
|
|
const componentContext = useMemo(() => ({
|
|
state: { ...extendedDataContext._local },
|
|
setState: handleSetState,
|
|
stateRef: latestLocalStateRef,
|
|
computedRef: latestComputedRef,
|
|
// 격리된 상태 컨텍스트 (target: "isolated" setState 지원)
|
|
isolatedContext,
|
|
// 부모 데이터 컨텍스트 전달 (engine-v1.19.1)
|
|
// DataGrid 등 복합 컴포넌트의 cellChildren/footerCells/footerCardChildren에서
|
|
// iteration 패턴 사용 시 데이터소스(order, active_carriers 등)에 접근 필요.
|
|
// getEffectiveContext()에서 데이터소스 키를 최상위 컨텍스트에 병합함.
|
|
parentDataContext: extendedDataContext,
|
|
}), [extendedDataContext._local, handleSetState, localDynamicState, isolatedContext, extendedDataContext]);
|
|
|
|
/**
|
|
* 조건부 렌더링 평가
|
|
*
|
|
* effectiveComponentDef.if 또는 conditions 속성을 평가하여 렌더링 여부 결정
|
|
* evaluateRenderCondition 함수를 사용하여 if와 conditions 모두 지원
|
|
*
|
|
* @since engine-v1.10.0 - conditions 속성 지원 추가
|
|
*/
|
|
const shouldRender = useMemo(() => {
|
|
const result = evaluateRenderCondition(
|
|
{
|
|
if: effectiveComponentDef.if,
|
|
condition: effectiveComponentDef.condition,
|
|
conditions: effectiveComponentDef.conditions,
|
|
},
|
|
extendedDataContext,
|
|
bindingEngine,
|
|
effectiveComponentDef.id
|
|
);
|
|
|
|
// DevTools에 조건 추적 (if 또는 condition 또는 conditions 속성이 있는 경우)
|
|
const hasCondition = effectiveComponentDef.if || effectiveComponentDef.condition !== undefined || effectiveComponentDef.conditions;
|
|
if (hasCondition) {
|
|
const devTools = getDevTools();
|
|
if (devTools?.isEnabled()) {
|
|
// if 속성이 있으면 기존 방식으로 추적
|
|
if (effectiveComponentDef.if) {
|
|
devTools.trackIfCondition(
|
|
effectiveComponentDef.id || `if-${Date.now()}`,
|
|
effectiveComponentDef.if,
|
|
result
|
|
);
|
|
}
|
|
// TODO: conditions 속성은 DevTools Phase 5에서 별도 추적 구현 예정
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}, [effectiveComponentDef.if, effectiveComponentDef.conditions, extendedDataContext, bindingEngine, effectiveComponentDef.id]);
|
|
|
|
/**
|
|
* 슬롯 ID 평가
|
|
*
|
|
* slot 속성이 있으면 표현식을 평가하여 실제 슬롯 ID를 결정합니다.
|
|
* skipCache: true 옵션으로 캐시를 무시하여 항상 최신 값을 사용합니다.
|
|
*
|
|
* @since engine-v1.10.0
|
|
*/
|
|
const resolvedSlotId = useMemo(() => {
|
|
if (!effectiveComponentDef.slot) return null;
|
|
|
|
const slotExpr = effectiveComponentDef.slot;
|
|
|
|
// {{...}} 표현식인 경우 평가
|
|
if (slotExpr.startsWith('{{') && slotExpr.endsWith('}}')) {
|
|
const expr = slotExpr.slice(2, -2).trim();
|
|
try {
|
|
// DevTools 추적을 위한 컴포넌트 컨텍스트 정보
|
|
const slotTrackingInfo = {
|
|
componentId: effectiveComponentDef.id,
|
|
componentName: effectiveComponentDef.name,
|
|
propName: 'slot',
|
|
};
|
|
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
|
|
// `|` 를 JS 비트 OR 로 보므로 결과가 문자열이 아니게 되고, 아래 `typeof result === 'string'`
|
|
// 가드에 걸려 슬롯 미등록으로 조용히 흡수된다(컴포넌트가 화면에서 사라짐).
|
|
// @since engine-v1.54.10
|
|
const result = hasPipes(expr)
|
|
? bindingEngine.evaluatePipeExpression(expr, extendedDataContext, undefined, slotTrackingInfo)
|
|
: bindingEngine.evaluateExpression(expr, extendedDataContext, slotTrackingInfo);
|
|
logger.log(`[Slot] 표현식 평가: "${expr}" => "${result}" (컴포넌트: ${effectiveComponentDef.id})`);
|
|
return typeof result === 'string' ? result : null;
|
|
} catch (error) {
|
|
logger.warn(
|
|
`slot 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 정적 슬롯 ID
|
|
logger.log(`[Slot] 정적 슬롯 ID: "${slotExpr}" (컴포넌트: ${effectiveComponentDef.id})`);
|
|
return slotExpr;
|
|
}, [effectiveComponentDef.slot, effectiveComponentDef.id, extendedDataContext, bindingEngine]);
|
|
|
|
/**
|
|
* 슬롯 등록 키 (중복 등록 방지용)
|
|
*
|
|
* 동일한 컴포넌트가 동일한 슬롯에 중복 등록되는 것을 방지합니다.
|
|
*/
|
|
const slotRegistrationKey = useMemo(() => {
|
|
if (!resolvedSlotId || !effectiveComponentDef.id) return '';
|
|
return `${effectiveComponentDef.id}-${resolvedSlotId}-${JSON.stringify(effectiveComponentDef.slotOrder ?? 0)}`;
|
|
}, [resolvedSlotId, effectiveComponentDef.id, effectiveComponentDef.slotOrder]);
|
|
|
|
/**
|
|
* 이전 슬롯 ID 추적 (슬롯 변경 시 이전 슬롯에서 제거하기 위함)
|
|
*/
|
|
const prevSlotIdRef = useRef<string | null>(null);
|
|
|
|
/**
|
|
* 슬롯 등록/해제 처리
|
|
*
|
|
* - slot 속성이 있는 컴포넌트는 원래 위치에서 렌더링되지 않음
|
|
* - 대신 SlotContext에 등록되어 SlotContainer에서 렌더링됨
|
|
* - 슬롯 ID가 변경되면 이전 슬롯에서 제거 후 새 슬롯에 등록
|
|
*
|
|
* 중요: React Context 대신 window.__slotContextValue를 직접 사용
|
|
* - React의 리렌더링 시 Context 값이 defaultValue로 잠시 변경되는 타이밍 이슈 방지
|
|
* - SlotProvider가 항상 전역 변수에 최신 값을 유지
|
|
*/
|
|
useEffect(() => {
|
|
// 전역 SlotContext 값 직접 참조 (타이밍 이슈 방지)
|
|
const globalSlotContext = (window as any).__slotContextValue;
|
|
const isSlotEnabled = globalSlotContext?.isEnabled ?? false;
|
|
|
|
// 디버그: 슬롯 등록 조건 확인
|
|
if (effectiveComponentDef.slot) {
|
|
logger.log(`[Slot Registration] 컴포넌트: ${effectiveComponentDef.id}, isSlotEnabled: ${isSlotEnabled}, shouldRender: ${shouldRender}, resolvedSlotId: ${resolvedSlotId}`);
|
|
}
|
|
|
|
// 슬롯 컨텍스트가 비활성화되었거나 렌더링 조건이 false면 등록 안함
|
|
if (!isSlotEnabled || !shouldRender) {
|
|
if (effectiveComponentDef.slot) {
|
|
logger.log(`[Slot Registration] 등록 스킵 - isSlotEnabled: ${isSlotEnabled}, shouldRender: ${shouldRender}`);
|
|
}
|
|
// 이전에 등록된 경우 해제
|
|
if (prevSlotIdRef.current && effectiveComponentDef.id && globalSlotContext) {
|
|
globalSlotContext.unregisterFromSlot(prevSlotIdRef.current, effectiveComponentDef.id);
|
|
prevSlotIdRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 슬롯 ID가 없으면 등록 안함
|
|
if (!resolvedSlotId || !effectiveComponentDef.id) {
|
|
if (effectiveComponentDef.slot) {
|
|
logger.log(`[Slot Registration] 등록 스킵 - resolvedSlotId: ${resolvedSlotId}, id: ${effectiveComponentDef.id}`);
|
|
}
|
|
// 이전에 등록된 경우 해제
|
|
if (prevSlotIdRef.current && effectiveComponentDef.id && globalSlotContext) {
|
|
globalSlotContext.unregisterFromSlot(prevSlotIdRef.current, effectiveComponentDef.id);
|
|
prevSlotIdRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 슬롯 ID가 변경된 경우 이전 슬롯에서 제거
|
|
if (prevSlotIdRef.current && prevSlotIdRef.current !== resolvedSlotId) {
|
|
globalSlotContext.unregisterFromSlot(prevSlotIdRef.current, effectiveComponentDef.id);
|
|
}
|
|
|
|
// 슬롯에 등록 (slot 속성을 제거한 컴포넌트 정의)
|
|
const componentDefWithoutSlot = {
|
|
...effectiveComponentDef,
|
|
slot: undefined,
|
|
slotOrder: undefined,
|
|
};
|
|
|
|
globalSlotContext.registerToSlot(resolvedSlotId, effectiveComponentDef.id, {
|
|
componentDef: componentDefWithoutSlot,
|
|
dataContext: dataContext,
|
|
order: effectiveComponentDef.slotOrder ?? 0,
|
|
parentFormContext: parentFormContextProp ?? null,
|
|
getParentComponentContext: () => componentContext,
|
|
translationContext: translationContext,
|
|
registrationKey: slotRegistrationKey,
|
|
});
|
|
|
|
prevSlotIdRef.current = resolvedSlotId;
|
|
|
|
// 클린업: 언마운트 시 슬롯에서 제거
|
|
return () => {
|
|
// 클린업 시에도 전역 변수에서 직접 가져옴
|
|
const cleanupSlotContext = (window as any).__slotContextValue;
|
|
if (resolvedSlotId && effectiveComponentDef.id && cleanupSlotContext) {
|
|
cleanupSlotContext.unregisterFromSlot(resolvedSlotId, effectiveComponentDef.id);
|
|
}
|
|
};
|
|
}, [
|
|
// slotContext 의존성 제거 - 전역 변수 사용으로 불필요
|
|
shouldRender,
|
|
resolvedSlotId,
|
|
effectiveComponentDef,
|
|
dataContext,
|
|
parentFormContextProp,
|
|
componentContext,
|
|
translationContext,
|
|
slotRegistrationKey,
|
|
]);
|
|
|
|
/**
|
|
* 값에 대해 재귀적으로 다국어 번역을 처리합니다.
|
|
*
|
|
* $t:defer: prefix가 있는 번역 문자열은 건너뛰고 원본을 유지합니다.
|
|
* 이 문자열은 renderItemChildren에서 올바른 iteration 컨텍스트와 함께 처리됩니다.
|
|
*
|
|
* @example
|
|
* - "$t:common.hello" → 즉시 번역
|
|
* - "$t:defer:admin.modules.vendor|vendor={{row.vendor}}" → 번역 건너뜀 (renderItemChildren에서 처리)
|
|
*/
|
|
const resolveTranslationsDeep = useCallback((value: any): any => {
|
|
if (typeof value === 'string') {
|
|
// raw 마커: 번역 면제 — 마커 제거 후 원본 반환
|
|
// @since engine-v1.27.0
|
|
if (isRawWrapped(value)) {
|
|
return unwrapRaw(value);
|
|
}
|
|
|
|
// 혼합 보간 시 raw 마커 영역 보호
|
|
// 예: "제목: \uFDD0$t:admin.key\uFDD1 - $t:common.by"
|
|
// 마커 내부의 $t:는 번역에서 제외, 외부의 $t:만 번역
|
|
// @since engine-v1.27.0
|
|
if (containsRawMarker(value)) {
|
|
const rawSegments: string[] = [];
|
|
const protectedValue = value.replace(
|
|
new RegExp(`${RAW_MARKER_START}([^${RAW_MARKER_END}]*)${RAW_MARKER_END}`, 'g'),
|
|
(_m: string, content: string) => {
|
|
rawSegments.push(content);
|
|
return `${RAW_PLACEHOLDER_MARKER}${rawSegments.length - 1}${RAW_PLACEHOLDER_MARKER}`;
|
|
}
|
|
);
|
|
// 나머지 부분만 번역
|
|
let translated = protectedValue;
|
|
if (/\$t:[a-zA-Z0-9._-]+/.test(protectedValue)) {
|
|
translated = translationEngine.resolveTranslations(
|
|
protectedValue,
|
|
translationContext,
|
|
extendedDataContext
|
|
);
|
|
}
|
|
// 플레이스홀더를 원본으로 복원
|
|
return translated.replace(
|
|
new RegExp(`${RAW_PLACEHOLDER_MARKER}(\\d+)${RAW_PLACEHOLDER_MARKER}`, 'g'),
|
|
(_m: string, idx: string) => rawSegments[parseInt(idx)]
|
|
);
|
|
}
|
|
|
|
// $t:defer: prefix가 있으면 번역하지 않고 원본 반환
|
|
// renderItemChildren에서 올바른 컨텍스트와 함께 처리됨
|
|
if (value.startsWith('$t:defer:')) {
|
|
return value;
|
|
}
|
|
// 문자열 내에 $t: 번역 패턴이 있는지 확인
|
|
// $t: 뒤에 번역 키(알파벳, 숫자, 점, 대시, 언더스코어)가 오는 패턴만 매칭
|
|
// 예: "$t:common.save", "27$t:admin.roles.form.suffix"
|
|
if (/\$t:[a-zA-Z0-9._-]+/.test(value)) {
|
|
// JSON 구조 문자열 내부의 $t: 토큰은 데이터 자체의 일부이므로 번역하지 않음
|
|
// 예: CodeEditor value prop의 레이아웃 JSON 문자열 {"text":"$t:error.401.title"}
|
|
// @since engine-v1.26.1
|
|
const trimmed = value.trim();
|
|
if (
|
|
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
) {
|
|
return value;
|
|
}
|
|
return translationEngine.resolveTranslations(
|
|
value,
|
|
translationContext,
|
|
extendedDataContext
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => resolveTranslationsDeep(item));
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
const result: Record<string, any> = {};
|
|
for (const [k, v] of Object.entries(value)) {
|
|
result[k] = resolveTranslationsDeep(v);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return value;
|
|
}, [translationEngine, translationContext, extendedDataContext]);
|
|
|
|
/**
|
|
* 복잡한 표현식 여부 감지
|
|
*
|
|
* 판정은 BindingShape 정본에 위임한다 — 종전에는 이 자리의 문자 집합이 좁아
|
|
* `query['sales_status[]']` 같은 따옴표 키 인덱싱과 배열/객체 리터럴이
|
|
* "단순 경로" 로 오판되어 경로 탐색으로 갔고, 결과는 조용한 undefined 였다.
|
|
* 같은 식이 `if`(RenderHelpers 방언)에서는 정상 동작해 재현 위치를 특정하기 어려웠다.
|
|
*
|
|
* @since engine-v1.55.0
|
|
*/
|
|
const isComplexExpression = useCallback(
|
|
(expr: string): boolean => canonicalIsComplexExpression(expr),
|
|
[],
|
|
);
|
|
|
|
/**
|
|
* 단일 바인딩 표현식 추출
|
|
*
|
|
* 전체가 `{{...}}` 로 감싸진 식이면 내부 식을, 아니면 null 을 반환한다.
|
|
* 판정은 BindingShape 정본에 위임한다 (따옴표·이스케이프·중괄호 균형 추적).
|
|
*
|
|
* @since engine-v1.55.0
|
|
*/
|
|
const extractSingleBinding = useCallback(
|
|
(value: string): string | null => canonicalExtractSingleBinding(value),
|
|
[],
|
|
);
|
|
|
|
/**
|
|
* Props 처리
|
|
*
|
|
* 1. 데이터 바인딩 ({{variable}})
|
|
* 2. 다국어 번역 ($t:key) - 재귀적 처리
|
|
* 3. 액션 바인딩 (onClick 등)
|
|
*/
|
|
const resolvedProps = useMemo(() => {
|
|
let props = { ...(effectiveComponentDef.props || {}) };
|
|
|
|
// 기본 skipBindingKeys (하위 호환성)
|
|
// 컴포넌트 내부에서 자체적으로 바인딩 처리하는 키들
|
|
const DEFAULT_SKIP_BINDING_KEYS = ['cellChildren', 'expandChildren', 'expandContext', 'render'];
|
|
|
|
// 컴포넌트 메타데이터에서 skipBindingKeys 조회
|
|
const componentMeta = registry.getMetadata(effectiveComponentDef.name);
|
|
const componentSkipKeys = componentMeta?.skipBindingKeys || [];
|
|
|
|
// 기본값과 컴포넌트별 설정 병합 (중복 제거)
|
|
const skipBindingKeys = [...new Set([...DEFAULT_SKIP_BINDING_KEYS, ...componentSkipKeys])];
|
|
|
|
// iteration 컨텍스트에서는 캐시 사용 안 함
|
|
// 같은 표현식이 다른 아이템 컨텍스트로 평가되어야 하므로 캐시 사용 시 잘못된 값 반환
|
|
const bindingOptions = isInsideIteration ? { skipCache: true } : undefined;
|
|
|
|
// 중첩 객체 해석에도 컴포넌트별 skipBindingKeys 를 전달한다.
|
|
// 종전에는 전달하지 않아 resolveObject 의 기본 4개만 적용됐고, 그래서 컴포넌트가
|
|
// "내가 직접 반복 렌더한다" 고 선언한 키가 중첩 위치에서는 선평가됐다 —
|
|
// 그 시점에는 항목 컨텍스트가 없으므로 표현식이 stale 값으로 고정된다.
|
|
// 저장소 전수 스캔 기준 현재 해당 위치 0건(예방 목적). @since engine-v1.55.1
|
|
const nestedBindingOptions = { ...(bindingOptions ?? {}), skipBindingKeys };
|
|
|
|
// _computed 참조 표현식의 캐시 스킵 여부를 판별하는 헬퍼
|
|
// _computed는 컴포넌트마다 _local 기반으로 재계산되므로 컴포넌트 간 공유 캐시 사용 시
|
|
// 먼저 렌더링된 컴포넌트의 stale 값이 이후 컴포넌트에 전파되는 버그 발생
|
|
const getComputedAwareOptions = (expr: string) => {
|
|
if (bindingOptions) return bindingOptions; // iteration이면 이미 skipCache
|
|
if (expr.startsWith('_computed') || expr.startsWith('$computed')) {
|
|
return { skipCache: true };
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// 1. 데이터 바인딩
|
|
for (const [key, value] of Object.entries(props)) {
|
|
// 특정 키는 바인딩 처리 건너뛰기
|
|
if (skipBindingKeys.includes(key)) {
|
|
continue;
|
|
}
|
|
if (typeof value === 'string') {
|
|
// 전체가 {{expression}} 패턴인 경우 (복잡한 표현식 포함)
|
|
const singleBindingPath = extractSingleBinding(value);
|
|
if (singleBindingPath !== null) {
|
|
// raw: 접두사 감지 @since engine-v1.27.0
|
|
let isRawBinding = false;
|
|
let effectivePath = singleBindingPath;
|
|
if (singleBindingPath.startsWith(RAW_PREFIX)) {
|
|
isRawBinding = true;
|
|
effectivePath = singleBindingPath.slice(RAW_PREFIX.length);
|
|
}
|
|
|
|
// 복잡한 표현식(삼항 연산자, 논리 연산자 등)인 경우 evaluateExpression 사용
|
|
// DevTools 추적을 위한 컴포넌트 컨텍스트 정보
|
|
const trackingInfo = {
|
|
componentId: effectiveComponentDef.id,
|
|
componentName: effectiveComponentDef.name,
|
|
propName: key,
|
|
};
|
|
|
|
let resolvedValue;
|
|
if (hasPipes(effectivePath)) {
|
|
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다.
|
|
// isComplexExpression 은 `|` 를 연산자로 보므로 그대로 두면
|
|
// `value | pipe` 가 JS 비트 OR 로 평가된다 — text 처리와 동일 분기.
|
|
// @since engine-v1.54.3
|
|
// prop 값은 원본 타입이 필요하므로 문자열 서식을 거치지 않으며,
|
|
// 캐시 옵션도 다른 분기와 동일하게 _computed 인지를 반영한다.
|
|
// @since engine-v1.54.9
|
|
try {
|
|
resolvedValue = bindingEngine.evaluatePipeExpression(
|
|
effectivePath,
|
|
extendedDataContext,
|
|
getComputedAwareOptions(effectivePath),
|
|
trackingInfo,
|
|
);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`파이프 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}, prop: ${key}):`,
|
|
error
|
|
);
|
|
resolvedValue = undefined;
|
|
}
|
|
} else if (isComplexExpression(effectivePath)) {
|
|
try {
|
|
resolvedValue = bindingEngine.evaluateExpression(effectivePath, extendedDataContext, trackingInfo);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}, prop: ${key}):`,
|
|
error
|
|
);
|
|
resolvedValue = undefined;
|
|
}
|
|
} else {
|
|
resolvedValue = bindingEngine.resolve(effectivePath, extendedDataContext, getComputedAwareOptions(effectivePath), trackingInfo);
|
|
}
|
|
props[key] = isRawBinding && resolvedValue != null ? wrapRawDeep(resolvedValue) : resolvedValue;
|
|
} else {
|
|
// 문자열 보간 (예: "Hello {{name}}")
|
|
const trackingInfo = {
|
|
componentId: effectiveComponentDef.id,
|
|
componentName: effectiveComponentDef.name,
|
|
propName: key,
|
|
};
|
|
props[key] = bindingEngine.resolveBindings(value, extendedDataContext, bindingOptions, trackingInfo);
|
|
}
|
|
} else if (Array.isArray(value)) {
|
|
props[key] = value.map((item) =>
|
|
typeof item === 'string'
|
|
? bindingEngine.resolveBindings(item, extendedDataContext, bindingOptions)
|
|
: typeof item === 'object' && item !== null
|
|
? bindingEngine.resolveObject(item, extendedDataContext, nestedBindingOptions)
|
|
: item
|
|
);
|
|
} else if (value && typeof value === 'object') {
|
|
props[key] = bindingEngine.resolveObject(value, extendedDataContext, nestedBindingOptions);
|
|
}
|
|
}
|
|
|
|
|
|
// 2. 다국어 번역 (재귀적 처리)
|
|
for (const [key, value] of Object.entries(props)) {
|
|
props[key] = resolveTranslationsDeep(value);
|
|
}
|
|
|
|
// 2.5. style prop이 문자열인 경우 React style 객체로 변환
|
|
// React에서 style prop은 객체여야 하므로, CSS 문자열을 자동 변환
|
|
if (typeof props.style === 'string') {
|
|
props.style = parseCSSStringToStyleObject(props.style);
|
|
}
|
|
|
|
// 3. 액션 바인딩 (공유 유틸리티 함수 사용)
|
|
//
|
|
// 편집 모드에서는 navigate/apiCall/openModal 등 모든 액션을 발동하지
|
|
// 않는다 — actions 를 undefined 로 넘겨 바인딩 자체를 건너뛴다. 편집 모드의
|
|
// 클릭은 오직 "요소 선택" 만 의미하며, 액션 발동은 운영 화면에서만 일어난다.
|
|
// (편집 모드 진입 시 actions JSON 값은 보존된 채 단지 onClick 핸들러로 변환되지
|
|
// 않을 뿐이므로 무손실. 저장 시 그대로 직렬화됨.)
|
|
props = bindComponentActions(
|
|
props,
|
|
isEditMode ? undefined : effectiveComponentDef.actions,
|
|
extendedDataContext,
|
|
{ componentContext, actionDispatcher }
|
|
);
|
|
|
|
// 4. classMap 처리 (조건부 스타일 매핑)
|
|
// classMap이 있으면 해석하여 기존 className과 병합
|
|
if (effectiveComponentDef.classMap) {
|
|
const classMapResult = resolveClassMap(
|
|
effectiveComponentDef.classMap,
|
|
extendedDataContext,
|
|
bindingEngine,
|
|
{ skipCache: true } // iteration 컨텍스트에서 각 row별 다른 값을 가져야 함
|
|
);
|
|
|
|
if (classMapResult) {
|
|
// 기존 className과 병합
|
|
const existingClassName = props.className || '';
|
|
props.className = existingClassName
|
|
? `${existingClassName} ${classMapResult}`
|
|
: classMapResult;
|
|
}
|
|
}
|
|
|
|
// 5. 인라인 스타일 적용
|
|
if (effectiveComponentDef.style) {
|
|
props.style = effectiveComponentDef.style;
|
|
}
|
|
|
|
// 6. 동적 상태를 props로 전달
|
|
// Basic 컴포넌트는 HTML 요소를 렌더링하므로 객체 props가 [object Object]로 변환됨
|
|
// Composite 컴포넌트(React 컴포넌트)만 dynamicState를 props로 받을 수 있음
|
|
if (effectiveComponentDef.actions && effectiveComponentDef.actions.length > 0) {
|
|
const isCompositeComponent = effectiveComponentDef.type === 'composite';
|
|
|
|
if (isCompositeComponent) {
|
|
// Composite 컴포넌트: dynamicState 전체 전달 (loadingActions, apiError 등)
|
|
Object.assign(props, dynamicState);
|
|
}
|
|
// Basic/Layout 컴포넌트: dynamicState 전달 안 함
|
|
// (바인딩 표현식으로 필요한 값에 접근 가능: {{_local.xxx}})
|
|
}
|
|
|
|
// 7. component_layout 처리 (컴포넌트 전용 커스텀 레이아웃)
|
|
// component_layout이 있으면 __componentLayoutDefs prop으로 전달
|
|
// 컴포넌트에서 이를 사용하여 커스텀 렌더링 수행
|
|
if (effectiveComponentDef.component_layout) {
|
|
props.__componentLayoutDefs = effectiveComponentDef.component_layout;
|
|
}
|
|
|
|
// 8. componentContext 자동 전달 (Phase 1-3: 확대 적용)
|
|
// 부모 상태(dynamicState)를 자식 컴포넌트가 접근/수정할 수 있도록
|
|
// 다음 조건 중 하나라도 해당하면 __componentContext를 자동 전달:
|
|
// - expandChildren: DataGrid 확장 행 (ComponentDefinition 또는 props)
|
|
// - cellChildren: DataGrid/CardGrid 셀 렌더링 (props 전달)
|
|
// - children: 자식 컴포넌트 렌더링
|
|
// - component_layout: 커스텀 레이아웃 정의
|
|
// - slot: 슬롯 지정 컴포넌트
|
|
// - modalId: 외부 모달을 여는 컴포넌트 (engine-v1.17.3)
|
|
// - composite 타입: 복합 컴포넌트는 내부적으로 dispatch 호출 가능성 있음 (engine-v1.17.3)
|
|
const hasExpandChildren = effectiveComponentDef.expandChildren || effectiveComponentDef.props?.expandChildren;
|
|
const hasCellChildren = effectiveComponentDef.props?.cellChildren;
|
|
const hasChildren = effectiveComponentDef.children && effectiveComponentDef.children.length > 0;
|
|
const hasComponentLayout = effectiveComponentDef.component_layout;
|
|
const hasSlot = effectiveComponentDef.slot;
|
|
const hasModalId = effectiveComponentDef.props?.modalId;
|
|
const isCompositeComponent = effectiveComponentDef.type === 'composite';
|
|
|
|
if (hasExpandChildren || hasCellChildren || hasChildren || hasComponentLayout || hasSlot || hasModalId || isCompositeComponent) {
|
|
props.__componentContext = componentContext;
|
|
}
|
|
|
|
return props;
|
|
}, [
|
|
effectiveComponentDef.props,
|
|
effectiveComponentDef.actions,
|
|
effectiveComponentDef.style,
|
|
effectiveComponentDef.classMap,
|
|
effectiveComponentDef.id,
|
|
extendedDataContext,
|
|
translationContext,
|
|
bindingEngine,
|
|
translationEngine,
|
|
componentContext,
|
|
dynamicState,
|
|
resolveTranslationsDeep,
|
|
isComplexExpression,
|
|
extractSingleBinding,
|
|
isInsideIteration,
|
|
]);
|
|
|
|
/**
|
|
* resolvedProps 참조 안정화 (engine-v1.25.0)
|
|
*
|
|
* 상태 변경 시 resolvedProps가 재계산되지만, 해석된 값이 이전과 동일하면
|
|
* 이전 참조를 반환하여 하위 컴포넌트의 React.memo가 실제로 작동하게 합니다.
|
|
* shallowObjectEqual은 각 prop 값을 ===로 비교합니다.
|
|
*
|
|
* @since engine-v1.25.0
|
|
*/
|
|
const previousResolvedPropsRef = useRef<Record<string, any> | null>(null);
|
|
const stableResolvedProps = useMemo(() => {
|
|
if (
|
|
previousResolvedPropsRef.current &&
|
|
shallowObjectEqual(previousResolvedPropsRef.current, resolvedProps)
|
|
) {
|
|
return previousResolvedPropsRef.current;
|
|
}
|
|
previousResolvedPropsRef.current = resolvedProps;
|
|
return resolvedProps;
|
|
}, [resolvedProps]);
|
|
|
|
/**
|
|
* DOM id 표현식 해석 (engine-v1.50.0)
|
|
*
|
|
* 노드의 최상위 `id` 필드는 props 가 아니므로 props 바인딩 경로를 타지 않는다.
|
|
* iteration 내부에서 `id: "item_{{$idx}}"` 처럼 표현식을 쓰면 각 row 마다
|
|
* 고유 DOM id 가 되어야 하지만, 보간 없이 그대로 출력되면 같은 리터럴이
|
|
* row 마다 반복되어 W3C HTML id 중복을 유발한다.
|
|
*
|
|
* `{{` 가 포함된 경우에만 extendedDataContext($idx 등 iteration 변수 포함)로
|
|
* 문자열 보간한다. 표현식이 없는 정적 id 는 원본을 그대로 반환하여 비용을 회피한다.
|
|
* 이 값은 DOM 출력 전용이며, slot 등록·DevTools 등 내부 식별에는 원본 id 를 계속 사용한다.
|
|
*/
|
|
const resolvedComponentId = useMemo(() => {
|
|
const rawId = effectiveComponentDef.id;
|
|
if (typeof rawId !== 'string' || !rawId.includes('{{')) {
|
|
return rawId;
|
|
}
|
|
const resolved = bindingEngine.resolveBindings(rawId, extendedDataContext, { skipCache: true });
|
|
return typeof resolved === 'string' ? resolved : rawId;
|
|
}, [effectiveComponentDef.id, extendedDataContext, bindingEngine]);
|
|
|
|
/**
|
|
* 자식에게 전달할 FormContext
|
|
*
|
|
* 현재 컴포넌트에 dataKey가 있으면 새 FormContext를 생성하여 자식에게 전달합니다.
|
|
* 없으면 부모로부터 받은 FormContext를 그대로 전달합니다.
|
|
* 이렇게 prop으로 전달해야 useMemo 타이밍 문제를 피할 수 있습니다.
|
|
*
|
|
* dataKey 형식:
|
|
* - "formData" → _local.formData 사용
|
|
* - "_global.formData" → _global.formData 사용
|
|
*/
|
|
const formContextForChildren = useMemo<FormContextValue | undefined>(() => {
|
|
// dataKey는 componentDef 레벨에 정의됨 (props가 아님)
|
|
const dataKey = effectiveComponentDef.dataKey;
|
|
const trackChanges = effectiveComponentDef.trackChanges;
|
|
const debounce = effectiveComponentDef.debounce;
|
|
|
|
// dataKey가 있으면 새 FormContext 생성
|
|
if (dataKey) {
|
|
// _global. 접두사 처리
|
|
const isGlobal = dataKey.startsWith('_global.');
|
|
const actualDataKey = isGlobal ? dataKey.slice(8) : dataKey; // "_global." 제거
|
|
|
|
// _global 또는 _local 상태 선택
|
|
const targetState = isGlobal
|
|
? (dataContext._global as Record<string, any> | undefined)
|
|
: (extendedDataContext._local as Record<string, any> | undefined);
|
|
|
|
// _global인 경우 globalSetState 사용, 아니면 localSetState 사용
|
|
const targetSetState = isGlobal
|
|
? (updates: Record<string, any>) => {
|
|
// _global 상태 업데이트는 actionDispatcher를 통해 수행
|
|
// globalSetState 핸들러가 필요하므로 componentContext.setState와 다름
|
|
// 여기서는 _global 객체를 직접 업데이트할 수 없으므로
|
|
// dataContext를 통해 전달된 _global setState를 사용해야 함
|
|
if (dataContext._globalSetState) {
|
|
(dataContext._globalSetState as (updates: Record<string, any>) => void)(updates);
|
|
} else {
|
|
logger.warn('_global.formData 사용 시 _globalSetState가 필요합니다.');
|
|
}
|
|
}
|
|
: handleSetState;
|
|
|
|
return {
|
|
dataKey: actualDataKey, // "_global." 접두사 제거된 실제 키
|
|
trackChanges: trackChanges ?? false,
|
|
debounce: debounce,
|
|
setState: targetSetState,
|
|
state: targetState ?? {},
|
|
// _global 여부를 추가로 전달 (propsWithAutoBinding에서 사용)
|
|
_isGlobal: isGlobal,
|
|
} as FormContextValue & { _isGlobal?: boolean };
|
|
}
|
|
|
|
// dataKey가 없으면 부모의 FormContext를 그대로 전달 (trackChanges 포함)
|
|
// parentFormContext가 유효하면 (dataKey가 있으면) 그대로 자식에게 전달
|
|
if (parentFormContext.dataKey) {
|
|
return parentFormContext;
|
|
}
|
|
|
|
return undefined;
|
|
}, [effectiveComponentDef.dataKey, effectiveComponentDef.trackChanges, effectiveComponentDef.debounce, effectiveComponentDef.id, handleSetState, extendedDataContext._local, dataContext._global, dataContext._globalSetState, parentFormContext]);
|
|
|
|
/**
|
|
* 이미 remount 키가 적용된 컴포넌트 ID를 추적합니다.
|
|
* 중복 ID가 있는 경우 첫 번째만 remount 처리하기 위함입니다.
|
|
*/
|
|
const appliedRemountIds = useRef<Set<string>>(new Set());
|
|
|
|
// _remountKeys가 변경될 때마다 추적 Set 초기화
|
|
useEffect(() => {
|
|
appliedRemountIds.current.clear();
|
|
}, [dataContext._global?._remountKeys]);
|
|
|
|
/**
|
|
* 컴포넌트 ID 기반으로 React key를 생성합니다.
|
|
*
|
|
* _remountKeys에 해당 컴포넌트 ID의 값이 있으면 key에 포함시켜
|
|
* remount 핸들러 호출 시 컴포넌트가 리마운트되도록 합니다.
|
|
*
|
|
* 중복 ID 처리:
|
|
* - ID가 있고 처음 등장하는 경우: remount 키 적용
|
|
* - ID가 없거나 중복되는 경우: 기존 fallback 키 사용
|
|
*
|
|
* @param componentId 컴포넌트 ID
|
|
* @param fallbackKey ID가 없을 때 사용할 폴백 키
|
|
* @returns React key 문자열
|
|
*/
|
|
const getRemountKey = useCallback((componentId: string | undefined, fallbackKey: string): string => {
|
|
const remountKeys = dataContext._global?._remountKeys;
|
|
|
|
// ID가 있고, remount 키가 있고, 아직 적용되지 않은 경우에만 remount 처리
|
|
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.current.has(componentId)) {
|
|
appliedRemountIds.current.add(componentId);
|
|
return `${componentId}-remount-${remountKeys[componentId]}`;
|
|
}
|
|
|
|
// ID가 없거나 중복되는 경우 기존 방식
|
|
return componentId || fallbackKey;
|
|
}, [dataContext._global?._remountKeys]);
|
|
|
|
/**
|
|
* 자식 컴포넌트 렌더링
|
|
*/
|
|
const renderChildren = useMemo(() => {
|
|
// 1. text 속성이 있으면 최우선으로 사용
|
|
if (effectiveComponentDef.text !== undefined) {
|
|
if (typeof effectiveComponentDef.text === 'string') {
|
|
const text = effectiveComponentDef.text;
|
|
let resolvedText: any = text;
|
|
|
|
// 1단계: 데이터 바인딩 처리 (props 처리와 동일한 로직)
|
|
// skipCache: true - 반복 렌더링에서 같은 경로가 다른 값을 가져야 함
|
|
// DevTools 추적을 위한 컴포넌트 컨텍스트 정보
|
|
const textTrackingInfo = {
|
|
componentId: effectiveComponentDef.id,
|
|
componentName: effectiveComponentDef.name,
|
|
propName: 'text',
|
|
skipCache: true,
|
|
};
|
|
const singleBindingPath = extractSingleBinding(text);
|
|
|
|
if (singleBindingPath !== null) {
|
|
// raw: 접두사 감지 — props 처리(위 renderProps)와 동일 규칙.
|
|
// 접두사를 벗기지 않으면 `raw:` 의 콜론이 식의 일부로 파싱되어
|
|
// evaluateExpression 이 `Unexpected token ':'` 로 던지고 텍스트가 통째로 사라진다.
|
|
// @since engine-v1.54.4
|
|
let isRawBinding = false;
|
|
let effectivePath = singleBindingPath;
|
|
if (singleBindingPath.startsWith(RAW_PREFIX)) {
|
|
isRawBinding = true;
|
|
effectivePath = singleBindingPath.slice(RAW_PREFIX.length);
|
|
}
|
|
|
|
// 파이프가 있는 경우 evaluatePipeExpression 사용 (파이프 체인 실행 포함).
|
|
// 다른 두 분기(resolve/evaluateExpression)와 마찬가지로 원본 타입을 유지한다 —
|
|
// 문자열 서식을 거치면 `first`/`filter` 같은 파이프의 결과 타입이 바뀐다.
|
|
// @since engine-v1.54.9
|
|
if (hasPipes(effectivePath)) {
|
|
try {
|
|
resolvedText = bindingEngine.evaluatePipeExpression(effectivePath, extendedDataContext, { skipCache: true }, textTrackingInfo);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`text 파이프 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
error
|
|
);
|
|
resolvedText = '';
|
|
}
|
|
} else if (isComplexExpression(effectivePath)) {
|
|
// 복잡한 표현식인 경우 evaluateExpression 사용
|
|
try {
|
|
resolvedText = bindingEngine.evaluateExpression(effectivePath, extendedDataContext, textTrackingInfo);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`text 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
error
|
|
);
|
|
resolvedText = '';
|
|
}
|
|
} else {
|
|
resolvedText = bindingEngine.resolve(effectivePath, extendedDataContext, { skipCache: true }, textTrackingInfo);
|
|
}
|
|
|
|
if (isRawBinding && resolvedText != null) {
|
|
resolvedText = wrapRawDeep(resolvedText);
|
|
}
|
|
} else if (text.includes('{{')) {
|
|
// 문자열 보간
|
|
resolvedText = bindingEngine.resolveBindings(text, extendedDataContext, { skipCache: true }, textTrackingInfo);
|
|
}
|
|
|
|
// 2단계: 다국어 번역 처리 (기존 헬퍼 재사용)
|
|
resolvedText = resolveTranslationsDeep(resolvedText);
|
|
|
|
return resolvedText ?? '';
|
|
}
|
|
return effectiveComponentDef.text;
|
|
}
|
|
|
|
// 2. children이 없으면 null
|
|
if (!effectiveComponentDef.children || effectiveComponentDef.children.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// 3. children 배열 렌더링
|
|
return effectiveComponentDef.children.map((childDef, index) => {
|
|
// 문자열인 경우 그대로 반환 (텍스트 노드)
|
|
if (typeof childDef === 'string') {
|
|
return childDef;
|
|
}
|
|
|
|
// 컴포넌트 정의인 경우 DynamicRenderer로 렌더링
|
|
// 자식 컴포넌트에도 componentContext와 formContext를 전달하여 상태 공유
|
|
// extendedDataContext를 전달하여 _local 상태를 자식에게도 공유
|
|
// componentPath를 누적하여 자식에게 전달 (WYSIWYG 편집기용)
|
|
// 자식이 responsive 분기 children 으로 교체된 경우, 편집 path 에 분기 출처를
|
|
// 명시한다: `{부모}.responsive.{key}.children.{N}`. 그래야 편집기가 base children
|
|
// 의 같은 인덱스 노드가 아니라 보이는 분기 노드를 정확히 선택·패치한다.
|
|
// base children(분기 키 부재)은 종전 `.children.{N}` 형식 유지.
|
|
const branchKey = (effectiveComponentDef as any).__responsiveChildrenKey as string | undefined;
|
|
const childrenPathSeg = branchKey ? `responsive.${branchKey}.children` : 'children';
|
|
const childPath = componentPath ? `${componentPath}.${childrenPathSeg}.${index}` : `${index}`;
|
|
|
|
// @since engine-v1.24.8 _fromBase children은 stable key → 보존(update)
|
|
// non-_fromBase children은 layoutKey suffix로 페이지 전환 시 remount 보장
|
|
const baseChildKey = childDef._fromBase
|
|
? (childDef.id || `child-${index}`)
|
|
: getRemountKey(childDef.id, `child-${index}`);
|
|
const childKey = (!childDef._fromBase && layoutKey)
|
|
? `${baseChildKey}_${layoutKey}`
|
|
: baseChildKey;
|
|
|
|
return (
|
|
<DynamicRenderer
|
|
key={childKey}
|
|
componentDef={childDef}
|
|
dataContext={extendedDataContext}
|
|
translationContext={translationContext}
|
|
registry={registry}
|
|
bindingEngine={bindingEngine}
|
|
translationEngine={translationEngine}
|
|
actionDispatcher={actionDispatcher}
|
|
parentComponentContext={componentContext}
|
|
parentFormContextProp={formContextForChildren}
|
|
isInsideIteration={isInsideIteration}
|
|
isEditMode={isEditMode}
|
|
onComponentSelect={onComponentSelect}
|
|
onComponentHover={onComponentHover}
|
|
componentPath={childPath}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
layoutKey={layoutKey}
|
|
/>
|
|
);
|
|
});
|
|
}, [
|
|
effectiveComponentDef.text,
|
|
effectiveComponentDef.children,
|
|
effectiveComponentDef.id,
|
|
dataContext,
|
|
extendedDataContext,
|
|
translationContext,
|
|
registry,
|
|
bindingEngine,
|
|
translationEngine,
|
|
actionDispatcher,
|
|
componentContext,
|
|
formContextForChildren,
|
|
extractSingleBinding,
|
|
isComplexExpression,
|
|
resolveTranslationsDeep,
|
|
getRemountKey,
|
|
isInsideIteration,
|
|
isEditMode,
|
|
onComponentSelect,
|
|
onComponentHover,
|
|
componentPath,
|
|
onDragStart,
|
|
onDragEnd,
|
|
layoutKey,
|
|
]);
|
|
|
|
/**
|
|
* 반복 렌더링 처리
|
|
*/
|
|
const renderIteration = useMemo(() => {
|
|
if (!effectiveComponentDef.iteration) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// 데이터 소스 가져오기 (표현식 지원)
|
|
const dataArray = resolveIterationSource(
|
|
effectiveComponentDef.iteration.source,
|
|
extendedDataContext,
|
|
bindingEngine
|
|
);
|
|
|
|
if (!Array.isArray(dataArray)) {
|
|
logger.warn(
|
|
`iteration source가 배열이 아닙니다 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
dataArray
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// DevTools에 iteration 추적
|
|
const devTools = getDevTools();
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackIteration(
|
|
effectiveComponentDef.id || `iteration-${Date.now()}`,
|
|
effectiveComponentDef.iteration!.source,
|
|
effectiveComponentDef.iteration!.item_var,
|
|
effectiveComponentDef.iteration!.index_var,
|
|
dataArray.length
|
|
);
|
|
}
|
|
|
|
// 각 아이템에 대해 자식 컴포넌트 렌더링
|
|
return dataArray.map((item, index) => {
|
|
// 반복 컨텍스트 생성 (extendedDataContext 기반, _local 포함)
|
|
//
|
|
// `{item_var}_index` 자동 변수는 반복 렌더 경로(renderItemChildren)가 이미
|
|
// 제공하던 것으로, 이 경로에만 없어서 같은 작성이 한쪽에서만 동작했다.
|
|
// @since engine-v1.56.0
|
|
const iterationContext: Record<string, any> = {
|
|
...extendedDataContext,
|
|
[effectiveComponentDef.iteration!.item_var]: item,
|
|
[`${effectiveComponentDef.iteration!.item_var}_index`]: index,
|
|
};
|
|
|
|
if (effectiveComponentDef.iteration!.index_var) {
|
|
iterationContext[effectiveComponentDef.iteration!.index_var] = index;
|
|
}
|
|
|
|
// DevTools: Nested Context 추적 (iteration) - 성능 최적화: 샘플링
|
|
// 전체 컨텍스트 복사로 인한 성능 저하 방지를 위해 첫 번째/마지막 아이템만 추적
|
|
const isFirstOrLast = index === 0 || index === dataArray.length - 1;
|
|
if (devTools?.isEnabled() && isFirstOrLast) {
|
|
devTools.trackNestedContext?.({
|
|
componentId: `${effectiveComponentDef.id || 'unknown'}-iter-${index}`,
|
|
componentType: 'iteration',
|
|
parentContext: {
|
|
available: Object.keys(extendedDataContext),
|
|
// 성능 최적화: 전체 값 대신 키만 전달 (필요시 탭에서 조회)
|
|
values: {},
|
|
},
|
|
ownContext: {
|
|
added: [
|
|
effectiveComponentDef.iteration!.item_var,
|
|
...(effectiveComponentDef.iteration!.index_var ? [effectiveComponentDef.iteration!.index_var] : []),
|
|
],
|
|
values: {
|
|
[effectiveComponentDef.iteration!.item_var]: item,
|
|
...(effectiveComponentDef.iteration!.index_var ? { [effectiveComponentDef.iteration!.index_var]: index } : {}),
|
|
},
|
|
},
|
|
depth: 1,
|
|
});
|
|
}
|
|
|
|
// iteration 없이 현재 컴포넌트를 렌더링하기 위해 iteration을 제거한 복사본 생성
|
|
// children이 있든 없든 현재 컴포넌트(wrapper)로 감싸서 렌더링
|
|
//
|
|
// responsive 도 함께 제거: 부모에서 이미 해당 breakpoint 매칭으로 effectiveComponentDef
|
|
// 가 머지된 상태이므로, 자식에서 다시 responsive 머지가 일어나면 iteration 이 부활하여
|
|
// 무한 재귀가 발생함 (responsive.{breakpoint}.iteration 오버라이드 케이스).
|
|
const componentDefWithoutIteration = {
|
|
...effectiveComponentDef,
|
|
iteration: undefined,
|
|
responsive: undefined,
|
|
};
|
|
|
|
// iteration 아이템의 경로: 부모경로.iteration.인덱스
|
|
const iterationPath = componentPath
|
|
? `${componentPath}.iteration.${index}`
|
|
: `iteration.${index}`;
|
|
|
|
const iterKey = `${getRemountKey(effectiveComponentDef.id, 'item')}-${index}`;
|
|
|
|
return (
|
|
<DynamicRenderer
|
|
key={iterKey}
|
|
componentDef={componentDefWithoutIteration}
|
|
dataContext={iterationContext}
|
|
translationContext={translationContext}
|
|
registry={registry}
|
|
bindingEngine={bindingEngine}
|
|
translationEngine={translationEngine}
|
|
actionDispatcher={actionDispatcher}
|
|
parentComponentContext={componentContext}
|
|
parentFormContextProp={formContextForChildren}
|
|
isInsideIteration={true}
|
|
isEditMode={isEditMode}
|
|
onComponentSelect={onComponentSelect}
|
|
onComponentHover={onComponentHover}
|
|
componentPath={iterationPath}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
layoutKey={layoutKey}
|
|
/>
|
|
);
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
`iteration 렌더링 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}, [
|
|
effectiveComponentDef.iteration,
|
|
effectiveComponentDef.children,
|
|
effectiveComponentDef.id,
|
|
dataContext,
|
|
extendedDataContext,
|
|
translationContext,
|
|
registry,
|
|
bindingEngine,
|
|
translationEngine,
|
|
actionDispatcher,
|
|
componentContext,
|
|
formContextForChildren,
|
|
getRemountKey,
|
|
isEditMode,
|
|
onComponentSelect,
|
|
onComponentHover,
|
|
componentPath,
|
|
onDragStart,
|
|
onDragEnd,
|
|
layoutKey,
|
|
]);
|
|
|
|
/**
|
|
* 정렬 가능한 리스트 렌더링 처리 (@dnd-kit 기반)
|
|
*
|
|
* sortable 속성과 itemTemplate을 사용하여 드래그앤드롭으로
|
|
* 순서를 변경할 수 있는 리스트를 렌더링합니다.
|
|
*
|
|
* @since engine-v1.14.0
|
|
*/
|
|
// 정렬 버전 카운터 (useState): 정렬 완료 시 증가하여 컴포넌트 리렌더 + DndContext 리마운트 유도
|
|
// useRef → useState 변경: ref 변경은 리렌더를 트리거하지 않아 깊은 memo 체인에서 renderSortable이 재계산되지 않는 문제 해결
|
|
const [sortVersion, setSortVersion] = useState(0);
|
|
|
|
const renderSortable = useMemo(() => {
|
|
// sortable과 itemTemplate이 모두 있어야 처리
|
|
if (!effectiveComponentDef.sortable || !effectiveComponentDef.itemTemplate) {
|
|
return null;
|
|
}
|
|
|
|
const { sortable, itemTemplate } = effectiveComponentDef;
|
|
|
|
try {
|
|
// 데이터 소스 가져오기 (표현식 지원)
|
|
const dataArray = resolveIterationSource(
|
|
sortable.source,
|
|
extendedDataContext,
|
|
bindingEngine
|
|
);
|
|
|
|
if (!Array.isArray(dataArray)) {
|
|
logger.warn(
|
|
`sortable source가 배열이 아닙니다 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
dataArray
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// 빈 배열 처리
|
|
if (dataArray.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
|
|
// 설정값 추출 (기본값 적용)
|
|
const itemKey = sortable.itemKey || 'id';
|
|
const strategy = (sortable.strategy || 'verticalList') as SortableStrategy;
|
|
const itemVar = sortable.itemVar || '$item';
|
|
const indexVar = sortable.indexVar || '$index';
|
|
const handle = sortable.handle;
|
|
const wrapperElement = sortable.wrapperElement;
|
|
|
|
/**
|
|
* 정렬 완료 핸들러
|
|
*
|
|
* actions에서 onSortEnd 이벤트를 찾아 실행합니다.
|
|
* $sortedItems, $sortEvent 컨텍스트 변수를 주입합니다.
|
|
*/
|
|
const handleSortEnd = (
|
|
sortedItems: any[],
|
|
event: { oldIndex: number; newIndex: number }
|
|
) => {
|
|
|
|
// actions에서 onSortEnd 이벤트 찾기
|
|
const sortEndActions = effectiveComponentDef.actions?.filter(
|
|
(action: any) => action.event === 'onSortEnd' || action.type === 'onSortEnd'
|
|
);
|
|
|
|
// DndContext 리마운트를 위해 sortVersion 증가 (useState → 리렌더 트리거)
|
|
setSortVersion(prev => prev + 1);
|
|
|
|
if (sortEndActions && sortEndActions.length > 0 && actionDispatcher) {
|
|
// $sortedItems, $sortEvent 컨텍스트 변수 주입
|
|
const sortContext = {
|
|
...extendedDataContext,
|
|
$sortedItems: sortedItems,
|
|
$sortEvent: event,
|
|
};
|
|
|
|
// 모든 onSortEnd 액션 실행
|
|
sortEndActions.forEach((action: any) => {
|
|
try {
|
|
const handler = actionDispatcher.createHandler(
|
|
action,
|
|
sortContext,
|
|
componentContext
|
|
);
|
|
// 더미 이벤트로 실행
|
|
const sortEndEvent = new Event('sortend');
|
|
handler(sortEndEvent);
|
|
} catch (error) {
|
|
logger.error(`onSortEnd action failed: ${action.handler}`, error);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 정렬 시작 핸들러
|
|
*/
|
|
const handleSortStart = (event: { activeId: string | number }) => {
|
|
const sortStartActions = effectiveComponentDef.actions?.filter(
|
|
(action: any) => action.event === 'onSortStart' || action.type === 'onSortStart'
|
|
);
|
|
|
|
if (sortStartActions && sortStartActions.length > 0 && actionDispatcher) {
|
|
const sortContext = {
|
|
...extendedDataContext,
|
|
$activeId: event.activeId,
|
|
};
|
|
|
|
sortStartActions.forEach((action: any) => {
|
|
try {
|
|
const handler = actionDispatcher.createHandler(
|
|
action,
|
|
sortContext,
|
|
componentContext
|
|
);
|
|
// 더미 이벤트로 실행
|
|
const sortStartEvent = new Event('sortstart');
|
|
handler(sortStartEvent);
|
|
} catch (error) {
|
|
logger.error(`onSortStart action failed: ${action.handler}`, error);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// 각 아이템 렌더링
|
|
const renderItems = dataArray.map((item, index) => {
|
|
const itemId = String(item[itemKey]);
|
|
|
|
|
|
// 아이템 컨텍스트 생성
|
|
const itemContext = {
|
|
...extendedDataContext,
|
|
[itemVar]: item,
|
|
[indexVar]: index,
|
|
};
|
|
|
|
// sortable 아이템 경로
|
|
const sortablePath = componentPath
|
|
? `${componentPath}.sortable.${index}`
|
|
: `sortable.${index}`;
|
|
|
|
// sortVersion 포함 키: 정렬 시 sortVersionRef 변경으로 모든 아이템 강제 리마운트
|
|
// 이를 통해 MultilingualInput 등 내부 상태를 가진 컴포넌트도 정렬된 값으로 초기화됩니다.
|
|
const itemTemplateKey = `${getRemountKey(effectiveComponentDef.id, 'sortable-item')}-v${sortVersion}-${itemId}`;
|
|
|
|
return (
|
|
<SortableItemWrapper
|
|
key={itemTemplateKey}
|
|
id={itemId}
|
|
handle={handle}
|
|
{...(wrapperElement ? { as: wrapperElement } : {})}
|
|
>
|
|
{/* wrapperElement 지정 시 itemTemplate의 children을 직접 렌더링 (루트 요소 중복 방지) */}
|
|
{(wrapperElement && itemTemplate.children ? itemTemplate.children : [itemTemplate]).map(
|
|
(childDef: any, childIdx: number) => (
|
|
<DynamicRenderer
|
|
key={`${itemTemplateKey}-child-${childIdx}`}
|
|
componentDef={childDef}
|
|
dataContext={itemContext}
|
|
translationContext={translationContext}
|
|
registry={registry}
|
|
bindingEngine={bindingEngine}
|
|
translationEngine={translationEngine}
|
|
actionDispatcher={actionDispatcher}
|
|
parentComponentContext={componentContext}
|
|
parentFormContextProp={undefined}
|
|
isInsideIteration={true}
|
|
isEditMode={isEditMode}
|
|
onComponentSelect={onComponentSelect}
|
|
onComponentHover={onComponentHover}
|
|
componentPath={sortablePath}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
layoutKey={layoutKey}
|
|
/>
|
|
)
|
|
)}
|
|
</SortableItemWrapper>
|
|
);
|
|
});
|
|
|
|
// SortableContainer로 감싸서 반환
|
|
return (
|
|
<SortableContainer
|
|
items={dataArray}
|
|
itemKey={itemKey}
|
|
strategy={strategy}
|
|
onSortEnd={handleSortEnd}
|
|
onSortStart={handleSortStart}
|
|
sortVersion={sortVersion}
|
|
>
|
|
{renderItems}
|
|
</SortableContainer>
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`sortable 렌더링 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}, [
|
|
effectiveComponentDef.sortable,
|
|
effectiveComponentDef.itemTemplate,
|
|
effectiveComponentDef.actions,
|
|
effectiveComponentDef.id,
|
|
dataContext,
|
|
extendedDataContext,
|
|
translationContext,
|
|
registry,
|
|
bindingEngine,
|
|
translationEngine,
|
|
actionDispatcher,
|
|
componentContext,
|
|
getRemountKey,
|
|
isEditMode,
|
|
onComponentSelect,
|
|
onComponentHover,
|
|
componentPath,
|
|
onDragStart,
|
|
onDragEnd,
|
|
sortVersion,
|
|
layoutKey,
|
|
]);
|
|
|
|
/**
|
|
* Lifecycle 액션 실행을 위한 ref (중복 실행 방지)
|
|
*/
|
|
const mountedRef = useRef(false);
|
|
|
|
/**
|
|
* Lifecycle onMount/onUnmount 및 onComponentEvent 처리
|
|
*
|
|
* 컴포넌트 마운트 시:
|
|
* - lifecycle.onMount 액션들을 실행
|
|
* - onComponentEvent 이벤트 구독 설정
|
|
*
|
|
* 컴포넌트 언마운트 시:
|
|
* - onComponentEvent 이벤트 구독 해제
|
|
* - lifecycle.onUnmount 액션들을 실행
|
|
*/
|
|
useEffect(() => {
|
|
// 이미 마운트된 경우 중복 실행 방지 (React Strict Mode 대응)
|
|
if (mountedRef.current) {
|
|
return;
|
|
}
|
|
mountedRef.current = true;
|
|
|
|
// DevTools 마운트 추적
|
|
const devTools = getDevTools();
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackMount(componentDef.id, {
|
|
name: componentDef.name,
|
|
type: componentDef.type || 'component',
|
|
props: stableResolvedProps,
|
|
});
|
|
}
|
|
|
|
// onMount 액션 실행
|
|
if (componentDef.lifecycle?.onMount && componentDef.lifecycle.onMount.length > 0) {
|
|
for (const action of componentDef.lifecycle.onMount) {
|
|
try {
|
|
// 핸들러 생성 및 실행
|
|
const handler = actionDispatcher.createHandler(
|
|
action,
|
|
extendedDataContext,
|
|
componentContext
|
|
);
|
|
// 더미 이벤트로 실행
|
|
const mountEvent = new Event('mount');
|
|
handler(mountEvent);
|
|
logger.log(`Lifecycle onMount executed: ${action.handler} (Component: ${componentDef.id})`);
|
|
} catch (error) {
|
|
logger.error(
|
|
`Lifecycle onMount failed: ${action.handler} (Component: ${componentDef.id})`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// onComponentEvent 구독 설정
|
|
const eventUnsubscribers: Array<() => void> = [];
|
|
if (componentDef.onComponentEvent && componentDef.onComponentEvent.length > 0) {
|
|
const G7Core = (window as any).G7Core;
|
|
if (G7Core?.componentEvent?.on) {
|
|
for (const eventDef of componentDef.onComponentEvent) {
|
|
if (!eventDef.event) {
|
|
logger.warn(`onComponentEvent: event name is required (Component: ${componentDef.id})`);
|
|
continue;
|
|
}
|
|
|
|
// 이벤트 콜백 함수 생성
|
|
const eventCallback = async (eventData?: any) => {
|
|
try {
|
|
// 이벤트 데이터를 포함한 확장 컨텍스트 생성
|
|
const eventContext = {
|
|
...extendedDataContext,
|
|
_eventData: eventData,
|
|
};
|
|
|
|
// 핸들러 생성 및 실행
|
|
const handler = actionDispatcher.createHandler(
|
|
{
|
|
handler: eventDef.handler,
|
|
params: eventDef.params,
|
|
onSuccess: eventDef.onSuccess,
|
|
onError: eventDef.onError,
|
|
},
|
|
eventContext,
|
|
componentContext
|
|
);
|
|
|
|
const result = await handler(new CustomEvent(eventDef.event, { detail: eventData }));
|
|
logger.log(
|
|
`onComponentEvent "${eventDef.event}" handled: ${eventDef.handler} (Component: ${componentDef.id})`,
|
|
{ eventData, result }
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(
|
|
`onComponentEvent "${eventDef.event}" failed: ${eventDef.handler} (Component: ${componentDef.id})`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// 이벤트 구독
|
|
const unsubscribe = G7Core.componentEvent.on(eventDef.event, eventCallback);
|
|
eventUnsubscribers.push(unsubscribe);
|
|
logger.log(`onComponentEvent: Subscribed to "${eventDef.event}" (Component: ${componentDef.id})`);
|
|
}
|
|
} else {
|
|
logger.warn(`onComponentEvent: G7Core.componentEvent is not available (Component: ${componentDef.id})`);
|
|
}
|
|
}
|
|
|
|
// cleanup 함수 (이벤트 구독 해제 + onUnmount 실행)
|
|
return () => {
|
|
// DevTools 언마운트 추적
|
|
const devToolsCleanup = getDevTools();
|
|
if (devToolsCleanup?.isEnabled()) {
|
|
devToolsCleanup.trackUnmount(componentDef.id);
|
|
}
|
|
|
|
// onComponentEvent 구독 해제 (onUnmount보다 먼저 실행)
|
|
for (const unsubscribe of eventUnsubscribers) {
|
|
try {
|
|
unsubscribe();
|
|
} catch (error) {
|
|
logger.error(`onComponentEvent: Failed to unsubscribe (Component: ${componentDef.id})`, error);
|
|
}
|
|
}
|
|
if (eventUnsubscribers.length > 0) {
|
|
logger.log(`onComponentEvent: Unsubscribed ${eventUnsubscribers.length} event(s) (Component: ${componentDef.id})`);
|
|
}
|
|
|
|
if (componentDef.lifecycle?.onUnmount && componentDef.lifecycle.onUnmount.length > 0) {
|
|
for (const action of componentDef.lifecycle.onUnmount) {
|
|
try {
|
|
const handler = actionDispatcher.createHandler(
|
|
action,
|
|
extendedDataContext,
|
|
componentContext
|
|
);
|
|
const unmountEvent = new Event('unmount');
|
|
handler(unmountEvent);
|
|
logger.log(`Lifecycle onUnmount executed: ${action.handler} (Component: ${componentDef.id})`);
|
|
} catch (error) {
|
|
logger.error(
|
|
`Lifecycle onUnmount failed: ${action.handler} (Component: ${componentDef.id})`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []); // 마운트 시 한 번만 실행
|
|
|
|
/**
|
|
* 현재 컴포넌트의 Form 컨텍스트 값
|
|
*
|
|
* dataKey prop이 있으면 새로운 FormContext를 생성하고,
|
|
* 없으면 부모의 FormContext를 그대로 사용합니다.
|
|
*/
|
|
const currentFormContext = useMemo<FormContextValue | null>(() => {
|
|
const dataKey = stableResolvedProps.dataKey;
|
|
const trackChanges = stableResolvedProps.trackChanges;
|
|
const debounce = stableResolvedProps.debounce;
|
|
|
|
// dataKey가 없으면 null 반환 (FormProvider를 사용하지 않음)
|
|
if (!dataKey) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
dataKey,
|
|
trackChanges: trackChanges ?? false,
|
|
debounce: debounce,
|
|
setState: handleSetState,
|
|
state: dynamicState,
|
|
};
|
|
}, [stableResolvedProps.dataKey, stableResolvedProps.trackChanges, stableResolvedProps.debounce, handleSetState, dynamicState]);
|
|
|
|
/**
|
|
* DevTools Form 추적
|
|
*
|
|
* dataKey가 있는 컴포넌트를 Form으로 추적합니다.
|
|
* 마운트 시 등록하고 언마운트 시 해제합니다.
|
|
*
|
|
* 참고: dataKey는 레이아웃 JSON에서 컴포넌트 최상위 속성으로 정의됨
|
|
* (props 내부가 아님)
|
|
*/
|
|
useEffect(() => {
|
|
// dataKey는 effectiveComponentDef 또는 stableResolvedProps에서 가져옴
|
|
const dataKey = (effectiveComponentDef as any).dataKey || stableResolvedProps.dataKey;
|
|
if (!dataKey) return;
|
|
|
|
const devToolsForm = getDevTools();
|
|
if (!devToolsForm?.isEnabled()) return;
|
|
|
|
// Form 내부 Input들의 이름을 수집하기 위해 children을 순회
|
|
const inputNames: { name: string; type: string }[] = [];
|
|
const collectInputs = (children: any[]) => {
|
|
if (!children) return;
|
|
for (const child of children) {
|
|
// JSON 레이아웃 구조: child.props?.name 또는 child.name (컴포넌트 타입)
|
|
// props 안에 name이 있거나, 직접 name 속성이 있는 경우
|
|
const inputName = child?.props?.name;
|
|
if (inputName) {
|
|
inputNames.push({
|
|
name: inputName,
|
|
type: child.name || child.type || 'unknown',
|
|
});
|
|
}
|
|
// 중첩된 children도 재귀적으로 탐색
|
|
if (child?.children) {
|
|
collectInputs(child.children);
|
|
}
|
|
// slots 내부의 컴포넌트도 탐색
|
|
if (child?.slots) {
|
|
for (const slotChildren of Object.values(child.slots)) {
|
|
if (Array.isArray(slotChildren)) {
|
|
collectInputs(slotChildren);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if (componentDef.children) {
|
|
collectInputs(componentDef.children);
|
|
}
|
|
// slots 내부도 탐색
|
|
if ((componentDef as any).slots) {
|
|
for (const slotChildren of Object.values((componentDef as any).slots)) {
|
|
if (Array.isArray(slotChildren)) {
|
|
collectInputs(slotChildren);
|
|
}
|
|
}
|
|
}
|
|
|
|
const formId = componentDef.id || `form-${dataKey}`;
|
|
devToolsForm.trackForm(formId, dataKey, inputNames);
|
|
|
|
// 클린업: 언마운트 시 Form 추적 해제
|
|
return () => {
|
|
devToolsForm.untrackForm(formId);
|
|
};
|
|
}, [effectiveComponentDef, stableResolvedProps.dataKey, componentDef.id, componentDef.children]);
|
|
|
|
/**
|
|
* 자동 바인딩 debounce 타이머 관리
|
|
*
|
|
* 컴포넌트별로 debounce 타이머를 관리하여 빈번한 상태 업데이트를 최적화합니다.
|
|
*/
|
|
const autoBindingDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// debounce 대기 중 사용자 입력값 추적 (플리커 방지)
|
|
// debounce 중 부모 리렌더링 시 stale state value 대신 pending value를 사용
|
|
const autoBindingPendingValueRef = useRef<any>(undefined);
|
|
|
|
/**
|
|
* 자동바인딩 경로 레지스트리 등록/해제 (engine-v1.43.0+)
|
|
*
|
|
* 이 Input/Textarea 등이 마운트되는 동안 fullPath(=`${dataKey}.${name}`)를
|
|
* `window.__g7AutoBindingPaths` Map에 reference count로 기록한다.
|
|
*
|
|
* 용도: G7CoreGlobals.setLocal이 `render:false` 호출 시 업데이트 경로가
|
|
* 레지스트리와 겹치면 render:true로 자동 승격하여 저장소 A(localDynamicState)
|
|
* 동기화를 구조적으로 보장한다 (`options.selfManaged === true`만 예외).
|
|
*
|
|
* Reference count가 필요한 이유:
|
|
* - iteration 내 동일 fullPath 중복 마운트 (여러 Input이 같은 name)
|
|
* - React Strict Mode의 이중 마운트 (mount→cleanup→mount)
|
|
* Set만 쓰면 첫 cleanup이 경로 제거 → 두 번째 마운트 후 stale 위험.
|
|
*
|
|
* 조건: 실제 자동바인딩이 활성화된 경로만 등록 (propsWithAutoBinding의 조건과 동일).
|
|
* - name prop 존재
|
|
* - 부모 FormContext의 dataKey/setState 존재
|
|
* - props.autoBinding !== false
|
|
*/
|
|
useEffect(() => {
|
|
const name = stableResolvedProps?.name;
|
|
if (!name || !parentFormContext.dataKey || !parentFormContext.setState) return;
|
|
if (stableResolvedProps?.autoBinding === false) return;
|
|
|
|
const fullPath = `${parentFormContext.dataKey}.${name}`;
|
|
const registry: Map<string, number> =
|
|
((window as any).__g7AutoBindingPaths as Map<string, number> | undefined)
|
|
?? new Map<string, number>();
|
|
(window as any).__g7AutoBindingPaths = registry;
|
|
registry.set(fullPath, (registry.get(fullPath) ?? 0) + 1);
|
|
|
|
return () => {
|
|
const count = registry.get(fullPath) ?? 0;
|
|
if (count <= 1) registry.delete(fullPath);
|
|
else registry.set(fullPath, count - 1);
|
|
};
|
|
}, [stableResolvedProps?.name, stableResolvedProps?.autoBinding, parentFormContext.dataKey, parentFormContext.setState]);
|
|
|
|
/**
|
|
* 폼 자동 바인딩이 적용된 props
|
|
*
|
|
* 부모 FormContext가 있고 name prop이 있으면:
|
|
* 1. value/checked: dataKey 경로에서 해당 필드 값 자동 바인딩
|
|
* - 값이 boolean이면 checked로 바인딩 (Toggle, Checkbox 등)
|
|
* - 그 외는 value로 바인딩 (Input, Textarea, Select 등)
|
|
* 2. onChange: 값 변경 시 자동으로 상태 업데이트
|
|
* 3. trackChanges가 true이면 hasChanges도 자동 설정
|
|
* 4. debounce가 설정되면 상태 업데이트를 지연
|
|
*
|
|
* props.value는 기본값으로 사용되며, 바인딩된 값이 있으면 바인딩 값이 우선합니다.
|
|
*/
|
|
const propsWithAutoBinding = useMemo(() => {
|
|
// dataKey, trackChanges, debounce는 컴포넌트에 전달하지 않음 (내부 처리용)
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { dataKey, trackChanges, debounce, ...propsWithoutFormConfig } = stableResolvedProps;
|
|
|
|
// 자동 바인딩 조건 체크
|
|
const name = propsWithoutFormConfig.name;
|
|
if (!name || !parentFormContext.dataKey || !parentFormContext.setState) {
|
|
return propsWithoutFormConfig;
|
|
}
|
|
|
|
// --- 자동 바인딩 스킵 조건 (engine-v1.17.6) ---
|
|
// autoBinding: false 명시 시 자동 바인딩 비활성화
|
|
// (라디오 등 auto-binding의 value 오버라이드가 유해한 컴포넌트에서 사용)
|
|
if (propsWithoutFormConfig.autoBinding === false) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { autoBinding: _ab, ...cleanProps } = propsWithoutFormConfig;
|
|
return cleanProps;
|
|
}
|
|
|
|
// 자동 바인딩 적용
|
|
const fullPath = `${parentFormContext.dataKey}.${name}`;
|
|
const boundValue = getNestedValue(parentFormContext.state, fullPath);
|
|
|
|
// 바인딩된 값이 있으면 사용, 없으면 props.value를 기본값으로 사용
|
|
const currentValue = boundValue !== undefined ? boundValue : propsWithoutFormConfig.value;
|
|
|
|
// 컴포넌트 메타데이터 기반 바인딩 타입 결정
|
|
// - 'checked': 항상 checked 바인딩 (Toggle, Checkbox, ChipCheckbox)
|
|
// - 'checkable': type이 checkbox/radio일 때만 checked (Input)
|
|
// - 미지정: 항상 value 바인딩 (RadioGroup, Select 등)
|
|
const componentMeta = registry.getMetadata(componentDef.name);
|
|
const metadataBindingType = componentMeta?.bindingType;
|
|
const isCheckedBinding = typeof currentValue === 'boolean' && (
|
|
metadataBindingType === 'checked'
|
|
|| (metadataBindingType === 'checkable'
|
|
&& ['checkbox', 'radio'].includes(propsWithoutFormConfig?.type))
|
|
);
|
|
|
|
// debounce 설정 가져오기
|
|
const debounceMs = parentFormContext.debounce;
|
|
|
|
/**
|
|
* Form 자동바인딩 상태 업데이트 — "이중 저장소 동기화" 구조 (engine-v1.43.0+)
|
|
*
|
|
* ⚠️ 엔진 유지보수자 필독: 아래 구조는 단순 구현이 아니라 "단일화 시도 실패" 이력의 결과다.
|
|
*
|
|
* [배경]
|
|
* 엔진은 폼 데이터를 두 저장소에 나눠 관리한다:
|
|
* - 저장소 A: React localDynamicState (useState)
|
|
* — Form 자동바인딩 쓰기, Input 컴포넌트 부분 리렌더로 빠른 반영
|
|
* - 저장소 B: globalState._local (TemplateApp)
|
|
* — G7Core.state.setLocal/getLocal, apiCall body 바인딩, 플러그인 동기화
|
|
*
|
|
* engine-v1.43.0 이전에는 자동바인딩이 A에만 썼다. CKEditor5 같은 플러그인이
|
|
* setLocal({render:false})로 B에 쓸 때 mergedPending = deepMerge(globalLocal, ...)의
|
|
* globalLocal base에 자동바인딩 값이 없어, 자동바인딩 값이 빈 초기값으로 덮이는
|
|
* 구조적 결함이 있었다.
|
|
*
|
|
* [왜 저장소 A를 없애고 B로 단일화하지 않는가]
|
|
* 1) B 단일화 = 매 키입력마다 TemplateApp.setGlobalState → root.render() 전체 리렌더.
|
|
* 대형 폼(필드 수십 개 + 바인딩 수천 개)에서 타이핑 지연 발생 위험.
|
|
* 2) "필요한 부분만 리렌더" 최적화 경로(경로 기반 구독 시스템)는 과거에 도입 후
|
|
* 필터 체크박스 클릭 수백 ms 지연 이슈로 전체 롤백된 실패 경로.
|
|
* 3) 즉 "구조 단일화 + 부분 리렌더"는 복구 경로 없는 설계. 이중 저장소를 전제로 운영한다.
|
|
*
|
|
* [동기화 규칙]
|
|
* - 자동바인딩 쓰기는 A + B 양쪽에 동시에 쓴다. B 쓰기는 render:false로 리렌더 억제.
|
|
* - A의 React useState가 해당 Input 자식 트리 리렌더를 담당 (성능 보존).
|
|
* - B는 플러그인/핸들러가 읽는 정본(source of truth). A는 B의 캐시가 아니라 "쓰는 시점에 강제로 일치시키는" 미러.
|
|
* - G7Core.state.setLocal({...}, {render:false}) 호출로 __g7PendingLocalState,
|
|
* __g7ForcedLocalFields, __g7SetLocalOverrideKeys, __g7LastSetLocalSnapshot,
|
|
* __g7SequenceLocalSync 보조 캐시도 모두 B와 함께 자동 갱신된다.
|
|
*
|
|
* [엔진 수정 시 주의]
|
|
* - 이 동기화 규칙을 지키지 않는 새 쓰기 경로를 추가하면 자동바인딩 정합성이 깨진다.
|
|
* - 엔진 레벨 재발 방지 장치 (같은 engine-v1.43.0 도입):
|
|
* · __g7AutoBindingPaths 레지스트리: 자동바인딩 마운트 중인 fullPath 추적 (아래 useEffect)
|
|
* · G7CoreGlobals.setLocal: render:false 호출이 레지스트리와 겹치면 render:true 자동 승격
|
|
* · 예외: options.selfManaged === true 인 경우 승격 제외. CKEditor5 등 자체 DOM 관리
|
|
* 플러그인이 의도적으로 render:false를 유지하려 할 때 명시. 누락 시 엔진이 자동 승격하여
|
|
* 정합성 보장 (safe-by-default).
|
|
* · 즉 플러그인이 render:false setLocal을 쓰든 자동바인딩과 충돌 시 엔진이 구조적으로 차단,
|
|
* selfManaged 명시한 플러그인만 예외적으로 render:false 유지.
|
|
* - 구독 기반 선택적 리렌더는 과거에 도입 후 롤백된 경로이므로 재시도 전에
|
|
* 반드시 관련 설계 검토 후 논의할 것.
|
|
*/
|
|
const performStateUpdate = (newValue: any) => {
|
|
// 중요: prev (localDynamicState) 대신 parentFormContext.state (extendedDataContext._local)를 기반으로 사용
|
|
// 그래야 init_actions로 설정된 API 데이터가 손실되지 않음
|
|
const baseState = parentFormContext.state || {};
|
|
const update = setNestedValue(baseState, fullPath, newValue);
|
|
|
|
// trackChanges가 true이면 hasChanges도 설정
|
|
if (parentFormContext.trackChanges) {
|
|
update.hasChanges = true;
|
|
}
|
|
|
|
// 저장소 A: React setState가 비동기이므로, 액션 핸들러에서 즉시 최신 값을 읽을 수 있도록
|
|
// 전역 캐시(__g7PendingLocalState)에 업데이트된 상태를 저장. G7Core.state.getLocal()이 이 캐시를 우선 사용.
|
|
//
|
|
// engine-v1.63.4 (사례 41): `update` 를 그대로 싣지 않는다. base 인 parentFormContext.state 는
|
|
// 재계산되지 않은 useMemo 결과일 수 있어, selfManaged 플러그인이 저장소 B 에만 쓴 편집분을
|
|
// 담고 있지 않다. 그 stale 스냅샷이 pending 에 실리면 아래 setLocal 이 그것을 base 로 채택해
|
|
// (`currentSnapshot = pendingState || baseLocal`) 저장소 B 를 통째 교체한다.
|
|
// 렌더러와 같은 순서로 __g7ForcedLocalFields 를 얹어 화면과 같은 스냅샷을 싣는다.
|
|
(window as any).__g7PendingLocalState = composeAutoBindingPendingSnapshot(
|
|
update,
|
|
(window as any).__g7ForcedLocalFields,
|
|
fullPath,
|
|
newValue
|
|
);
|
|
|
|
// 저장소 A: React localDynamicState — 해당 Input 트리 리렌더 (기존 경로, 성능 보존)
|
|
parentFormContext.setState!(update);
|
|
|
|
// _global.xxx dataKey는 _globalSetState가 이미 globalState에 직접 쓰므로 B 동기화 불필요
|
|
if ((parentFormContext as any)._isGlobal) return;
|
|
|
|
// 저장소 B: globalState._local — 플러그인/apiCall이 읽는 정본. render:false로 리렌더 억제
|
|
// 이중 저장소를 늘 일치시키는 핵심 1줄. 이 경로가 빠지면 자동바인딩 정합성이 깨진다.
|
|
const G7Core = (window as any).G7Core;
|
|
const patch: Record<string, any> = { [fullPath]: newValue };
|
|
if (parentFormContext.trackChanges) patch.hasChanges = true;
|
|
G7Core?.state?.setLocal?.(patch, { render: false });
|
|
};
|
|
|
|
// debounce가 설정되면 debounced 업데이트, 아니면 즉시 업데이트
|
|
// onComplete 콜백: 상태 업데이트 완료 후 실행 (액션 핸들러 등)
|
|
const debouncedStateUpdate = (newValue: any, onComplete?: () => void) => {
|
|
if (debounceMs && debounceMs > 0) {
|
|
// 기존 타이머 정리
|
|
if (autoBindingDebounceTimerRef.current) {
|
|
clearTimeout(autoBindingDebounceTimerRef.current);
|
|
}
|
|
// 새 타이머 설정
|
|
autoBindingDebounceTimerRef.current = setTimeout(() => {
|
|
performStateUpdate(newValue);
|
|
autoBindingDebounceTimerRef.current = null;
|
|
// pending ref는 여기서 클리어하지 않음!
|
|
// React setState는 비동기 배치되므로, 이 시점에서 클리어하면
|
|
// refs 클리어 후 ~ state 반영 전 사이 렌더에서 stale 값이 표시됨 (플리커)
|
|
// → useMemo에서 state가 pending 값과 일치하는 것을 확인한 후 클리어
|
|
// 상태 업데이트 후 콜백 실행
|
|
// __g7PendingLocalState 캐시를 사용하여 액션 핸들러에서 즉시 최신 값을 읽을 수 있음
|
|
if (onComplete) {
|
|
onComplete();
|
|
// 콜백 실행 후 캐시 정리
|
|
(window as any).__g7PendingLocalState = null;
|
|
}
|
|
}, debounceMs);
|
|
} else {
|
|
performStateUpdate(newValue);
|
|
// debounce가 없어도 동일하게 처리
|
|
if (onComplete) {
|
|
onComplete();
|
|
// 콜백 실행 후 캐시 정리
|
|
(window as any).__g7PendingLocalState = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
if (isCheckedBinding) {
|
|
// checked 바인딩 (Toggle, Checkbox 등) - debounce 미적용 (토글은 즉시 반영)
|
|
const autoOnChange = (e: React.ChangeEvent<HTMLInputElement> | { target: { checked: boolean } }) => {
|
|
const newValue = e.target.checked;
|
|
performStateUpdate(newValue);
|
|
|
|
// 기존 onChange가 있으면 호출
|
|
if (propsWithoutFormConfig.onChange) {
|
|
propsWithoutFormConfig.onChange(e as any);
|
|
}
|
|
};
|
|
|
|
return {
|
|
...propsWithoutFormConfig,
|
|
checked: currentValue, // 체크박스는 checked prop 사용
|
|
onChange: autoOnChange,
|
|
};
|
|
} else {
|
|
// value 바인딩 (Input, Textarea, Select, IconSelect, MultilingualInput 등)
|
|
// 이벤트 객체 형태 { target: { value } } 또는 직접 값 전달 둘 다 지원
|
|
const autoOnChange = (eventOrValue: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> | { target: { value: any } } | any) => {
|
|
// 이벤트 객체인지 직접 값인지 판단
|
|
const newValue = eventOrValue?.target?.value !== undefined
|
|
? eventOrValue.target.value
|
|
: eventOrValue;
|
|
|
|
// debounce 활성 시 pending value 즉시 저장 (플리커 방지)
|
|
// useMemo 재계산 시 stale state value 대신 이 값을 사용
|
|
if (debounceMs && debounceMs > 0) {
|
|
autoBindingPendingValueRef.current = newValue;
|
|
}
|
|
|
|
// debounce 콜백에서 사용할 이벤트 복사본 생성
|
|
// React SyntheticEvent는 비동기적으로 접근하면 속성이 null이 되므로
|
|
// 필요한 값을 미리 복사해둠 (300ms debounce 후에도 값에 접근 가능)
|
|
const eventCopy = eventOrValue?.target
|
|
? {
|
|
target: {
|
|
value: newValue,
|
|
name: eventOrValue.target.name,
|
|
type: eventOrValue.target.type,
|
|
checked: eventOrValue.target.checked,
|
|
},
|
|
currentTarget: eventOrValue.currentTarget ? {
|
|
value: newValue,
|
|
name: eventOrValue.currentTarget.name,
|
|
} : undefined,
|
|
}
|
|
: eventOrValue;
|
|
|
|
// debounce가 설정되면 debounced 업데이트
|
|
// 상태 업데이트 완료 후 기존 onChange(액션 핸들러) 호출
|
|
debouncedStateUpdate(newValue, () => {
|
|
if (propsWithoutFormConfig.onChange) {
|
|
propsWithoutFormConfig.onChange(eventCopy);
|
|
}
|
|
});
|
|
};
|
|
|
|
// debounce 대기 중 또는 debounce 완료 직후 pending value 사용 (플리커 방지)
|
|
// 부모 리렌더링 시 currentValue는 stale state에서 읽힌 값일 수 있음
|
|
// pending ref는 debounce 콜백에서 클리어하지 않고, state가 확정된 후 여기서 클리어
|
|
let effectiveValue: any;
|
|
if (autoBindingPendingValueRef.current !== undefined) {
|
|
if (autoBindingDebounceTimerRef.current !== null) {
|
|
// 타이머 실행 중 → 사용자 입력 중, pending value 사용
|
|
effectiveValue = autoBindingPendingValueRef.current;
|
|
} else {
|
|
// 타이머 완료 (null) → state 반영 대기 중
|
|
// state가 pending 값과 일치하면 확정 → pending 클리어
|
|
if (String(currentValue ?? '') === String(autoBindingPendingValueRef.current)) {
|
|
autoBindingPendingValueRef.current = undefined;
|
|
effectiveValue = currentValue ?? '';
|
|
} else {
|
|
// state가 아직 반영되지 않음 → pending value 유지 (플리커 방지)
|
|
effectiveValue = autoBindingPendingValueRef.current;
|
|
}
|
|
}
|
|
} else {
|
|
effectiveValue = currentValue ?? '';
|
|
}
|
|
|
|
return {
|
|
...propsWithoutFormConfig,
|
|
value: effectiveValue,
|
|
onChange: autoOnChange,
|
|
};
|
|
}
|
|
}, [stableResolvedProps, parentFormContext, parentFormContext.state, parentFormContext.debounce]);
|
|
|
|
/**
|
|
* 메인 렌더링 로직
|
|
*/
|
|
const renderComponent = useMemo(() => {
|
|
// DevTools 렌더 추적
|
|
const devToolsRender = getDevTools();
|
|
if (devToolsRender?.isEnabled()) {
|
|
devToolsRender.trackRender(componentDef.name);
|
|
}
|
|
|
|
// 조건부 렌더링 체크
|
|
//
|
|
// iteration 이 있으면 여기서 끊지 않는다. `if` 가 항목 변수(`{{user.is_active}}` 등)를
|
|
// 참조하는 경우 이 시점의 컨텍스트에는 그 변수가 없어 조건이 항상 false 가 되고,
|
|
// **목록 전체가 렌더되지 않는다**(에러도 경고도 없음). 반복 렌더 경로
|
|
// (renderItemChildren)는 항목별 컨텍스트에서 평가하므로 같은 작성이 정상 동작했다 —
|
|
// 그 비대칭을 없앤다. 항목별 재평가는 자식 DynamicRenderer 가 수행한다
|
|
// (iteration 만 제거한 정의를 넘기므로 `if` 는 그대로 남는다).
|
|
// @since engine-v1.56.0
|
|
if (!shouldRender) {
|
|
if (!effectiveComponentDef.iteration) {
|
|
return null;
|
|
}
|
|
return renderIteration;
|
|
}
|
|
|
|
// 정렬 가능 리스트 렌더링 체크 (sortable은 iteration보다 우선)
|
|
if (effectiveComponentDef.sortable && effectiveComponentDef.itemTemplate) {
|
|
return renderSortable;
|
|
}
|
|
|
|
// 반복 렌더링 체크
|
|
if (effectiveComponentDef.iteration) {
|
|
return renderIteration;
|
|
}
|
|
|
|
// Extension Point 타입 처리
|
|
// 백엔드에서 이미 children에 컴포넌트를 주입한 상태로 전달됩니다.
|
|
// 프론트엔드에서는 단순히 컨테이너로 렌더링하고 children을 출력합니다.
|
|
if (effectiveComponentDef.type === 'extension_point') {
|
|
const { layout, columns, gap, className, ...restProps } = effectiveComponentDef.props || {};
|
|
|
|
// 레이아웃 설정에 따른 컨테이너 스타일 결정
|
|
let containerClass = className || '';
|
|
if (layout === 'grid' && columns) {
|
|
containerClass = `${containerClass} grid grid-cols-${columns} gap-${gap || 4}`.trim();
|
|
} else if (layout === 'flex') {
|
|
containerClass = `${containerClass} flex flex-col gap-${gap || 4}`.trim();
|
|
}
|
|
|
|
return (
|
|
<div
|
|
id={resolvedComponentId}
|
|
className={containerClass || undefined}
|
|
{...restProps}
|
|
>
|
|
{renderChildren}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ComponentRegistry에서 컴포넌트 조회
|
|
const Component = registry.getComponent(effectiveComponentDef.name);
|
|
|
|
if (!Component) {
|
|
logger.error(
|
|
`컴포넌트를 찾을 수 없습니다: ${effectiveComponentDef.name} (ID: ${effectiveComponentDef.id})`,
|
|
'componentDef:', JSON.stringify(effectiveComponentDef, null, 2)
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// 컴포넌트에 전달할 최종 props 구성
|
|
// - 컴포넌트 정의의 id를 DOM id로 전달 (showErrorPage의 containerId로 사용 가능)
|
|
// - props에 이미 id가 명시적으로 지정되어 있으면 그것을 우선 사용
|
|
// - cellChildren이 있는 컴포넌트는 _remountKeys 변경 시 리렌더링되어야 함
|
|
const remountKeys = dataContext._global?._remountKeys;
|
|
const finalProps: Record<string, any> = {
|
|
...propsWithAutoBinding,
|
|
id: propsWithAutoBinding.id ?? resolvedComponentId,
|
|
// _remountKeys가 변경되면 React가 props 변경을 감지하여 컴포넌트 리렌더링
|
|
// cellChildren 내부의 컴포넌트가 새 key를 받을 수 있도록 함
|
|
...(remountKeys && Object.keys(remountKeys).length > 0 && {
|
|
__remountTrigger: JSON.stringify(remountKeys),
|
|
}),
|
|
};
|
|
|
|
// Sortable drag handle 자동 적용
|
|
// data-drag-handle 속성이 있고 sortableContext가 있으면 listeners 적용
|
|
if (sortableContext?.listeners && sortableContext.handle) {
|
|
const hasDragHandle = propsWithAutoBinding['data-drag-handle'] !== undefined;
|
|
if (hasDragHandle) {
|
|
// listeners를 finalProps에 병합
|
|
Object.assign(finalProps, sortableContext.listeners);
|
|
// 드래그 커서 스타일 추가
|
|
finalProps.style = {
|
|
...finalProps.style,
|
|
cursor: sortableContext.isDragging ? 'grabbing' : 'grab',
|
|
};
|
|
}
|
|
}
|
|
|
|
// 편집 모드일 때 data-editor-* 표식 + 선택/hover 핸들러 주입
|
|
//
|
|
// @since engine-v1.50.0 — 주입분을 단일 `editorAttrs` 객체로도 묶어
|
|
// 전달한다. composite/layout 컴포넌트는 도메인 prop 만 명시 구조분해하고 미명시
|
|
// props 를 DOM 으로 흘리지 않으므로, 개별 키 주입만으로는 `data-editor-*`/핸들러가
|
|
// 유실되어 그 노드가 편집기의 드롭 슬롯/선택/드래그 대상에서 누락됐다.
|
|
// 각 nesting 컴포넌트가 `editorAttrs` 를 받아 시각적 루트에 spread 하면 유실이 해소된다.
|
|
// basic 컴포넌트(`{...props}` 패스스루)는 아래 개별 키 주입으로 종전대로 동작하며,
|
|
// `editorAttrs` 객체 키는 basic/layout DOM-safe 필터(아래)에서 제외해 DOM 누출을 막는다.
|
|
if (isEditMode) {
|
|
// id가 없는 컴포넌트에 자동 ID 생성 (컴포넌트 타입과 랜덤 문자열 기반)
|
|
const editorId =
|
|
effectiveComponentDef.id ||
|
|
`auto_${effectiveComponentDef.name || effectiveComponentDef.type || 'unknown'}_${Math.random().toString(36).substring(2, 8)}`;
|
|
|
|
// 편집 모드 클릭 핸들러
|
|
// - 편집 모드에서는 그 컴포넌트의 모든 액션이 발동되지 않는다 (위에서
|
|
// bindComponentActions 에 actions=undefined 전달로 가로채짐).
|
|
// - 추가로 `<a href>` 의 기본 브라우저 네비게이션을 막기 위해 preventDefault.
|
|
// - 클릭 의미는 오직 "요소 선택" — onComponentSelect 만 호출.
|
|
const editorOnClick = (e: React.MouseEvent) => {
|
|
// <a href> 기본 네비게이션 차단 — 전체 페이지 리로드 방지
|
|
// (편집 모드에서 전체 화면 리프레시가 일어나서는 안 된다)
|
|
if (typeof e.preventDefault === 'function') {
|
|
e.preventDefault();
|
|
}
|
|
if (onComponentSelect) {
|
|
e.stopPropagation();
|
|
onComponentSelect(editorId, e);
|
|
}
|
|
};
|
|
|
|
// editorAttrs 단일 객체 — composite/layout 컴포넌트가 명시 수신 후 루트에 spread.
|
|
const editorAttrs: Record<string, unknown> = {
|
|
'data-editor-id': editorId,
|
|
'data-editor-name': effectiveComponentDef.name,
|
|
'data-editor-type': effectiveComponentDef.type,
|
|
onClick: editorOnClick,
|
|
};
|
|
// 인덱스 경로 추가 (ID 없는 컴포넌트 편집용)
|
|
if (componentPath) {
|
|
editorAttrs['data-editor-path'] = componentPath;
|
|
}
|
|
// 슬롯 마커 — 공통(base) 레이아웃 편집 시 자식 콘텐츠가 삽입될 슬롯
|
|
// 위치(`slot: "content"` 등)를 편집기 오버레이/CSS 가 시각화할 수 있도록 슬롯 이름을
|
|
// DOM 속성으로 노출한다. base 편집 캔버스는 슬롯 노드가 SlotContext 의 원위치 미렌더
|
|
// (`return null`)에 걸리지 않도록 PreviewCanvas 가 `slot` 키를 표시 마커
|
|
// `__editorSlotName` 으로 치환해 공급하므로 마커를 우선 읽는다. 편집 모드(editorAttrs
|
|
// 부여 시)에만 추가 — 운영 렌더 영향 0.
|
|
const slotMarker =
|
|
(effectiveComponentDef as { __editorSlotName?: unknown }).__editorSlotName ??
|
|
(effectiveComponentDef as { slot?: unknown }).slot;
|
|
if (typeof slotMarker === 'string') {
|
|
editorAttrs['data-editor-slot'] = slotMarker;
|
|
}
|
|
|
|
// 편집 모드 호버 핸들러
|
|
// onMouseMove + stopPropagation을 사용하여 가장 안쪽(자식) 컴포넌트가 우선 하이라이트되도록 함
|
|
if (onComponentHover) {
|
|
editorAttrs.onMouseMove = (e: React.MouseEvent) => {
|
|
e.stopPropagation(); // 부모 컴포넌트로 이벤트 전파 방지
|
|
onComponentHover(editorId, e);
|
|
};
|
|
editorAttrs.onMouseLeave = (e: React.MouseEvent) => {
|
|
onComponentHover(null, e);
|
|
};
|
|
}
|
|
|
|
// (1) basic 컴포넌트(`{...props}` 패스스루)용 — 개별 키 주입 유지.
|
|
Object.assign(finalProps, editorAttrs);
|
|
// (2) composite/layout 컴포넌트용 — 단일 객체 주입(명시 수신 → 루트 spread).
|
|
finalProps.editorAttrs = editorAttrs;
|
|
|
|
// 드래그(draggable/onDragStart/onDragEnd) props 주입은 제거됨.
|
|
// - 끌어 이동은 `DndCanvasLayer` 가 `[data-editor-path]` 를 DOM 쿼리해 투명 드래그
|
|
// 핸들 오버레이를 얹고 dnd-kit PointerSensor 로 처리한다.
|
|
// - `PreviewCanvas` 는 DynamicRenderer 에 onDragStart/onDragEnd 를 전달하지 않으므로
|
|
// 기존 `if (onDragStart)` 블록은 이미 죽은 경로였고, native `draggable=true` 는
|
|
// HTML5 drag 와 dnd-kit pointer drag 가 동시에 걸리는 충돌 표면일 뿐이었다.
|
|
// 호출 그래프 전수 확인 후 제거(engine-regression-code-first 규율). @since engine-v1.50.0
|
|
}
|
|
|
|
// 컴포넌트 렌더링
|
|
// Basic/Layout 컴포넌트는 HTML 요소를 직접 렌더링하므로
|
|
// 내부 props(__componentContext 등)를 DOM에 전달하면 [object Object]로 노출됨
|
|
// Composite 컴포넌트(React 컴포넌트)는 내부 props를 직접 처리하므로 그대로 전달
|
|
//
|
|
// @since engine-v1.50.0 `__` 시작 키 일괄 차단 안전망.
|
|
// 백엔드가 `with_source_meta=1` 옵션 응답에 `__source` 메타를 부여할 수 있으며,
|
|
// 일반 사이트 렌더에도 만일의 누수가 발생해도 React props 로 전달되지 않도록 차단.
|
|
const isBasicComponent = effectiveComponentDef.type === 'basic';
|
|
const isLayoutComponent = effectiveComponentDef.type === 'layout';
|
|
const isBasicOrLayoutComponent = isBasicComponent || isLayoutComponent;
|
|
const propsForRendering = isBasicOrLayoutComponent
|
|
? (() => {
|
|
const domSafeProps: Record<string, any> = {};
|
|
for (const [key, value] of Object.entries(finalProps)) {
|
|
// `__` 접두사는 G7 내부 메타 전용 약속 — DOM 노출 차단(basic/layout 공통)
|
|
if (key.startsWith('__')) {
|
|
continue;
|
|
}
|
|
// `editorAttrs` 객체 키 처리 — **타입별로 다르다**:
|
|
// - **basic**: `{...props}` 패스스루라 `editorAttrs` 가 그대로 DOM 에 흘러
|
|
// `<div editorAttrs={..}>` 누출 + React 경고 → **제거**. 편집 표식은
|
|
// 위 Object.assign 으로 주입한 개별 `data-editor-*` 키가 담당(basic 이 spread).
|
|
// - **layout**: Container/Flex/Grid 등은 도메인 prop 만 명시 구조분해하고
|
|
// `{...props}` 패스스루를 **안 하므로** 개별 `data-editor-*` 키가 DOM 에
|
|
// 도달하지 못한다. 대신 `editorAttrs` 를 **명시 수신해 루트에 spread** 하므로
|
|
// 이 키를 **남겨야** 한다(제거하면 layout 노드가 편집기에서 누락 — 컨테이너
|
|
// 바깥 드롭존 소실의 원인.). DOM 누출은 컴포넌트가 editorAttrs 만
|
|
// spread 하고 객체 자체를 DOM 에 안 흘리므로 발생하지 않는다.
|
|
// @since engine-v1.50.0
|
|
if (key === 'editorAttrs' && isBasicComponent) {
|
|
continue;
|
|
}
|
|
domSafeProps[key] = value;
|
|
}
|
|
return domSafeProps;
|
|
})()
|
|
: finalProps;
|
|
|
|
let componentElement: React.ReactNode = <Component {...propsForRendering}>{renderChildren}</Component>;
|
|
|
|
// dataKey가 있으면 FormProvider로 감싸기
|
|
// formContextForChildren은 effectiveComponentDef.dataKey를 사용 (componentDef 레벨)
|
|
// currentFormContext는 stableResolvedProps.dataKey를 사용하므로 맞지 않음 (props 내부에 dataKey가 없음)
|
|
if (formContextForChildren) {
|
|
componentElement = (
|
|
<FormProvider value={formContextForChildren}>
|
|
{componentElement}
|
|
</FormProvider>
|
|
);
|
|
}
|
|
|
|
// isolatedState가 있으면 IsolatedStateProvider로 감싸기
|
|
// IsolatedStateProvider는 가장 바깥에 위치하여 독립된 상태 스코프 생성
|
|
if (effectiveComponentDef.isolatedState) {
|
|
const baseIsolatedState = typeof effectiveComponentDef.isolatedState === 'object'
|
|
? effectiveComponentDef.isolatedState
|
|
: {};
|
|
|
|
// _isolatedInit가 있으면 초기 상태와 병합 (data_sources의 initIsolated 옵션 지원)
|
|
const isolatedInitData = (dataContext as any)?._isolatedInit;
|
|
const initialIsolatedState = isolatedInitData
|
|
? { ...baseIsolatedState, ...isolatedInitData }
|
|
: baseIsolatedState;
|
|
|
|
componentElement = (
|
|
<IsolatedStateProvider
|
|
initialState={initialIsolatedState}
|
|
scopeId={effectiveComponentDef.isolatedScopeId}
|
|
>
|
|
{componentElement}
|
|
</IsolatedStateProvider>
|
|
);
|
|
}
|
|
|
|
return componentElement;
|
|
}, [
|
|
shouldRender,
|
|
effectiveComponentDef.iteration,
|
|
effectiveComponentDef.sortable,
|
|
effectiveComponentDef.itemTemplate,
|
|
effectiveComponentDef.type,
|
|
effectiveComponentDef.name,
|
|
effectiveComponentDef.id,
|
|
effectiveComponentDef.props,
|
|
effectiveComponentDef.isolatedState,
|
|
effectiveComponentDef.isolatedScopeId,
|
|
registry,
|
|
propsWithAutoBinding,
|
|
renderChildren,
|
|
renderIteration,
|
|
renderSortable,
|
|
sortableContext,
|
|
currentFormContext,
|
|
isEditMode,
|
|
onComponentSelect,
|
|
onComponentHover,
|
|
onDragStart,
|
|
onDragEnd,
|
|
componentPath,
|
|
// cellChildren이 있는 컴포넌트가 _remountKeys 변경 시 리렌더링되도록 의존성 추가
|
|
dataContext._global?._remountKeys,
|
|
]);
|
|
|
|
/**
|
|
* blur_until_loaded 적용 여부 결정
|
|
*
|
|
* blur_until_loaded는 다음 형식을 지원합니다:
|
|
* - boolean true: 모든 데이터 소스 로딩 완료까지 blur
|
|
* - string: 표현식 (예: "{{_global.isSaving}}")이 truthy일 때 blur
|
|
* - object: { enabled: boolean, data_sources?: string | string[] }
|
|
* - enabled: blur 활성화 여부
|
|
* - data_sources: 특정 데이터 소스만 체크 (지정하지 않으면 모든 데이터 소스 체크)
|
|
*
|
|
* blur 적용 조건:
|
|
* 1. 페이지 전환 중 (isTransitioning === true)
|
|
* 2. 지정된 데이터 소스가 아직 로드되지 않음 (undefined)
|
|
*/
|
|
const shouldApplyBlur = useMemo(() => {
|
|
const blurConfig = effectiveComponentDef.blur_until_loaded;
|
|
|
|
if (!blurConfig) {
|
|
return false;
|
|
}
|
|
|
|
// 프리뷰 모드에서는 blur 비활성화 — 시각적 확인이 목적이므로
|
|
// @since engine-v1.26.1
|
|
if (dataContext?._global?.__isPreview) {
|
|
return false;
|
|
}
|
|
|
|
// 표현식 문자열인 경우 (예: "{{_global.isSaving}}")
|
|
if (typeof blurConfig === 'string') {
|
|
const singleBindingPath = extractSingleBinding(blurConfig);
|
|
if (singleBindingPath !== null) {
|
|
try {
|
|
// DevTools 추적을 위한 컴포넌트 컨텍스트 정보
|
|
const blurTrackingInfo = {
|
|
componentId: effectiveComponentDef.id,
|
|
componentName: effectiveComponentDef.name,
|
|
propName: 'blur_until_loaded',
|
|
};
|
|
// blur_until_loaded 는 truthiness 게이트다. 파이프를 resolveBindings 로
|
|
// 위임하면 결과가 항상 문자열이 되어 "false"/"0" 도 truthy 가 되므로
|
|
// 여기서는 파이프 분기를 두지 않는다(블러가 영구히 켜지는 편이 더 나쁘다).
|
|
// 파이프는 값 서식용이므로 이 속성에 쓰이지 않는다.
|
|
const result = bindingEngine.evaluateExpression(singleBindingPath, extendedDataContext, blurTrackingInfo);
|
|
return Boolean(result);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`blur_until_loaded 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
|
|
error
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 객체 형태인 경우: { enabled: boolean, data_sources?: string | string[] }
|
|
if (typeof blurConfig === 'object' && blurConfig !== null) {
|
|
if (!blurConfig.enabled) {
|
|
return false;
|
|
}
|
|
|
|
// data_sources가 지정된 경우 해당 데이터 소스만 체크 (isTransitioning 무시)
|
|
if (blurConfig.data_sources) {
|
|
const targetSources = Array.isArray(blurConfig.data_sources)
|
|
? blurConfig.data_sources
|
|
: [blurConfig.data_sources];
|
|
return targetSources.some((key: string) => dataContext[key] === undefined);
|
|
}
|
|
|
|
// data_sources가 없으면 페이지 전환 상태 또는 모든 데이터 소스 체크
|
|
if (isTransitioning) {
|
|
return true;
|
|
}
|
|
|
|
const systemKeys = ['route', 'query', '_global', '_local', '_dataSourceErrors'];
|
|
const dataSourceKeys = Object.keys(dataContext).filter(
|
|
key => !systemKeys.includes(key) && !key.startsWith('_')
|
|
);
|
|
return dataSourceKeys.some(key => dataContext[key] === undefined);
|
|
}
|
|
|
|
// boolean true인 경우
|
|
|
|
// 1. 페이지 전환 중이면 blur 적용
|
|
if (isTransitioning) {
|
|
return true;
|
|
}
|
|
|
|
// 2. 모든 데이터 소스 상태 체크
|
|
const systemKeys = ['route', 'query', '_global', '_local', '_dataSourceErrors'];
|
|
const dataSourceKeys = Object.keys(dataContext).filter(
|
|
key => !systemKeys.includes(key) && !key.startsWith('_')
|
|
);
|
|
return dataSourceKeys.some(key => dataContext[key] === undefined);
|
|
}, [effectiveComponentDef.blur_until_loaded, effectiveComponentDef.id, isTransitioning, dataContext, extendedDataContext, bindingEngine, extractSingleBinding]);
|
|
|
|
// DEBUG: blur 적용 시 로깅
|
|
if (shouldApplyBlur) {
|
|
const blurConfig = effectiveComponentDef.blur_until_loaded;
|
|
if (typeof blurConfig === 'object' && blurConfig?.data_sources) {
|
|
const targetSources = Array.isArray(blurConfig.data_sources)
|
|
? blurConfig.data_sources
|
|
: [blurConfig.data_sources];
|
|
const dataSourceStatus = targetSources.reduce((acc, key) => {
|
|
acc[key] = dataContext[key] === undefined ? 'UNDEFINED' : 'LOADED';
|
|
return acc;
|
|
}, {} as Record<string, string>);
|
|
// dataContext에 있는 모든 키도 출력
|
|
const allDataContextKeys = Object.keys(dataContext).filter(k => !k.startsWith('_'));
|
|
logger.log(`[BLUR APPLIED] Component: ${effectiveComponentDef.id}, data_sources status:`, dataSourceStatus, 'Available keys:', allDataContextKeys);
|
|
} else {
|
|
logger.log(`[BLUR APPLIED] Component: ${effectiveComponentDef.id}, blur_until_loaded:`, blurConfig);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* blur_until_loaded 래퍼에 전달할 그리드 아이템 클래스 추출
|
|
*
|
|
* CSS Grid에서 col-span, row-span 등은 그리드 컨테이너의 직접 자식에만 적용됩니다.
|
|
* blur_until_loaded가 래퍼 div를 생성하면 그리드 레이아웃이 깨지므로,
|
|
* 그리드 아이템 관련 클래스를 래퍼로 전달합니다.
|
|
*
|
|
* @since engine-v1.17.6
|
|
*
|
|
* 중요: Rules of Hooks 준수 — 아래 early return(slot 조건) 보다 먼저 호출되어야 함.
|
|
* 같은 컴포넌트에서 렌더 경로가 달라지더라도 hook 호출 순서/개수가 일관되어야 한다.
|
|
*/
|
|
const getBlurWrapperClasses = useMemo(() => {
|
|
if (!effectiveComponentDef.blur_until_loaded) {
|
|
return { wrapperClasses: '', innerClasses: '' };
|
|
}
|
|
|
|
const className = resolvedProps?.className || '';
|
|
if (!className) {
|
|
return { wrapperClasses: '', innerClasses: '' };
|
|
}
|
|
|
|
// 그리드 아이템/플렉스 아이템 관련 클래스 패턴
|
|
// - col-span-*, row-span-*: 그리드 셀 확장
|
|
// - col-start-*, col-end-*, row-start-*, row-end-*: 그리드 위치
|
|
// - self-*, place-self-*: 아이템 정렬
|
|
// - order-*: 순서
|
|
// - flex-shrink-*, flex-grow-*, flex-*: 플렉스 아이템
|
|
// 반응형 접두사 포함: sm:, md:, lg:, xl:, 2xl:
|
|
const gridItemPatterns = [
|
|
/^(sm:|md:|lg:|xl:|2xl:)?(col-span-|row-span-|col-start-|col-end-|row-start-|row-end-)/,
|
|
/^(sm:|md:|lg:|xl:|2xl:)?(self-|place-self-|order-)/,
|
|
/^(sm:|md:|lg:|xl:|2xl:)?(flex-shrink|flex-grow|flex-\d|basis-)/,
|
|
];
|
|
|
|
const classes = className.split(/\s+/).filter(Boolean);
|
|
const wrapperClassList: string[] = [];
|
|
const innerClassList: string[] = [];
|
|
|
|
for (const cls of classes) {
|
|
const isGridItemClass = gridItemPatterns.some(pattern => pattern.test(cls));
|
|
if (isGridItemClass) {
|
|
wrapperClassList.push(cls);
|
|
} else {
|
|
innerClassList.push(cls);
|
|
}
|
|
}
|
|
|
|
return {
|
|
wrapperClasses: wrapperClassList.join(' '),
|
|
innerClasses: innerClassList.join(' '),
|
|
};
|
|
}, [effectiveComponentDef.blur_until_loaded, resolvedProps?.className]);
|
|
|
|
/**
|
|
* slot 속성이 있는 컴포넌트는 원래 위치에서 렌더링하지 않음
|
|
*
|
|
* SlotContext에 등록되어 SlotContainer에서 렌더링됩니다.
|
|
* useEffect에서 이미 등록 처리가 완료되었으므로 여기서는 null 반환만 합니다.
|
|
*
|
|
* 중요: React Context 대신 window.__slotContextValue를 직접 확인
|
|
* - 타이밍 이슈로 slotContext.isEnabled가 false일 수 있음
|
|
*/
|
|
const globalSlotContextForRender = (window as any).__slotContextValue;
|
|
if (resolvedSlotId && globalSlotContextForRender?.isEnabled) {
|
|
return null;
|
|
}
|
|
|
|
// 기본 렌더링 결과
|
|
const renderedContent = (
|
|
<ComponentErrorBoundary
|
|
componentId={componentDef.id}
|
|
componentName={componentDef.name}
|
|
>
|
|
{effectiveComponentDef.blur_until_loaded ? (
|
|
<div className={[
|
|
getBlurWrapperClasses.wrapperClasses,
|
|
shouldApplyBlur ? "opacity-50 blur-sm transition-all duration-300 pointer-events-none" : undefined
|
|
].filter(Boolean).join(' ') || undefined}>
|
|
{renderComponent}
|
|
</div>
|
|
) : (
|
|
renderComponent
|
|
)}
|
|
</ComponentErrorBoundary>
|
|
);
|
|
|
|
// 외부(renderTemplate/updateTemplateData)에서 SlotProvider를 제공하므로
|
|
// isRootRenderer에서의 추가 SlotProvider 래핑 불필요 (이중 래핑 방지)
|
|
return renderedContent;
|
|
}
|
|
);
|
|
|
|
DynamicRenderer.displayName = 'DynamicRenderer';
|
|
|
|
/**
|
|
* DynamicRenderer를 전역에 노출
|
|
*
|
|
* SlotContainer 등 외부 컴포넌트에서 G7Core.getDynamicRenderer()로 접근할 수 있도록 합니다.
|
|
*/
|
|
if (typeof window !== 'undefined') {
|
|
(window as any).__DynamicRenderer = DynamicRenderer;
|
|
}
|
|
|
|
export default DynamicRenderer;
|