/** * NOTE: @since versions (engine-v1.x.x) refer to internal template engine * development iterations, not G7 platform release versions. */ /** * ActionDispatcher.ts * * 그누보드7 템플릿 엔진의 이벤트 핸들러 관리 및 액션 실행 엔진 * * 주요 기능: * - 이벤트 핸들러 바인딩 (onClick, onChange 등) * - 액션 타입별 실행 (navigate, apiCall, setState 등) * - 파라미터 바인딩 및 {{}} 변수 치환 * - React Router 및 상태 관리 통합 * * @module ActionDispatcher */ import { DataBindingEngine } from './DataBindingEngine'; import { extractSingleBinding } from './BindingShape'; import { hasPipes } from './PipeRegistry'; import { resolveExpressionString } from './helpers/RenderHelpers'; import { TranslationEngine, TranslationContext } from './TranslationEngine'; import { AuthManager, AuthType } from '../auth/AuthManager'; import { getApiClient } from '../api/ApiClient'; import { getErrorHandlingResolver } from '../error'; import type { ErrorHandlerConfig, ErrorContext } from '../types/ErrorHandling'; import { createLogger } from '../utils/Logger'; import type { G7DevToolsInterface } from './G7CoreGlobals'; import { evaluateConditionBranches } from './helpers/ConditionEvaluator'; import { addMissingLeafKeys } from './helpers/StateMerge'; import { triggerModalParentUpdate } from './ParentContextProvider'; import type { GlobalHeaderRule } from './LayoutLoader'; import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor'; import { isAbortError, isNetworkFailure, loadScriptWithRetry, loadStylesheetWithRetry } from './networkResilience'; import { isAllowedScriptSrc, getTrustedScriptHosts } from '../support/scriptSrcPolicy'; const logger = createLogger('ActionDispatcher'); /** * 액션 실패 시 화면에 띄울 문구를 결정합니다. * * 우선순위는 (1) 서버가 준 메시지 → (2) 네트워크 실패 안내 → (3) 내부 식별 문구다. * * 네트워크 실패(`TypeError: Failed to fetch`)와 요청 취소(AbortError)는 **응답 자체가 없어** * 서버 메시지가 존재하지 않는다. 그때 내부 식별 문구(`Failed to execute action: apiCall`)를 * 그대로 토스트에 띄우면 운영자는 무슨 일이 일어났는지 알 수 없고 다국어도 적용되지 않는다. * * @param originalError 원래 발생한 에러 * @param handler 실패한 핸들러명 * @param serverMessage 서버 응답이 준 메시지 (없으면 undefined) * @param fallbackMessage 위 둘이 없을 때 쓸 문구 (기본: 내부 식별 문구). * `ActionError` 가 이미 고유 메시지를 들고 있으면 그것을 넘겨 보존한다. * @return string 화면에 띄울 문구 ($t: 구문이면 호출부가 번역한다) * @since engine-v1.54.6 */ export function resolveActionFailureMessage( originalError: unknown, handler: string, serverMessage?: string, fallbackMessage?: string ): string { if (serverMessage) { return serverMessage; } if (isNetworkFailure(originalError) || isAbortError(originalError)) { return '$t:core.errors.network_request_failed'; } return fallbackMessage || `Failed to execute action: ${handler}`; } /** * 프리뷰 모드에서 억제되는 핸들러 목록 * * 프리뷰 페이지 이탈 또는 현재 세션 변경을 유발하는 핸들러를 정의합니다. * 이 목록에 포함된 핸들러는 프리뷰 모드에서 실행되지 않고 로그만 남깁니다. * * @since engine-v1.26.1 */ const PREVIEW_SUPPRESSED_HANDLERS: ReadonlySet = new Set([ // 직접 네비게이션 — 현재 페이지를 떠남 'navigate', 'navigateBack', 'navigateForward', 'replaceUrl', // 간접 네비게이션 — 페이지 리로드 또는 리다이렉트 유발 'refresh', // window.location.reload() 'logout', // 로그아웃 후 로그인 페이지로 리다이렉트 ]); /** * 프리뷰 모드에서 억제되는 레이아웃 기능 목록 * * 레이아웃 JSON의 최상위 기능 중 프리뷰 모드에서 비활성화할 대상입니다. * redirect 속성 등 라우트 레벨에서 페이지 이탈을 유발하는 기능을 정의합니다. * * @since engine-v1.26.1 */ const PREVIEW_SUPPRESSED_LAYOUT_FEATURES: ReadonlySet = new Set([ 'redirect', // 라우트 리다이렉트 속성 ]); /** * 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; } } // ============================================================================ // 타입 정의 // ============================================================================ /** * 액션 타입 */ export type ActionType = | 'navigate' // 페이지 이동 | 'navigateBack' // 브라우저 뒤로가기 | 'navigateForward' // 브라우저 앞으로가기 | 'replaceUrl' // URL만 변경 (데이터소스 refetch 없음) | 'apiCall' // API 호출 | 'login' // 로그인 (토큰 저장 포함) | 'loginTwoFactor' // 2단계 인증 코드 확인 (로그인 완료) | 'loginTwoFactorResend' // 2단계 인증 코드 재발송 | 'logout' // 로그아웃 | 'setState' // 상태 변경 | 'setError' // 에러 상태 설정 | 'openModal' // 모달 열기 | 'closeModal' // 모달 닫기 | 'showAlert' // 알림 표시 | 'toast' // 토스트 알림 표시 | 'switch' // 조건 분기 처리 | 'conditions' // 조건 분기 처리 (AND/OR 그룹, if-else 체인 지원) | 'sequence' // 순차 액션 실행 | 'parallel' // 병렬 액션 실행 | 'showErrorPage' // 에러 페이지 표시 | 'loadScript' // 외부 스크립트 동적 로드 | 'callExternal' // 외부 스크립트 생성자/메서드 호출 | 'callExternalEmbed' // 외부 스크립트를 레이어 모드로 임베드 | 'saveToLocalStorage' // 로컬스토리지에 저장 | 'loadFromLocalStorage' // 로컬스토리지에서 불러오기 | 'scrollIntoView' // 특정 요소로 스크롤 | 'ensureIdentityVerified' // 본인인증 선제 보장 (IDV 정책 프론트 가드) | 'resolveIdentityChallenge' // 본인인증 모달 → IdentityGuardInterceptor deferred resolver 통보 | 'startInterval' // 주기적으로 액션 실행 (id 기반 관리, stopInterval 로 중단) | 'stopInterval' // startInterval 로 등록한 interval 중단 | 'custom'; // 사용자 정의 액션 /** * 이벤트 타입 */ export type EventType = | 'click' | 'change' | 'input' | 'submit' | 'focus' | 'blur' | 'keydown' | 'keyup' | 'keypress' | 'mousedown' | 'mouseup' | 'mouseenter' | 'mouseleave' | 'scroll' // 드래그 앤 드롭 이벤트 | 'dragstart' | 'drag' | 'dragend' | 'dragenter' | 'dragover' | 'dragleave' | 'drop' // sortable 이벤트 (@dnd-kit 기반) | 'onSortStart' | 'onSortEnd' | 'onSortOver'; /** * 액션 정의 */ export interface ActionDefinition { /** 이벤트 타입 */ type: EventType; /** 액션 핸들러 이름 */ handler: ActionType | string; /** 커스텀 이벤트 핸들러 이름 (예: "onNavigate") - 지정 시 type 대신 사용됨 */ event?: string; /** 액션 타겟 (URL, API 엔드포인트 등) */ target?: string; /** 액션 파라미터 */ params?: Record; /** 액션 성공 시 실행할 후속 액션 (단일 또는 배열) */ onSuccess?: ActionDefinition | ActionDefinition[]; /** 액션 실패 시 실행할 후속 액션 (단일 또는 배열) - errorHandling에 매칭되지 않을 때 실행 */ onError?: ActionDefinition | ActionDefinition[]; /** * HTTP 에러 코드별 핸들러 매핑 * * onError보다 우선순위가 높습니다. * - errorHandling[코드] → errorHandling[default] → onError 순으로 확인 * * @example * ```json * { * "errorHandling": { * "403": { "handler": "showErrorPage", "params": { "target": "content" } }, * "422": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } }, * "default": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } } * } * } * ``` */ errorHandling?: import('../types/ErrorHandling').ErrorHandlingMap; /** 확인 메시지 표시 여부 */ confirm?: string; /** 키보드 이벤트에서 특정 키 필터링 (예: "Enter", "Escape") */ key?: string; /** switch 핸들러용 케이스 정의 - $args[0] 값을 기준으로 분기 */ cases?: Record; /** sequence 핸들러용 순차 실행 액션 배열 */ actions?: ActionDefinition[]; /** 인증 필요 여부 (apiCall 핸들러에서 Bearer 토큰 포함 여부) - auth_mode 사용 권장 */ auth_required?: boolean; /** * 인증 모드 (apiCall 핸들러) * - 'none': 토큰 미포함 (기본값) * - 'required': 토큰 필수 (없으면 에러) * - 'optional': 토큰이 있으면 포함, 없으면 미포함 */ auth_mode?: 'none' | 'required' | 'optional'; /** * 본인인증(IDV) 대상 — apiCall 핸들러 전용 선언적 메타 속성. * * 이 apiCall 이 HTTP 428(identity_verification_required)을 받으면, IdentityGuardInterceptor 가 * 인증 challenge 를 시작할 때 사용할 인증 대상(이메일·전화번호)을 흐름이 직접 선언한다. * 비로그인(게스트) 흐름에서 서버는 사용자가 방금 화면에 입력한 값을 알 수 없으므로, * 레이아웃이 이 속성에 표현식으로 대상을 명시하면 launcher 가 흐름 무지식으로 그 값을 받는다. * 로그인 사용자는 서버가 세션에서 자동 도출하므로 빈 값이어도 무방하다. * * email / phone 둘 중 하나만 있어도 충분하며, 우선순위는 표현식 자체가 결정한다 * (예: 주문자 정보 우선 → 수취인 정보 폴백). * * @example * ```json * { * "handler": "apiCall", * "target": "/api/.../orders", * "identity_target": { * "email": "{{_local.orderer?.email || ''}}", * "phone": "{{_local.orderer?.phone || _local.shipping?.recipient_phone || ''}}" * }, * "params": { "method": "POST", "body": { } } * } * ``` * * @since engine-v1.50.0 */ identity_target?: { email?: string; phone?: string }; /** 조건부 실행 - 표현식이 true일 때만 액션 실행 */ if?: string; /** * conditions 핸들러용 조건 브랜치 배열 * * if/else if/else 체인을 정의합니다. 첫 번째 매칭되는 브랜치의 then 액션이 실행됩니다. * * @example * ```json * { * "handler": "conditions", * "conditions": [ * { * "if": "{{$args[0] === 'edit'}}", * "then": { "handler": "navigate", "params": { "path": "/edit/{{row.id}}" } } * }, * { * "if": "{{$args[0] === 'delete'}}", * "then": [ * { "handler": "setState", "params": { "target": "_local", "deleteTargetId": "{{row.id}}" } }, * { "handler": "openModal", "params": { "id": "delete_confirm_modal" } } * ] * }, * { * "then": { "handler": "toast", "params": { "message": "알 수 없는 액션" } } * } * ] * } * ``` * * @since engine-v1.10.0 */ conditions?: import('./helpers/ConditionEvaluator').ConditionBranch[]; /** * loadScript 핸들러 - 스크립트 로드 완료 후 실행할 액션 * * @example * ```json * { * "handler": "loadScript", * "params": { * "src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js", * "id": "daum_postcode_script" * }, * "onLoad": { "handler": "setState", "params": { "daumPostcodeLoaded": true } } * } * ``` */ onLoad?: ActionDefinition; /** * callExternal 핸들러 - 외부 스크립트 콜백 이벤트명 * * oncomplete 등의 콜백이 호출되면 이 이벤트를 발생시킵니다. * G7Core.componentEvent.on()으로 구독할 수 있습니다. * * @example * ```json * { * "handler": "callExternal", * "params": { * "constructor": "daum.Postcode", * "args": { "oncomplete": true }, * "callbackEvent": "postcode:complete", * "method": "open" * } * } * ``` */ callbackEvent?: string; /** * 핸들러 실행 결과를 상태에 저장 * * 핸들러가 반환하는 값을 지정된 상태 키에 저장합니다. * 동적 컬럼 생성, 계산 결과 저장 등에 활용됩니다. * * @example * ```json * { * "handler": "sirsoft-ecommerce.buildProductColumns", * "params": { * "baseColumns": [...], * "currencies": "{{ecommerceSettings?.language_currency?.currencies}}" * }, * "resultTo": { * "target": "_local", * "key": "productColumns" * } * } * ``` */ resultTo?: { /** 저장 대상: "_local" (로컬 상태), "_global" (전역 상태), "_isolated" (격리된 상태) */ target: '_local' | '_global' | '_isolated'; /** 상태 키 (dot notation 지원, 예: "calculatedPrices.{{option.id}}") */ key: string; /** 병합 모드: "replace" | "shallow" | "deep" (기본값: "deep") */ merge?: 'replace' | 'shallow' | 'deep'; }; /** * 액션 실행 지연 (debounce) * * 연속 호출 시 마지막 호출만 실행합니다. * 숫자: delay ms (기본 설정 적용) * 객체: 상세 설정 * * @example * ```json * // 간단한 형태 (300ms 지연) * { "type": "change", "handler": "updateField", "debounce": 300 } * * // 상세 설정 * { * "type": "change", * "handler": "updateField", * "debounce": { * "delay": 500, * "leading": false, * "trailing": true * } * } * ``` */ debounce?: | number | { /** 지연 시간 (ms) */ delay: number; /** 첫 호출 즉시 실행 여부 (기본: false) */ leading?: boolean; /** 마지막 호출 후 실행 여부 (기본: true) */ trailing?: boolean; }; /** * named_actions에 정의된 액션을 참조 * * named_actions의 키를 지정하면 해당 액션 정의(handler, params 등)를 가져옵니다. * type, key, event 등 이벤트 바인딩 속성은 개별 지정합니다. * actionRef와 인라인 handler/params가 동시에 있으면 인라인이 우선합니다. * * @example * ```json * { * "type": "keypress", * "key": "Enter", * "actionRef": "searchProducts" * } * ``` * * @since engine-v1.19.0 */ actionRef?: string; } /** * Navigate 옵션 (React Router NavigateOptions 호환) */ export interface NavigateOptions { /** true이면 히스토리를 교체 (뒤로가기 불가) */ replace?: boolean; /** 전달할 상태 데이터 */ state?: any; } /** * 격리된 상태 컨텍스트 인터페이스 * * isolatedState 속성이 있는 컴포넌트에서 IsolatedStateProvider를 통해 제공됩니다. */ export interface IsolatedContextValue { /** 현재 격리된 상태 */ state: Record; /** 특정 경로의 상태를 설정 */ setState: (path: string, value: any) => void; /** 특정 경로의 상태를 조회 */ getState: (path: string) => any; /** 상태를 병합 모드에 따라 업데이트 */ mergeState: (updates: Record, mergeMode?: 'replace' | 'shallow' | 'deep') => void; } /** * 액션 컨텍스트 */ export interface ActionContext { /** 데이터 컨텍스트 */ data?: any; /** 이벤트 객체 */ event?: Event; /** 컴포넌트 props */ props?: Record; /** 현재 상태 */ state?: any; /** 상태 업데이트 함수 */ setState?: (updates: any) => void; /** React Router navigate 함수 */ navigate?: (path: string, options?: NavigateOptions) => void; /** * 격리된 상태 컨텍스트 * * isolatedState 속성이 있는 컴포넌트에서 자동으로 주입됩니다. * target: "isolated"로 setState 핸들러 호출 시 사용됩니다. */ isolatedContext?: IsolatedContextValue | null; } /** * 로딩 상태 맵 타입 * 액션 ID를 키로, 로딩 여부를 값으로 가지는 객체 */ export type LoadingActionsMap = Record; /** * 액션 핸들러 함수 타입 */ export type ActionHandler = ( action: ActionDefinition, context: ActionContext ) => void | Promise; /** * 액션 실행 결과 */ export interface ActionResult { success: boolean; data?: any; error?: Error; } /** * 액션 에러 클래스 */ export class ActionError extends Error { /** * 미등록 핸들러로 인한 에러 여부 * * 확장 번들이 로드되지 않아 그 확장 소유 핸들러가 등록되지 않은 경우가 대표적이다. * 사용자가 조치할 수 있는 일이 아니고 내부 식별자를 노출하게 되므로, 표시 계층은 * 이 플래그를 보고 errorHandling 정책(토스트 등)을 태우지 않는다. * * @since engine-v1.53.0 */ public unknownHandler = false; constructor( message: string, public action?: ActionDefinition, public originalError?: Error ) { super(message); this.name = 'ActionError'; } } // ============================================================================ // ActionDispatcher 클래스 // ============================================================================ /** * DOM 이벤트 이름 → React prop 이름 매핑 (camelCase) * * 액션의 `type` 과 `event` 두 경로가 **같은 표**를 써야 한다. 한쪽만 매핑하면 * 같은 이벤트를 어떤 키로 적었는지에 따라 핸들러가 붙기도 하고 안 붙기도 한다. */ const DOM_EVENT_PROP_MAP: Record = { click: 'onClick', change: 'onChange', input: 'onInput', submit: 'onSubmit', focus: 'onFocus', blur: 'onBlur', keydown: 'onKeyDown', keyup: 'onKeyUp', keypress: 'onKeyPress', mousedown: 'onMouseDown', mouseup: 'onMouseUp', mouseenter: 'onMouseEnter', mouseleave: 'onMouseLeave', scroll: 'onScroll', // 드래그 앤 드롭 이벤트 dragstart: 'onDragStart', drag: 'onDrag', dragend: 'onDragEnd', dragenter: 'onDragEnter', dragover: 'onDragOver', dragleave: 'onDragLeave', drop: 'onDrop', }; /** * 이벤트 핸들러 관리 및 액션 실행 엔진 * * @example * const dispatcher = new ActionDispatcher({ navigate, setState }); * const handler = dispatcher.createHandler(actionDef, dataContext); * */ export class ActionDispatcher { /** 데이터 바인딩 엔진 */ private bindingEngine: DataBindingEngine; /** 번역 엔진 */ private translationEngine?: TranslationEngine; /** 번역 컨텍스트 */ private translationContext?: TranslationContext; /** 커스텀 액션 핸들러 맵 */ private customHandlers: Map = new Map(); /** 기본 컨텍스트 */ private defaultContext: Partial; /** 전역 상태 업데이트 함수 (engine-v1.42.0: render 옵션 추가) */ private globalStateUpdater?: (updates: any, options?: { render?: boolean }) => void; /** ErrorHandlingResolver 연동 여부 */ private errorHandlingSetup: boolean = false; /** * Debounce 타이머 저장소 * * 컴포넌트별, 액션별로 debounce 타이머를 관리합니다. * Key: `${componentId}-${handler}-${eventName}` */ private debounceTimers: Map> = new Map(); /** * 대기 중인 debounce 액션의 즉시 실행 함수 맵 * * 비디바운스 액션 실행 전에 대기 중인 debounce 액션을 즉시 실행(flush)하여 * state가 최신 상태가 되도록 합니다. * Key 형식은 debounceTimers와 동일합니다. */ private pendingDebounceFlushers: Map void> = new Map(); /** * 디바운스 대기 중 누적된 객체 값 * * _changedKeys 메타데이터가 포함된 이벤트의 변경분을 누적합니다. * 이를 통해 다국어 입력 등 객체 값의 stale closure로 인한 키 유실을 방지합니다. * Key: debounceKey * @since engine-v1.28.0 */ private debounceAccumulatedValues: Map> = new Map(); /** * 전역 헤더 규칙 (레이아웃에서 설정) * * apiCall 핸들러 호출 시 패턴에 매칭되는 엔드포인트에 자동으로 헤더를 추가합니다. * * @since engine-v1.16.0 */ private globalHeaders: GlobalHeaderRule[] = []; /** * 명명된 액션 정의 (레이아웃에서 설정) * * 레이아웃 JSON의 named_actions 섹션에서 정의된 재사용 가능한 액션입니다. * 컴포넌트의 actionRef 속성으로 참조할 수 있습니다. * * @since engine-v1.19.0 */ private namedActions: Record = {}; /** * 프리뷰 모드 여부 * * 프리뷰 모드에서는 PREVIEW_SUPPRESSED_HANDLERS에 정의된 핸들러가 * 실행되지 않고 로그만 남깁니다. * * @since engine-v1.26.1 */ private previewMode: boolean = false; /** * `writeLocalState` 재진입 깊이. * * 미러 쓰기(`G7Core.state.setLocal`)가 다시 저장소 A writer 를 호출하는 구조라, * 그 안에서 같은 헬퍼로 되돌아오면 무한 재귀가 된다. 0 보다 크면 미러를 붙이지 않고 * 원본 writer 만 호출한다. * * @since engine-v1.63.5 */ private localMirrorDepth = 0; /** * startInterval 핸들러로 등록된 타이머 맵. * * key = 레이아웃이 지정한 interval id, value = setInterval 반환 timerId. * 레이아웃은 stopInterval 로 id 기반 중단을 수행할 수 있으며, * 페이지 전환 시에는 `stopAllIntervals()` 로 일괄 정리된다. * * @since engine-v1.45.0 */ private intervals: Map> = new Map(); /** * ActionDispatcher 생성자 */ constructor( defaultContext: Partial = {}, translationEngine?: TranslationEngine, translationContext?: TranslationContext ) { this.bindingEngine = new DataBindingEngine(); this.translationEngine = translationEngine; this.translationContext = translationContext; this.defaultContext = defaultContext; // 기본 핸들러 등록 this.registerDefaultHandlers(); // ErrorHandlingResolver에 액션 실행기 등록 this.setupErrorHandling(); } /** * 전역 헤더 규칙을 설정합니다. * * 레이아웃 로드 시 호출되어 globalHeaders를 설정합니다. * 이후 apiCall 핸들러에서 패턴 매칭을 통해 자동으로 헤더가 적용됩니다. * * @param headers 전역 헤더 규칙 배열 * @since engine-v1.16.0 */ public setGlobalHeaders(headers: GlobalHeaderRule[]): void { this.globalHeaders = headers || []; } /** * 명명된 액션 정의를 설정합니다. * * 레이아웃 로드 시 호출되어 namedActions를 설정합니다. * 이후 컴포넌트의 actionRef 속성으로 참조할 수 있습니다. * * @param actions 명명된 액션 정의 맵 * @since engine-v1.19.0 */ public setNamedActions(actions: Record): void { this.namedActions = actions || {}; // DevTools: named_actions 정의 등록 const devTools = getDevTools(); if (devTools?.isEnabled?.()) { devTools.setNamedActionDefinitions?.(this.namedActions); } logger.log('[setNamedActions] registered:', Object.keys(this.namedActions)); } /** * 현재 등록된 named_actions 정의를 반환합니다. * * @returns 명명된 액션 정의 맵 * @since engine-v1.19.0 */ public getNamedActions(): Record { return this.namedActions; } /** * 프리뷰 모드를 설정합니다. * * 프리뷰 모드에서는 PREVIEW_SUPPRESSED_HANDLERS에 정의된 핸들러가 * 실행되지 않고 경고 로그만 남깁니다. * * @param enabled 프리뷰 모드 활성화 여부 * @since engine-v1.26.1 */ public setPreviewMode(enabled: boolean): void { this.previewMode = enabled; logger.log('Preview mode:', enabled ? 'enabled' : 'disabled'); } /** * 현재 프리뷰 모드 여부를 반환합니다. * * @returns 프리뷰 모드 활성화 여부 * @since engine-v1.26.1 */ public isPreviewMode(): boolean { return this.previewMode; } /** * 프리뷰 모드에서 억제되는 핸들러 목록을 반환합니다. * * @returns 억제 대상 핸들러 이름의 ReadonlySet * @since engine-v1.26.1 */ public static getPreviewSuppressedHandlers(): ReadonlySet { return PREVIEW_SUPPRESSED_HANDLERS; } /** * 프리뷰 모드에서 억제되는 레이아웃 기능 목록을 반환합니다. * * @returns 억제 대상 레이아웃 기능의 ReadonlySet * @since engine-v1.26.1 */ public static getPreviewSuppressedLayoutFeatures(): ReadonlySet { return PREVIEW_SUPPRESSED_LAYOUT_FEATURES; } /** * actionRef를 해석하여 완전한 ActionDefinition을 반환합니다. * * actionRef가 있으면 named_actions에서 해당 정의를 가져와 이벤트 속성과 병합합니다. * 인라인 handler/params가 있으면 인라인이 우선합니다 (override). * * @param action 원본 액션 정의 (actionRef 포함 가능) * @returns 해석된 액션 정의 * @since engine-v1.19.0 */ resolveActionRef(action: ActionDefinition): ActionDefinition { if (!action.actionRef) { return action; } const namedAction = this.namedActions[action.actionRef]; if (!namedAction) { logger.warn(`[resolveActionRef] named action not found: "${action.actionRef}"`); return action; } // DevTools: actionRef 해석 이력 기록 const devTools = getDevTools(); if (devTools?.isEnabled?.()) { devTools.trackNamedActionRef?.({ actionRefName: action.actionRef, resolvedHandler: namedAction.handler, timestamp: Date.now(), }); } // 이벤트 바인딩 속성(type, key, event, if, debounce)은 원본에서 유지 // handler, params, onSuccess, onError 등은 namedAction에서 가져오되, 인라인이 있으면 override const { actionRef, type, key, event, ...inlineOverrides } = action; const resolved: ActionDefinition = { ...namedAction, ...Object.fromEntries( Object.entries(inlineOverrides).filter(([, v]) => v !== undefined) ), type: type ?? namedAction.type, }; // key, event는 원본에서만 가져옴 (namedAction에는 이벤트 바인딩이 없음) if (key !== undefined) resolved.key = key; if (event !== undefined) resolved.event = event; return resolved; } /** * 엔드포인트가 패턴에 매칭되는지 확인합니다. * * @param endpoint API 엔드포인트 경로 * @param pattern glob 스타일 패턴 ("*", "/api/shop/*" 등) * @returns 매칭 여부 * @since engine-v1.16.0 */ private matchesPattern(endpoint: string, pattern: string): boolean { if (pattern === '*') return true; // glob 패턴을 정규식으로 변환 (* → .*) const regexPattern = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // 특수문자 이스케이프 .replace(/\*/g, '.*'); // * → .* return new RegExp(`^${regexPattern}$`).test(endpoint); } /** * 엔드포인트에 매칭되는 전역 헤더를 추출합니다. * * globalHeaders 배열을 순회하며 패턴이 매칭되는 모든 헤더를 병합합니다. * 나중에 정의된 규칙의 헤더가 먼저 정의된 규칙의 헤더를 덮어씁니다. * * @param endpoint API 엔드포인트 경로 * @param context 표현식 평가를 위한 컨텍스트 * @returns 병합된 헤더 객체 * @since engine-v1.16.0 */ private getMatchingGlobalHeaders( endpoint: string, context: Record, ): Record { const result: Record = {}; for (const rule of this.globalHeaders) { if (this.matchesPattern(endpoint, rule.pattern)) { Object.entries(rule.headers).forEach(([key, value]) => { // 표현식 평가 ({{_global.xxx}} 등) - resolveExpressionString으로 문자열 보간 지원 const resolved = resolveExpressionString(value, context, { skipCache: true }); // null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가 if (resolved != null && resolved !== '') { result[key] = String(resolved); } }); } } return result; } /** * ErrorHandlingResolver와 연동을 설정합니다. * * ErrorHandlingResolver가 에러 핸들러를 실행할 때 * ActionDispatcher.dispatchAction을 사용하도록 설정합니다. */ private setupErrorHandling(): void { if (this.errorHandlingSetup) { return; } const resolver = getErrorHandlingResolver(); // ErrorHandlingResolver에서 핸들러 실행 시 dispatchAction 사용 resolver.setActionExecutor(async (handler: ErrorHandlerConfig, errorContext: { error: ErrorContext }) => { // ErrorHandlerConfig를 ActionDefinition으로 변환 const action: ActionDefinition = { type: 'click', // 이벤트 타입은 의미 없음 (dispatchAction에서 사용하지 않음) handler: handler.handler as ActionType, target: handler.target, params: handler.params, actions: handler.actions, // sequence 핸들러용 actions 배열 전달 }; // 에러 컨텍스트를 data에 포함 const context: Partial = { data: { ...this.defaultContext.data, error: errorContext.error, }, }; return await this.dispatchAction(action, context); }); this.errorHandlingSetup = true; } /** * 기본 핸들러를 등록합니다. * * 템플릿 엔진에서 기본적으로 제공하는 핸들러들을 등록합니다. */ private registerDefaultHandlers(): void { // refetchDataSource: 데이터 소스 다시 fetch // sync 옵션: true면 startTransition 없이 즉시 렌더링 (드래그 앤 드롭 후 순서 변경 등) // globalStateOverride: sequence 내에서 setState 후 refetchDataSource 호출 시 // React 상태가 비동기로 업데이트되어 getState()가 이전 값을 반환하는 문제 해결 // localStateOverride: sequence 내에서 setState local 후 refetchDataSource 호출 시 // params에서 {{_local.xxx}} 참조 지원 // isolatedStateOverride: isolated 상태 override (isolatedContext가 있는 경우) this.registerHandler('refetchDataSource', async (action: ActionDefinition, context: ActionContext) => { const dataSourceId = action.params?.dataSourceId; const sync = action.params?.sync; if (!dataSourceId) { logger.warn('refetchDataSource: dataSourceId is required'); return; } // G7Core.dataSource.refetch() 호출 (sync, globalStateOverride, localStateOverride, isolatedStateOverride 전달) // G7Core.state.get()으로 최신 전역 상태를 직접 가져옴 (React 상태 비동기 업데이트 문제 해결) // context.state._global은 action 실행 시점의 snapshot이라 initCartKey 등에서 설정한 값이 반영 안됨 if (typeof window !== 'undefined' && (window as any).G7Core?.dataSource?.refetch) { // 최신 전역 상태 직접 가져오기 (G7Core.state.set()으로 설정한 값 즉시 반영) const currentGlobalState = (window as any).G7Core?.state?.get?.() || {}; const globalStateOverride = { ...context.state?._global, ...currentGlobalState, // 최신 상태로 덮어쓰기 }; // engine-v1.17.0: 커스텀 핸들러에서 setLocal 후 dispatch 호출 시 최신 로컬 상태 참조 // G7Core.state.getLocal()은 __g7PendingLocalState를 우선 확인하여 최신 값 반환 // // engine-v1.19.0: sequence 내 setState 후 refetchDataSource 호출 시 // React 18의 마이크로태스크 기반 배칭이 await 경계에서 렌더를 플러시할 수 있음 // 렌더 시 useLayoutEffect가 __g7PendingLocalState를 null로 클리어하므로 // getLocal()이 stale한 globalLocal(initLocal 시점 값)을 반환하는 문제 발생 // context.data._local은 sequence 핸들러가 누적한 최신 _local 상태이므로 우선 병합 // // context 구조별 _local 접근: // - 컴포넌트 컨텍스트: context.data._local = extendedDataContext._local // - sequence 컨텍스트: context.data._local = currentState (누적 상태) // - dispatch fallback: context.data._local = globalState._local const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || {}; const contextLocalState = context.data?._local || context.state || {}; const localStateOverride = { ...currentLocalState, ...contextLocalState }; const isolatedStateOverride = context.isolatedContext?.state; logger.log('[refetchDataSource] localStateOverride:', localStateOverride); await (window as any).G7Core.dataSource.refetch(dataSourceId, { ...(sync ? { sync: true } : {}), ...(globalStateOverride ? { globalStateOverride } : {}), ...(localStateOverride ? { localStateOverride } : {}), ...(isolatedStateOverride ? { isolatedStateOverride } : {}), }); } else { logger.warn('refetchDataSource: G7Core.dataSource.refetch is not available'); } }); // appendDataSource: 데이터 소스에 새 데이터를 병합 (무한 스크롤용) // 참고: executeAction에서 resolveParams가 이미 호출되어 action.params는 해석된 상태로 전달됨 this.registerHandler('appendDataSource', async (action: ActionDefinition, _context: ActionContext) => { const { dataSourceId, dataPath, newData } = action.params || {}; if (!dataSourceId) { logger.warn('appendDataSource: dataSourceId is required'); return; } if (newData === undefined) { logger.warn('appendDataSource: newData is required'); return; } // G7Core.dataSource.updateData() 호출 if (typeof window !== 'undefined' && (window as any).G7Core?.dataSource?.updateData) { // resolveParams에서 이미 표현식이 해석되었으므로 직접 사용 // dataPath와 newData는 이미 해석된 값임 if (!Array.isArray(newData)) { logger.warn('appendDataSource: newData must be an array, got:', typeof newData, newData); return; } await (window as any).G7Core.dataSource.updateData(dataSourceId, dataPath || null, newData, 'append'); } else { logger.warn('appendDataSource: G7Core.dataSource.updateData is not available'); } }); // updateDataSource: API 응답 데이터로 데이터 소스 전체 교체 (refetch 없이 직접 업데이트) // 장바구니 수량 변경 등 API가 전체 데이터를 반환하는 경우, refetch 대신 사용하여 네트워크 왕복 감소 // params.dataSourceId: 업데이트할 데이터소스 ID // params.data: 새 데이터 (API 응답에서 바인딩 표현식으로 가져옴, 예: {{response.data}}) // params.merge: true면 기존 데이터와 병합 (기본: false) this.registerHandler('updateDataSource', async (action: ActionDefinition, _context: ActionContext) => { const { dataSourceId, data, merge = false } = action.params || {}; if (!dataSourceId) { logger.warn('updateDataSource: dataSourceId is required'); return; } if (data === undefined) { logger.warn('updateDataSource: data is required'); return; } // G7Core.dataSource.set() 호출 if (typeof window !== 'undefined' && (window as any).G7Core?.dataSource?.set) { logger.log(`[updateDataSource] Updating dataSource '${dataSourceId}' with:`, data); (window as any).G7Core.dataSource.set(dataSourceId, data, { merge }); } else { logger.warn('updateDataSource: G7Core.dataSource.set is not available'); } }); // scrollIntoView: 특정 요소로 스크롤 // params.selector: CSS 선택자 (예: "#loading_indicator", "[data-id='item-1']") // params.behavior: 'smooth' | 'instant' | 'auto' (기본: 'smooth') // params.block: 'start' | 'center' | 'end' | 'nearest' (기본: 'nearest') // params.inline: 'start' | 'center' | 'end' | 'nearest' (기본: 'nearest') // params.waitForElement: true면 MutationObserver로 요소가 DOM에 추가될 때까지 대기 (기본: false) // params.timeout: waitForElement 사용 시 최대 대기 시간(ms) (기본: 2000) // params.delay: 스크롤 전 대기 시간(ms) - DOM 렌더링 대기용 (기본: 0, waitForElement=true면 무시) // params.retryCount: 요소를 찾지 못했을 때 재시도 횟수 (기본: 0, waitForElement=true면 무시) // params.retryInterval: 재시도 간격(ms) (기본: 50, waitForElement=true면 무시) // params.scrollContainer: 스크롤할 컨테이너 selector (지정 시 해당 컨테이너만 스크롤, 브라우저 스크롤 영향 없음) this.registerHandler('scrollIntoView', async (action: ActionDefinition, _context: ActionContext) => { const { selector, behavior = 'smooth', block = 'nearest', inline = 'nearest', waitForElement = false, timeout = 2000, delay = 0, retryCount = 0, retryInterval = 50, scrollContainer, } = action.params || {}; if (!selector) { logger.warn('scrollIntoView: selector is required'); return; } if (typeof window === 'undefined' || typeof document === 'undefined') { logger.warn('scrollIntoView: window/document is not available'); return; } let element: Element | null = null; if (waitForElement) { // MutationObserver를 사용하여 요소가 DOM에 추가될 때까지 대기 element = await new Promise((resolve) => { // 이미 요소가 존재하면 바로 반환 const existingElement = document.querySelector(selector); if (existingElement) { resolve(existingElement); return; } const timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); const observer = new MutationObserver(() => { const foundElement = document.querySelector(selector); if (foundElement) { clearTimeout(timeoutId); observer.disconnect(); resolve(foundElement); } }); observer.observe(document.body, { childList: true, subtree: true, }); }); if (!element) { logger.warn(`scrollIntoView: element not found for selector "${selector}" after ${timeout}ms timeout`); return; } } else { // 기존 방식: delay + retry if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } element = document.querySelector(selector); let attempts = 0; while (!element && attempts < retryCount) { await new Promise((resolve) => setTimeout(resolve, retryInterval)); element = document.querySelector(selector); attempts++; } if (!element) { logger.warn(`scrollIntoView: element not found for selector "${selector}" after ${attempts + 1} attempts`); return; } } // scrollContainer가 지정된 경우 해당 컨테이너 내에서만 스크롤 if (scrollContainer) { const container = document.querySelector(scrollContainer) as HTMLElement | null; if (!container) { logger.warn(`scrollIntoView: container not found for selector "${scrollContainer}"`); return; } const elementRect = element.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); // 컨테이너 기준 상대 위치 계산 const relativeTop = elementRect.top - containerRect.top + container.scrollTop; const relativeBottom = relativeTop + elementRect.height; let targetScrollTop: number; switch (block) { case 'start': targetScrollTop = relativeTop; break; case 'center': targetScrollTop = relativeTop - (container.clientHeight / 2) + (elementRect.height / 2); break; case 'end': targetScrollTop = relativeBottom - container.clientHeight; break; case 'nearest': default: // 요소가 이미 보이면 스크롤하지 않음 const isAbove = elementRect.top < containerRect.top; const isBelow = elementRect.bottom > containerRect.bottom; if (isAbove) { targetScrollTop = relativeTop; } else if (isBelow) { targetScrollTop = relativeBottom - container.clientHeight; } else { // 이미 보임 - 스크롤 불필요 return; } break; } container.scrollTo({ top: Math.max(0, targetScrollTop), behavior: behavior as ScrollBehavior, }); return; } // 기본: 네이티브 scrollIntoView 사용 element.scrollIntoView({ behavior: behavior as ScrollBehavior, block: block as ScrollLogicalPosition, inline: inline as ScrollLogicalPosition, }); }); // reloadExtensions: 확장 상태(routes/translations/layouts) 원자적 재동기화 // // 모듈/플러그인/템플릿 install/activate/deactivate/uninstall 직후 onSuccess 에서 호출합니다. // 내부적으로 TemplateApp.reloadExtensionState() 를 호출하여 최신 cache_version 으로 // routes/translations/layout 캐시를 일괄 갱신합니다. // // 선택적 파라미터 `{ moduleInfo, action }` 전달 시 모듈 에셋(JS/CSS) 동적 로드/제거도 // reloadModuleHandlers 와 동일한 방식으로 수행합니다 (플러그인도 동일 파라미터로 처리). // // @since engine-v1.19.0 this.registerHandler('reloadExtensions', async (action: ActionDefinition, context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadExtensions: window is not available'); return; } const templateApp = (window as any).__templateApp; if (!templateApp) { logger.warn('reloadExtensions: TemplateApp not initialized'); return; } // 1. 확장 상태 일괄 재동기화 if (typeof templateApp.reloadExtensionState === 'function') { try { await templateApp.reloadExtensionState(); } catch (error) { logger.error('reloadExtensions: reloadExtensionState failed', error); throw error; } } else { logger.warn('reloadExtensions: TemplateApp.reloadExtensionState unavailable'); } // 2. 선택적으로 모듈/플러그인 에셋 동적 로드/제거 const { moduleInfo, pluginInfo } = action.params || {}; if (moduleInfo) { try { await this.executeAction( { handler: 'reloadModuleHandlers', params: action.params, } as ActionDefinition, context ); } catch (error) { logger.error('reloadExtensions: reloadModuleHandlers failed', error); } } if (pluginInfo) { try { await this.executeAction( { handler: 'reloadPluginHandlers', params: action.params, } as ActionDefinition, context ); } catch (error) { logger.error('reloadExtensions: reloadPluginHandlers failed', error); } } logger.log('reloadExtensions: done'); }); // reloadRoutes: 라우트 다시 로드 (하위 호환) // // @deprecated engine-v1.19.0 이후 `reloadExtensions` 사용 권장. 본 핸들러는 // `reloadExtensionState()` 로 위임하여 버전 기반 캐시 갱신을 보장합니다. this.registerHandler('reloadRoutes', async (_action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadRoutes: window is not available'); return; } const templateApp = (window as any).__templateApp; if (!templateApp) { logger.warn('reloadRoutes: TemplateApp not initialized'); return; } if (typeof templateApp.reloadExtensionState === 'function') { try { await templateApp.reloadExtensionState(); logger.log('reloadRoutes: delegated to reloadExtensionState'); } catch (error) { logger.error('reloadRoutes: Failed to reload routes', error); throw error; } } else { // 최소 호환: Router 직접 호출 (캐시 버전 없이) const router = templateApp.getRouter?.(); if (router) { await router.loadRoutes(); logger.log('reloadRoutes: Routes reloaded (legacy fallback)'); } } }); // refresh: 현재 페이지 새로고침 // params.delayMs (number, 기본 0) — 선행 토스트/모달 닫힘 애니메이션이 보이도록 지연 후 reload. // 토스트(_global.toasts) 메시지가 사용자에게 인지되기 전에 reload 가 일어나는 것을 방지. this.registerHandler('refresh', async (action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('refresh: window is not available'); return; } const delayMs = Number(action.params?.delayMs ?? 0); if (delayMs > 0) { await new Promise(resolve => setTimeout(resolve, delayMs)); } window.location.reload(); }); // remount: 컴포넌트 ID 기반으로 리마운트 트리거 // _remountKeys 객체에 컴포넌트 ID별 카운터를 저장하여 key prop으로 사용 this.registerHandler('remount', async (action: ActionDefinition, _context: ActionContext) => { const { componentId } = action.params || {}; if (!componentId) { logger.warn('remount: componentId parameter is required'); return; } if (!this.globalStateUpdater) { logger.warn('remount: globalStateUpdater is not set'); return; } // G7Core.state에서 현재 _global._remountKeys 가져오기 // dataContext._global에 저장되어 있으므로 _global을 통해 접근 const G7Core = (window as any).G7Core; const currentState = G7Core?.state?.get() || {}; const remountKeys = currentState._global?._remountKeys || {}; const currentValue = remountKeys[componentId] || 0; // 해당 컴포넌트의 리마운트 키 증가 // globalStateUpdater는 TemplateApp.setGlobalState이며, 최상위 레벨 속성을 기대함 // setGlobalState 내부에서 updateTemplateData({ _global: { ...this.globalState } })로 감싸므로 // _global 없이 직접 _remountKeys를 전달해야 함 this.globalStateUpdater({ _remountKeys: { ...remountKeys, [componentId]: currentValue + 1, }, }); logger.log(`remount: ${componentId} key incremented to ${currentValue + 1}`); }); // reloadTranslations: 다국어 파일 다시 로드 (하위 호환) // // @deprecated engine-v1.19.0 이후 `reloadExtensions` 사용 권장. 본 핸들러는 // `reloadExtensionState()` 로 위임하여 다국어 외 routes/layouts 도 함께 갱신됩니다. this.registerHandler('reloadTranslations', async (_action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadTranslations: window is not available'); return; } const templateApp = (window as any).__templateApp; if (!templateApp) { logger.warn('reloadTranslations: TemplateApp not initialized'); return; } if (typeof templateApp.reloadExtensionState === 'function') { try { await templateApp.reloadExtensionState(); logger.log('reloadTranslations: delegated to reloadExtensionState'); } catch (error) { logger.error('reloadTranslations: Failed to reload translations', error); throw error; } } }); // reloadModuleHandlers: 모듈 핸들러 동적 로드/제거 // 모듈 활성화/비활성화 시 window.G7Config.moduleAssets 병합/제거 및 JS 로드 this.registerHandler('reloadModuleHandlers', async (action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadModuleHandlers: window is not available'); return; } const { moduleInfo, action: actionType } = action.params || {}; if (!moduleInfo) { logger.warn('reloadModuleHandlers: moduleInfo is required'); return; } // API 응답의 data 래퍼 처리 (ResponseHelper 응답 구조 호환) // 응답 구조: { success, message, data: { identifier, assets, ... } } const moduleData = moduleInfo.data || moduleInfo; if (!moduleData.identifier) { logger.warn('reloadModuleHandlers: moduleInfo with identifier is required'); return; } if (!actionType || (actionType !== 'add' && actionType !== 'remove')) { logger.warn('reloadModuleHandlers: action must be "add" or "remove"'); return; } const G7Config = (window as any).G7Config; if (!G7Config) { logger.warn('reloadModuleHandlers: G7Config not available'); return; } const identifier = moduleData.identifier; try { if (actionType === 'add') { // 활성화: moduleAssets에 병합 if (moduleData.assets) { G7Config.moduleAssets = G7Config.moduleAssets || {}; G7Config.moduleAssets[identifier] = moduleData.assets; logger.log(`reloadModuleHandlers: Added assets for ${identifier}`); // JS 파일이 있으면 동적 로드 if (moduleData.assets.js) { const scriptUrl = moduleData.assets.js; const scriptId = `module-${identifier}`; // 출처 게이트 — 정상 확장 자산 URL 은 전부 `/api/...` same-origin 이다. this.assertAllowedExtensionAssetUrl(scriptUrl, `module script (${identifier})`, action); // 이미 로드된 스크립트면 JS 만 건너뛴다 (CSS 는 아래 형제 블록이 계속 처리). if (document.getElementById(scriptId)) { logger.warn(`reloadModuleHandlers: Script ${scriptId} already loaded`); } else { await loadScriptWithRetry( scriptUrl, { id: scriptId }, { label: `module-script:${identifier}` } ); logger.log(`reloadModuleHandlers: Script loaded successfully for ${identifier}`); } } // CSS 파일이 있으면 동적 로드 (JS 유무·기존재와 무관한 형제 작업) if (moduleData.assets.css) { const cssUrl = moduleData.assets.css; const linkId = `module-css-${identifier}`; this.assertAllowedExtensionAssetUrl(cssUrl, `module stylesheet (${identifier})`, action); if (!document.getElementById(linkId)) { await loadStylesheetWithRetry( cssUrl, { id: linkId }, { label: `module-css:${identifier}` } ); logger.log(`reloadModuleHandlers: CSS loaded for ${identifier}`); } } } } else if (actionType === 'remove') { // 비활성화: moduleAssets에서 제거 if (G7Config.moduleAssets && G7Config.moduleAssets[identifier]) { delete G7Config.moduleAssets[identifier]; logger.log(`reloadModuleHandlers: Removed assets for ${identifier}`); } // 로드된 스크립트 제거 const scriptId = `module-${identifier}`; const script = document.getElementById(scriptId); if (script) { script.remove(); logger.log(`reloadModuleHandlers: Removed script for ${identifier}`); } // CSS 제거 const linkId = `module-css-${identifier}`; const link = document.getElementById(linkId); if (link) { link.remove(); logger.log(`reloadModuleHandlers: Removed CSS for ${identifier}`); } } } catch (error) { logger.error(`reloadModuleHandlers: Failed to ${actionType} module assets`, error); throw error; } }); // reloadPluginHandlers: 플러그인 핸들러 동적 로드/제거 // 플러그인 활성화/비활성화 시 window.G7Config.pluginAssets 병합/제거 및 JS 로드 this.registerHandler('reloadPluginHandlers', async (action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadPluginHandlers: window is not available'); return; } const { pluginInfo, action: actionType } = action.params || {}; if (!pluginInfo) { logger.warn('reloadPluginHandlers: pluginInfo is required'); return; } // API 응답의 data 래퍼 처리 (ResponseHelper 응답 구조 호환) // 응답 구조: { success, message, data: { identifier, assets, ... } } const pluginData = pluginInfo.data || pluginInfo; if (!pluginData.identifier) { logger.warn('reloadPluginHandlers: pluginInfo with identifier is required'); return; } if (!actionType || (actionType !== 'add' && actionType !== 'remove')) { logger.warn('reloadPluginHandlers: action must be "add" or "remove"'); return; } const G7Config = (window as any).G7Config; if (!G7Config) { logger.warn('reloadPluginHandlers: G7Config not available'); return; } const identifier = pluginData.identifier; try { if (actionType === 'add') { // 활성화: pluginAssets에 병합 if (pluginData.assets) { G7Config.pluginAssets = G7Config.pluginAssets || {}; G7Config.pluginAssets[identifier] = pluginData.assets; logger.log(`reloadPluginHandlers: Added assets for ${identifier}`); // JS 파일이 있으면 동적 로드 if (pluginData.assets.js) { const scriptUrl = pluginData.assets.js; const scriptId = `plugin-${identifier}`; // 출처 게이트 — 정상 확장 자산 URL 은 전부 `/api/...` same-origin 이다. this.assertAllowedExtensionAssetUrl(scriptUrl, `plugin script (${identifier})`, action); // 이미 로드된 스크립트면 JS 만 건너뛴다 (CSS 는 아래 형제 블록이 계속 처리). if (document.getElementById(scriptId)) { logger.warn(`reloadPluginHandlers: Script ${scriptId} already loaded`); } else { await loadScriptWithRetry( scriptUrl, { id: scriptId }, { label: `plugin-script:${identifier}` } ); logger.log(`reloadPluginHandlers: Script loaded successfully for ${identifier}`); } } // CSS 파일이 있으면 동적 로드 (JS 유무·기존재와 무관한 형제 작업) if (pluginData.assets.css) { const cssUrl = pluginData.assets.css; const linkId = `plugin-css-${identifier}`; this.assertAllowedExtensionAssetUrl(cssUrl, `plugin stylesheet (${identifier})`, action); if (!document.getElementById(linkId)) { await loadStylesheetWithRetry( cssUrl, { id: linkId }, { label: `plugin-css:${identifier}` } ); logger.log(`reloadPluginHandlers: CSS loaded for ${identifier}`); } } } } else if (actionType === 'remove') { // 비활성화: pluginAssets에서 제거 if (G7Config.pluginAssets && G7Config.pluginAssets[identifier]) { delete G7Config.pluginAssets[identifier]; logger.log(`reloadPluginHandlers: Removed assets for ${identifier}`); } // 로드된 스크립트 제거 const scriptId = `plugin-${identifier}`; const script = document.getElementById(scriptId); if (script) { script.remove(); logger.log(`reloadPluginHandlers: Removed script for ${identifier}`); } // CSS 제거 const linkId = `plugin-css-${identifier}`; const link = document.getElementById(linkId); if (link) { link.remove(); logger.log(`reloadPluginHandlers: Removed CSS for ${identifier}`); } } } catch (error) { logger.error(`reloadPluginHandlers: Failed to ${actionType} plugin assets`, error); throw error; } }); // showErrorPage: 에러 페이지 표시 // ErrorPageHandler를 통해 에러 코드에 맞는 레이아웃을 로드하고 렌더링합니다. let _showErrorPageActive = false; this.registerHandler('showErrorPage', async (action: ActionDefinition, _context: ActionContext) => { // 중복 호출 방지: 여러 데이터소스가 동시에 에러를 반환할 때 // 각각이 독립적으로 errorHandling을 트리거하여 showErrorPage가 복수 호출됨 // 첫 번째 호출만 처리하고 나머지는 무시 if (_showErrorPageActive) { logger.log('showErrorPage: Already active, skipping duplicate call'); return; } _showErrorPageActive = true; if (typeof window === 'undefined') { _showErrorPageActive = false; logger.warn('showErrorPage: window is not available'); return; } const templateApp = (window as any).__templateApp; if (!templateApp) { _showErrorPageActive = false; logger.warn('showErrorPage: TemplateApp not initialized'); return; } const errorPageHandler = templateApp.getErrorPageHandler?.(); if (!errorPageHandler) { _showErrorPageActive = false; logger.warn('showErrorPage: ErrorPageHandler not available'); return; } // params에서 에러 코드 및 옵션 추출 const errorCode = action.params?.errorCode || 500; const target = action.params?.target || 'content'; let containerId = action.params?.containerId; // containerId가 지정되지 않은 경우 target에 따라 결정 if (!containerId) { containerId = target === 'full' ? 'app' : 'main_content'; } // 레이아웃 경로가 지정된 경우 (향후 확장용) // const layout = action.params?.layout; logger.log('showErrorPage:', { errorCode, target, containerId, }); /** * 컨테이너가 DOM에 존재하는지 확인하고, 없으면 MutationObserver로 대기 * progressive 로딩 시 컨테이너가 아직 렌더링되지 않았을 수 있음 */ const waitForContainer = (id: string, timeout: number = 60000): Promise => { return new Promise((resolve, reject) => { // 이미 존재하면 즉시 반환 const existing = document.getElementById(id); if (existing) { resolve(existing); return; } logger.log(`showErrorPage: Waiting for container #${id}...`); // MutationObserver로 DOM 변화 감지 let timeoutId: ReturnType; const observer = new MutationObserver((_mutations, obs) => { const element = document.getElementById(id); if (element) { logger.log(`showErrorPage: Container #${id} found`); obs.disconnect(); clearTimeout(timeoutId); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true, }); // 타임아웃 설정 timeoutId = setTimeout(() => { observer.disconnect(); reject(new Error(`Container #${id} not found within ${timeout}ms`)); }, timeout); }); }; try { // 컨테이너가 준비될 때까지 대기 await waitForContainer(containerId); // 에러 페이지 렌더링 전 레이아웃 errorHandling 임시 해제 // 에러 페이지가 _user_base를 extends할 경우, 데이터소스 에러(예: user 401)가 // ErrorHandlingResolver 싱글톤의 레이아웃 errorHandling을 재트리거하여 무한 루프 발생 방지 const resolver = getErrorHandlingResolver(); const savedLayoutConfig = (resolver as any).layoutErrorHandling; resolver.clearLayoutConfig(); try { const success = await errorPageHandler.renderError(errorCode, containerId); if (!success) { logger.warn(`showErrorPage: Failed to render error page for code ${errorCode}`); } } finally { // 렌더링 완료 후 레이아웃 errorHandling 복원 resolver.setLayoutConfig(savedLayoutConfig); } } catch (error) { logger.error('showErrorPage: Error rendering error page:', error); throw error; } finally { _showErrorPageActive = false; } }); // emitEvent: 컴포넌트 이벤트 발생 // G7Core.componentEvent.emit()을 통해 이벤트를 브로드캐스트하고 모든 리스너의 응답을 기다립니다. // 파일 업로드, 폼 검증, 컴포넌트 간 통신 등 다양한 용도로 사용할 수 있습니다. // // @example 레이아웃 JSON에서 사용: // { // "handler": "emitEvent", // "params": { // "event": "upload:site_logo", // 이벤트명 (컴포넌트에서 구독) // "data": { "collection": "site_logo" } // 전달할 데이터 (선택) // } // } // // @example 컴포넌트에서 구독: // useEffect(() => { // const unsubscribe = G7Core.componentEvent.on('upload:site_logo', async (data) => { // const result = await uploadFiles(); // return result; // emitEvent 호출자에게 반환 // }); // return () => unsubscribe(); // }, []); // // 리스너의 결과는 _local._eventResult에 저장되어 후속 액션에서 접근 가능합니다. this.registerHandler('emitEvent', async (action: ActionDefinition, context: ActionContext) => { const eventName = action.params?.event; const eventData = action.params?.data; if (!eventName) { logger.warn('emitEvent: event parameter is required'); return; } if (typeof window === 'undefined') { logger.warn('emitEvent: window is not available'); return; } const G7Core = (window as any).G7Core; if (!G7Core?.componentEvent?.emit) { logger.warn('emitEvent: G7Core.componentEvent is not available'); return; } try { logger.log(`emitEvent: Emitting "${eventName}"`, eventData); // 이벤트 데이터에 컨텍스트 정보 병합 const mergedData = { ...eventData, _context: { data: context.data, state: context.state, }, }; const results = await G7Core.componentEvent.emit(eventName, mergedData); // 결과가 없으면 (리스너가 없으면) 경고 if (!results || results.length === 0) { logger.warn(`emitEvent: No listeners found for "${eventName}"`); } else { logger.log(`emitEvent: Event "${eventName}" completed with ${results.length} listener(s)`, results); } // 결과를 _local._eventResult에 저장하여 후속 액션에서 접근 가능하게 함 if (this.globalStateUpdater) { const currentGlobalLocal = (G7Core?.state?.get() || {})._local || {}; const eventResult = { event: eventName, success: true, data: results.length === 1 ? results[0] : results, listeners: results.length, }; // 글로벌 _local 갱신 (기존 동작 유지: 글로벌 _local 기준 머지) this.globalStateUpdater({ _local: { ...currentGlobalLocal, _eventResult: eventResult }, }); // engine-v1.50.0: sequence 내 emitEvent 결과를 후속 액션이 참조할 수 있도록 // __g7SequenceLocalSync 에 병합 스냅샷을 실는다. // 배경: handleSequence 의 currentState 는 setState 핸들러와 setLocal() 이 설정한 // __g7SequenceLocalSync 로만 갱신된다(트러블슈팅 사례 24). emitEvent 는 globalStateUpdater // 로만 _local 을 갱신했기에 같은 sequence 의 다음 액션(apiCall body / setState)이 // _eventResult 와 리스너가 갱신한 _local(예: form.images)을 보지 못했다. // (FileUploader onUploadComplete 가 업로드 직후 form.images 를 갱신해도 저장 PUT body 의 // _local.form 스냅샷에 미반영 → 백엔드 syncImages 가 방금 올린 이미지를 삭제하는 회귀.) // // base 는 시퀀스가 추적 중인 _local(context.state) 우선 — sequence 내 in-flight // setState 변경(예: isSaving)을 보존한다. 시퀀스 밖(standalone emitEvent)에서는 // context.state 가 없거나 부분적일 수 있어 글로벌 _local 로 폴백한다. const syncBase = (context.state && typeof context.state === 'object' && !Array.isArray(context.state)) ? context.state : currentGlobalLocal; (window as any).__g7SequenceLocalSync = { ...syncBase, _eventResult: eventResult }; } } catch (error) { logger.error(`emitEvent: Event "${eventName}" failed`, error); // 에러도 _local._eventResult에 저장 if (this.globalStateUpdater) { const currentState = G7Core?.state?.get() || {}; this.globalStateUpdater({ _local: { ...currentState._local, _eventResult: { event: eventName, success: false, error: error instanceof Error ? error.message : String(error), }, }, }); } throw error; } }); // updateProductField: 상품 목록에서 개별 필드 인라인 수정 // products 데이터 소스의 로컬 데이터를 업데이트하고, 변경된 상품 ID를 _local.modifiedProductIds에 추적합니다. // 일괄 변경 버튼 클릭 시 해당 목록의 상품들만 API로 전송합니다. // // @example 레이아웃 JSON에서 사용: // { // "handler": "updateProductField", // "params": { // "productId": "{{row.id}}", // "field": "stock_quantity", // "value": "{{$event.target.value}}" // } // } this.registerHandler('updateProductField', async (action: ActionDefinition, _context: ActionContext) => { const { productId, field, value } = action.params || {}; if (!productId || !field) { logger.warn('updateProductField: productId and field are required'); return; } if (typeof window === 'undefined') { logger.warn('updateProductField: window is not available'); return; } const G7Core = (window as any).G7Core; if (!G7Core?.state?.get || !this.globalStateUpdater) { logger.warn('updateProductField: G7Core.state or globalStateUpdater is not available'); return; } try { const currentState = G7Core.state.get() || {}; const productsData = currentState.products?.data?.data || []; // 상품 목록에서 해당 상품 찾아서 필드 업데이트 const updatedProducts = productsData.map((product: any) => { if (product.id === productId) { return { ...product, [field]: value, _modified: true, // 수정됨 표시 }; } return product; }); // 변경된 상품 ID 추적 const modifiedProductIds = new Set(currentState._local?.modifiedProductIds || []); modifiedProductIds.add(productId); // 상태 업데이트 (products 데이터 + 수정 추적) this.globalStateUpdater({ products: { ...currentState.products, data: { ...currentState.products?.data, data: updatedProducts, }, }, _local: { ...currentState._local, modifiedProductIds: Array.from(modifiedProductIds), }, }); logger.log(`updateProductField: Updated product ${productId}, field: ${field}, value:`, value); } catch (error) { logger.error('updateProductField: Error updating product field', error); throw error; } }); // updateOptionField: 상품 옵션 필드 인라인 수정 // 상품 옵션의 필드를 수정하고 변경 추적합니다. this.registerHandler('updateOptionField', async (action: ActionDefinition, _context: ActionContext) => { const { productId, optionId, field, value } = action.params || {}; if (!productId || !optionId || !field) { logger.warn('updateOptionField: productId, optionId and field are required'); return; } if (typeof window === 'undefined') { logger.warn('updateOptionField: window is not available'); return; } const G7Core = (window as any).G7Core; if (!G7Core?.state?.get || !this.globalStateUpdater) { logger.warn('updateOptionField: G7Core.state or globalStateUpdater is not available'); return; } try { const currentState = G7Core.state.get() || {}; const productsData = currentState.products?.data?.data || []; // 상품 목록에서 해당 상품과 옵션 찾아서 필드 업데이트 const updatedProducts = productsData.map((product: any) => { if (product.id === productId && product.options) { const updatedOptions = product.options.map((option: any) => { if (option.id === optionId) { return { ...option, [field]: value, _modified: true, }; } return option; }); return { ...product, options: updatedOptions, _modified: true, }; } return product; }); // 변경된 상품 ID 추적 const modifiedProductIds = new Set(currentState._local?.modifiedProductIds || []); modifiedProductIds.add(productId); // 상태 업데이트 this.globalStateUpdater({ products: { ...currentState.products, data: { ...currentState.products?.data, data: updatedProducts, }, }, _local: { ...currentState._local, modifiedProductIds: Array.from(modifiedProductIds), }, }); logger.log(`updateOptionField: Updated product ${productId} option ${optionId}, field: ${field}, value:`, value); } catch (error) { logger.error('updateOptionField: Error updating option field', error); throw error; } }); // setLocale: 언어 변경 핸들러 // TemplateApp.changeLocale()을 호출하여 언어 변경, DB 저장, UI 리렌더링을 수행합니다. this.registerHandler('setLocale', async (action: ActionDefinition, _context: ActionContext) => { const locale = action.target; if (!locale || typeof locale !== 'string') { logger.warn('setLocale: Invalid locale:', locale); return; } // TemplateApp 인스턴스를 통해 언어 변경 const templateApp = (window as any).__templateApp; if (templateApp && typeof templateApp.changeLocale === 'function') { try { await templateApp.changeLocale(locale); logger.log('setLocale: Locale changed to', locale); } catch (error) { logger.error('setLocale: Failed to change locale:', error); // 폴백: 페이지 새로고침 window.location.reload(); } } else { // TemplateApp이 없으면 localStorage에 저장 후 새로고침 logger.warn('setLocale: TemplateApp not found, falling back to page reload'); try { localStorage.setItem('g7_locale', locale); } catch { // ignore storage errors } window.location.reload(); } }); // suppress: 에러 전파를 의도적으로 방지하는 no-op 핸들러 // 데이터소스/apiCall의 errorHandling에서 특정 에러 코드를 상위 레벨로 전파하지 않을 때 사용 // 예: 비회원의 /api/auth/user 401은 정상 동작이므로 레이아웃 errorHandling으로 전파 방지 // @since engine-v1.21.0 this.registerHandler('suppress', async () => { logger.log('suppress: Error intentionally suppressed'); }); // DevTools에 빌트인 핸들러 메타데이터 일괄 등록 this.registerBuiltInHandlerMetadata(); } /** * 빌트인 핸들러 메타데이터를 DevTools에 등록합니다. */ private registerBuiltInHandlerMetadata(): void { const devTools = getDevTools(); if (!devTools?.isEnabled()) return; const builtInHandlers: Array<{ name: string; description: string }> = [ { name: 'refetchDataSource', description: '데이터 소스를 다시 fetch합니다' }, { name: 'appendDataSource', description: '데이터 소스에 새 데이터를 병합합니다 (무한 스크롤용)' }, { name: 'updateDataSource', description: 'API 응답으로 데이터 소스를 직접 업데이트합니다 (refetch 대체)' }, { name: 'reloadRoutes', description: '라우트를 다시 로드합니다' }, { name: 'refresh', description: '현재 페이지를 새로고침합니다' }, { name: 'remount', description: '컴포넌트를 리마운트합니다' }, { name: 'reloadTranslations', description: '다국어 파일을 다시 로드합니다' }, { name: 'showErrorPage', description: '에러 페이지를 표시합니다' }, { name: 'emitEvent', description: '이벤트를 발생시킵니다' }, { name: 'updateProductField', description: '상품 필드를 인라인 수정합니다' }, { name: 'updateOptionField', description: '상품 옵션 필드를 인라인 수정합니다' }, { name: 'setLocale', description: '언어를 변경합니다 (DB 저장 + UI 리렌더링)' }, ]; for (const handler of builtInHandlers) { devTools.trackHandlerRegistration(handler.name, 'built-in', handler.description); } } /** * 이벤트 핸들러를 생성합니다. * * @param action 액션 정의 * @param dataContext 데이터 컨텍스트 * @param componentContext 컴포넌트 컨텍스트 (state, setState) */ createHandler( action: ActionDefinition, dataContext?: any, componentContext?: { state?: any; setState?: (updates: any) => void; isolatedContext?: IsolatedContextValue | null } ): (event: Event) => void { logger.log('createHandler called for:', action.handler, action.type); // DevTools: 핸들러 생성 시점 상태 캡처 (stale closure 감지용) const devTools = (window as any).__g7DevTools; const handlerCreatedAt = Date.now(); const capturedLocalState = componentContext?.state ? { ...componentContext.state } : null; const handlerId = `handler_${action.handler}_${handlerCreatedAt}`; return async (event: Event) => { logger.log('Handler invoked for:', action.handler, 'event:', event.type); // DevTools: 핸들러 실행 시점에 stale closure 감지 if (devTools?.isEnabled?.() && capturedLocalState) { const G7Core = (window as any).G7Core; const currentLocalState = G7Core?.state?.getLocal?.() ?? componentContext?.state ?? {}; const timeDiff = Date.now() - handlerCreatedAt; // 캡처된 상태와 현재 상태 비교 for (const key of Object.keys(capturedLocalState)) { const capturedValue = capturedLocalState[key]; const currentValue = currentLocalState[key]; // 값이 다르고 100ms 이상 경과했으면 stale closure 경고 if (capturedValue !== currentValue && timeDiff > 100) { devTools.trackStaleClosureWarning?.({ type: 'event-handler-stale', location: `createHandler(${action.handler})`, capturedPath: `_local.${key}`, capturedValue, capturedAt: handlerCreatedAt, currentValue, actionId: handlerId, stackTrace: new Error().stack, }); logger.warn( `[Stale Closure] _local.${key} changed after handler creation:`, `captured="${capturedValue}" → current="${currentValue}" (${timeDiff}ms ago)` ); } } } try { // 기본 이벤트 동작 방지 (폼 제출 시 페이지 리로드 방지) // 단, change 이벤트에서는 preventDefault()를 호출하면 안됨 // 체크박스/라디오/input의 change 이벤트에서 preventDefault() 호출 시 // 네이티브 상태 변경이 취소되어 React 상태와 불일치 발생 if (event.type !== 'change') { event.preventDefault(); } // 폼 데이터 추출 (submit 이벤트인 경우) let formData: Record = {}; if ( event.type === 'submit' && event.target instanceof HTMLFormElement ) { const form = event.target as HTMLFormElement; const formDataObj = new FormData(form); // FormData를 객체로 변환 formDataObj.forEach((value, key) => { formData[key] = value; }); } // 컨텍스트 병합 (폼 데이터 + 컴포넌트 컨텍스트 포함) const context: ActionContext = { ...this.defaultContext, data: { ...dataContext, form: formData, // 폼 데이터를 form 객체로 추가 // _local: 레이아웃 수준의 로컬 상태 (componentContext.state) _local: componentContext?.state || {}, // $event: 이벤트 객체를 data에 추가 ({{$event.target.value}} 바인딩 지원) $event: event, }, event, // 컴포넌트 컨텍스트 병합 ...(componentContext && { state: componentContext.state, setState: componentContext.setState, }), // 격리된 상태 컨텍스트 (isolatedState 속성이 있는 컴포넌트에서 제공) isolatedContext: componentContext?.isolatedContext, }; // 확인 메시지 표시 if (action.confirm) { let message = this.resolveValue(action.confirm, context.data); // $t: 다국어 구문 처리 if (this.translationEngine && this.translationContext && message.startsWith('$t:')) { message = this.translationEngine.resolveTranslations( message, this.translationContext, context.data ); } if (!confirm(message)) { return; } } // G7Core.state.setLocal(), G7Core.modal.open() 등에서 사용할 수 있도록 현재 컨텍스트 저장 // engine-v1.16.0: componentContext + context.data를 함께 저장하여 $parent 바인딩 지원 const previousActionContext = (window as any).__g7ActionContext; (window as any).__g7ActionContext = { ...componentContext, // context.data에는 _global, _local, _computed 등 전체 데이터 컨텍스트가 포함됨 // G7Core.dispatch()에서 openModal 등 호출 시 이 데이터가 필요함 data: context.data, }; try { // 액션 실행 await this.executeAction(action, context); } finally { // 액션 완료 후 이전 컨텍스트 복원 (window as any).__g7ActionContext = previousActionContext; } } catch (error) { logger.error('Action execution failed:', error); // 에러 액션 실행 (컴포넌트 컨텍스트 포함) if (action.onError) { // 에러 객체를 data에 포함시켜 바인딩 가능하게 함 const errorData = error instanceof ActionError && error.originalError ? error.originalError : error; const errorContext = { ...this.defaultContext, data: { ...dataContext, error: errorData, // 에러 객체 추가 // _local: 레이아웃 수준의 로컬 상태 (componentContext.state) _local: componentContext?.state || {}, // $event: 이벤트 객체를 data에 추가 $event: event, }, event, // 컴포넌트 컨텍스트 병합 (setState를 통해 에러 상태 업데이트 가능) ...(componentContext && { state: componentContext.state, setState: componentContext.setState, }), }; // onError가 배열인 경우 순차 실행 const errorActions = Array.isArray(action.onError) ? action.onError : [action.onError]; for (const errorAction of errorActions) { await this.executeAction(errorAction, errorContext); } } } }; } /** * 액션 ID를 생성합니다. * * 핸들러 타입, 타겟, 타임스탬프를 조합하여 고유한 액션 ID를 생성합니다. * * @param action 액션 정의 * @param target 해석된 타겟 */ private generateActionId(action: ActionDefinition, target?: string): string { const handlerPart = action.handler; const targetPart = target || 'no-target'; const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 9); return `${handlerPart}_${targetPart.replace(/[^a-zA-Z0-9]/g, '_')}_${timestamp}_${random}`; } /** * 액션을 실행합니다. * * @param action 액션 정의 * @param context 액션 컨텍스트 */ private async executeAction( action: ActionDefinition, context: ActionContext ): Promise { // actionRef 해석 - named_actions 참조를 실제 액션 정의로 변환 action = this.resolveActionRef(action); // 동적 핸들러 이름 해석 — handler 가 `{{...}}` 바인딩이면 컨텍스트로 먼저 해석한다. // 백엔드 응답이 호출할 핸들러 풀네임을 내려주는 provider-agnostic 디스패치 // (예: 결제 진입 `handler: "{{response.data.pg_payment_handler}}"`)를 지원한다. // 빌트인 26종은 camelCase 리터럴이라 `{{` 미포함 → 해석 분기 미진입(무영향). // resolveActionRef 직후·프리뷰 억제 체크·switch 보다 앞에 두어 (1) 빌트인 라우팅이 // 해석된 이름으로 매칭되고 (2) PREVIEW_SUPPRESSED_HANDLERS 판정도 해석된 이름으로 // 이뤄지게 한다(편집기 프리뷰 정합). nested(conditions/sequence)도 동일 executeAction // 경유라 자동 적용. @since engine-v1.50.0 if (typeof action.handler === 'string' && action.handler.includes('{{')) { const resolvedHandler = this.evaluateExpression(action.handler, context.data); action = { ...action, handler: resolvedHandler == null ? '' : String(resolvedHandler) }; } // DevTools 액션 로깅 시작 const devTools = getDevTools(); const devToolsActionId = devTools?.isEnabled() ? `action_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` : undefined; const startTime = performance.now(); // DevTools: 해석된 params를 추적하기 위해 별도 저장 let devToolsResolvedParams: Record | undefined; if (devToolsActionId && devTools) { devTools.logAction({ id: devToolsActionId, type: action.handler, params: this.sanitizeForDevTools(action.params), context: this.sanitizeForDevTools({ hasState: !!context.state, hasSetState: !!context.setState, dataKeys: context.data ? Object.keys(context.data) : [], }), startTime, status: 'started', }); } // if 조건 확인 - 조건이 false이면 액션 건너뜀 if (action.if !== undefined) { try { const conditionResult: any = this.resolveValue(action.if, context.data); // resolveValue가 문자열 "false"/"true"를 반환할 수 있으므로 명시적 변환 const isTruthy = conditionResult === true || conditionResult === 'true' || (conditionResult && conditionResult !== 'false' && conditionResult !== '0' && conditionResult !== false); logger.log('[executeAction] if condition result:', action.handler, isTruthy); if (!isTruthy) { // DevTools: 조건 불충족으로 스킵됨 if (devToolsActionId && devTools) { devTools.logAction({ id: devToolsActionId, type: action.handler, startTime, endTime: performance.now(), duration: performance.now() - startTime, status: 'success', result: { skipped: true, reason: 'if condition false' }, }); } return { success: true, data: undefined }; } } catch (error) { logger.error('[executeAction] if condition error:', action.handler, error); // DevTools: 조건 평가 에러 if (devToolsActionId && devTools) { devTools.logAction({ id: devToolsActionId, type: action.handler, startTime, endTime: performance.now(), duration: performance.now() - startTime, status: 'error', error: { name: 'ConditionEvaluationError', message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }, }); } return { success: true, data: undefined }; } } // 액션 ID 생성 (API 호출인 경우에만) let actionId: string | undefined; try { // 파라미터 바인딩 const resolvedParams = this.resolveParams(action.params, context.data); // DevTools: 해석된 params 저장 if (devToolsActionId) { devToolsResolvedParams = this.sanitizeForDevTools(resolvedParams); } // 타겟 바인딩 const resolvedTarget = action.target ? this.resolveValue(action.target, context.data) : undefined; // API 호출 시 로딩 상태 시작 (컴포넌트별 개별 관리) if (action.handler === 'apiCall' && context.setState) { actionId = this.generateActionId(action, resolvedTarget); // 기존 loadingActions 상태 유지하면서 새 액션 추가 const currentLoadingActions = context.state?.loadingActions || {}; // loadingActions 는 apiCall 을 발화한 컴포넌트 자신의 일시적 표시 플래그다. // 저장소 B 는 페이지 단위 공유 슬롯이라 미러하면 ① 한 컴포넌트의 로딩 플래그가 // 다른 컴포넌트로 새고 ② __g7ForcedLocalFields 가 그 값을 모든 컴포넌트에 강제하는데, // 해제는 키 생략으로 하므로 깊은 병합에서 지워지지 않아 스피너가 영구히 남는다. // 소비자는 자기 렌더 컨텍스트로만 읽는다. // audit:allow local-store-write-must-mirror 컴포넌트별 일시 표시 플래그 (위 사유) context.setState({ loadingActions: { ...currentLoadingActions, [actionId]: true } }); } // 프리뷰 모드 억제 체크 (switch 진입 전) // PREVIEW_SUPPRESSED_HANDLERS에 정의된 핸들러는 프리뷰 모드에서 실행하지 않음 // @since engine-v1.26.1 if (this.previewMode && PREVIEW_SUPPRESSED_HANDLERS.has(action.handler)) { logger.warn(`Preview mode: "${action.handler}" suppressed`, resolvedTarget || resolvedParams); // DevTools: 억제 로그 if (devToolsActionId && devTools) { devTools.logAction({ id: devToolsActionId, type: action.handler, startTime, endTime: performance.now(), duration: performance.now() - startTime, status: 'skipped', metadata: { reason: 'preview_mode_suppressed' }, }); } return { success: true, data: undefined }; } // 액션 핸들러 실행 let result: any; switch (action.handler) { case 'ensureIdentityVerified': result = await this.handleEnsureIdentityVerified(resolvedParams); break; case 'resolveIdentityChallenge': result = this.handleResolveIdentityChallenge(resolvedParams); break; case 'navigate': // navigate 액션의 경우 params.path를 우선적으로 사용 (동적 바인딩 지원) const navigatePath = resolvedParams.path || resolvedTarget; result = await this.handleNavigate(navigatePath!, resolvedParams, context); break; case 'navigateBack': result = await this.handleNavigateBack(); break; case 'navigateForward': result = await this.handleNavigateForward(); break; case 'openWindow': { const openWindowPath = resolvedParams.path || resolvedTarget; result = await this.handleOpenWindow(openWindowPath!, resolvedParams); break; } case 'replaceUrl': { const replaceUrlPath = resolvedParams.path || resolvedTarget || window.location.pathname; result = await this.handleReplaceUrl(replaceUrlPath, resolvedParams); break; } case 'apiCall': // DevTools: Stale Closure 감지를 위한 상태 캡처 (onSuccess가 있는 경우만) if (action.onSuccess && devTools?.isEnabled() && devToolsActionId) { const G7Core = (window as any).G7Core; const captureState: Record = {}; // _global과 _local 상태 캡처 if (G7Core?.state?.get) { const globalState = G7Core.state.get(); captureState['_global'] = globalState; } if (context.state) { captureState['_local'] = context.state; } devTools.registerStateCaptureForHandler?.(devToolsActionId, ['_global', '_local'], captureState); } // auth_mode 우선, auth_required는 하위 호환 const authMode = action.auth_mode ?? (action.auth_required ? 'required' : 'none'); // identity_target: IDV 428 인터셉트 시 사용할 인증 대상(이메일·전화). 표현식 평가 후 전달. const resolvedIdentityTarget = action.identity_target ? (this.resolveParams(action.identity_target, context.data) as { email?: string; phone?: string; }) : undefined; result = await this.handleApiCall( resolvedTarget!, resolvedParams, context, authMode, resolvedIdentityTarget ); break; case 'login': result = await this.handleLogin( resolvedTarget!, resolvedParams, context ); break; case 'loginTwoFactor': result = await this.handleLoginTwoFactor( resolvedTarget!, resolvedParams, context ); break; case 'loginTwoFactorResend': result = await this.handleLoginTwoFactorResend( resolvedTarget!, resolvedParams, context ); break; case 'logout': result = await this.handleLogout(resolvedTarget!, context); break; case 'setState': logger.log('[executeAction] setState resolvedParams:', resolvedParams); // engine-v1.42.0: action.render 옵션을 __render 메타데이터로 전달 // handleSetState는 resolvedParams를 받으므로 action 레벨 속성에 직접 접근 불가 result = await this.handleSetState( action.render !== undefined ? { ...resolvedParams, __render: action.render } : resolvedParams, context ); break; case 'setError': result = await this.handleSetError(resolvedTarget!, resolvedParams, context); break; case 'openModal': result = await this.handleOpenModal(resolvedTarget!, context); break; case 'closeModal': result = await this.handleCloseModal(context); break; case 'showAlert': result = await this.handleShowAlert(resolvedTarget!, context); break; case 'toast': result = await this.handleToast(resolvedParams, context); break; case 'switch': result = await this.handleSwitch(action, context); break; case 'conditions': result = await this.handleConditions(action, context); break; case 'sequence': result = await this.handleSequence(action, context); break; case 'parallel': result = await this.handleParallel(action, context); break; case 'startInterval': result = this.handleStartInterval(resolvedParams, context); break; case 'stopInterval': result = this.handleStopInterval(resolvedParams); break; case 'loadScript': result = await this.handleLoadScript(resolvedParams, action, context); break; case 'callExternal': result = await this.handleCallExternal(resolvedParams, action, context); break; case 'callExternalEmbed': result = await this.handleCallExternalEmbed(resolvedParams, action, context); break; case 'saveToLocalStorage': result = await this.handleSaveToLocalStorage(resolvedParams, context); break; case 'loadFromLocalStorage': result = await this.handleLoadFromLocalStorage(resolvedParams, context); break; default: // 커스텀 핸들러 실행 (바인딩된 target과 params 전달) result = await this.handleCustomAction( { ...action, target: resolvedTarget, params: resolvedParams, }, context ); break; } // resultTo 처리: 핸들러 실행 결과를 상태에 저장 if (action.resultTo && result !== undefined) { const { target, key, merge: resultToMerge } = action.resultTo; // key에서 {{}} 바인딩 해석 const resolvedKey = this.resolveValue(key, context.data); const resultToMergeMode: 'replace' | 'shallow' | 'deep' = resultToMerge === 'replace' ? 'replace' : resultToMerge === 'shallow' ? 'shallow' : 'deep'; if (target === '_local' && context.setState) { // 로컬 상태에 저장 (dot notation 지원) const update = this.buildNestedUpdate(resolvedKey, result); // merge 모드에 따라 __mergeMode 메타데이터 추가 const updateWithMode = resultToMergeMode !== 'deep' ? { ...update, __mergeMode: resultToMergeMode } : update; this.writeLocalState(context, updateWithMode); logger.log(`[resultTo] Saved to _local.${resolvedKey} (merge=${resultToMergeMode}):`, result); } else if (target === '_local' && this.globalStateUpdater) { // init_actions 등에서 componentContext가 없는 경우 globalStateUpdater를 통해 _local 업데이트 // globalState._local에 저장하여 렌더링 시 DynamicRenderer에서 사용 const G7Core = (window as any).G7Core; const currentState = G7Core?.state?.get() || {}; const currentLocal = currentState._local || {}; const newValue = this.buildNestedUpdate(resolvedKey, result); let mergedLocal: Record; if (resultToMergeMode === 'replace') { mergedLocal = newValue; } else if (resultToMergeMode === 'shallow') { mergedLocal = { ...currentLocal, ...newValue }; } else { mergedLocal = this.deepMergeWithState(newValue, currentLocal); } this.globalStateUpdater({ _local: mergedLocal }); logger.log(`[resultTo] Saved to _local.${resolvedKey} via globalStateUpdater (merge=${resultToMergeMode}):`, result); } else if (target === '_global' && this.globalStateUpdater) { // 전역 상태에 저장 const update = this.buildNestedUpdate(resolvedKey, result); this.globalStateUpdater(update); logger.log(`[resultTo] Saved to _global.${resolvedKey}:`, result); } else if (target === '_isolated' && context.isolatedContext) { // 격리된 상태에 저장 const update = this.buildNestedUpdate(resolvedKey, result); context.isolatedContext.mergeState(update, resultToMergeMode); logger.log(`[resultTo] Saved to _isolated.${resolvedKey} (merge=${resultToMergeMode}):`, result); } else { logger.warn(`[resultTo] Cannot save result: target=${target}, setState=${!!context.setState}, globalStateUpdater=${!!this.globalStateUpdater}, isolatedContext=${!!context.isolatedContext}`); } } // 성공 액션 실행 (단일 또는 배열 지원) // 여러 액션이 있는 경우 sequence로 처리하여 상태 동기화 보장 if (action.onSuccess) { // DevTools: Stale Closure 감지 (apiCall 완료 후) if (devTools?.isEnabled() && devToolsActionId && action.handler === 'apiCall') { const G7Core = (window as any).G7Core; const currentState: Record = {}; if (G7Core?.state?.get) { currentState['_global'] = G7Core.state.get(); } if (context.state) { currentState['_local'] = context.state; } // Stale Closure 감지 - 캡처된 상태와 현재 상태 비교 devTools.detectStaleClosure?.( devToolsActionId, `${action.handler} → onSuccess`, currentState, 'callback-state-capture', devToolsActionId ); } // result와 response 모두 사용 가능하도록 컨텍스트 구성 // - result: 기존 호환성 유지 // - response: API 응답임을 명확히 표현 (권장) const successContext = { ...context, data: { ...context.data, result, response: result }, }; const successActions = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess]; if (successActions.length > 1) { // 여러 액션을 sequence로 감싸서 처리 - handleSequence의 상태 동기화 로직 활용 await this.handleSequence( { handler: 'sequence', type: 'click', actions: successActions }, successContext ); } else if (successActions.length === 1) { await this.executeAction(successActions[0], successContext); } } // DevTools: 성공 완료 로깅 (resolvedParams 포함) if (devToolsActionId && devTools) { devTools.logAction({ id: devToolsActionId, type: action.handler, params: this.sanitizeForDevTools(action.params), resolvedParams: devToolsResolvedParams, startTime, endTime: performance.now(), duration: performance.now() - startTime, status: 'success', result: this.sanitizeForDevTools(result), }); } return { success: true, data: result }; } catch (error) { const actionError = error instanceof ActionError ? error : new ActionError( `Failed to execute action: ${action.handler}`, action, error instanceof Error ? error : undefined ); // API 응답에서 에러 정보 추출 const apiResponse = (actionError.originalError as any)?.response || {}; const responseData = apiResponse.data || {}; const errorStatus = (actionError.originalError as any)?.status || apiResponse.status || 500; // 에러 컨텍스트 생성 (ErrorHandlingResolver와 호환) // API 응답의 message를 우선 사용하고, 응답이 아예 없었던 네트워크 실패에는 // 내부 식별 문구 대신 다국어 안내를 쓴다 (engine-v1.54.6) let errorMessage = resolveActionFailureMessage( actionError.originalError ?? error, action.handler, responseData.message, actionError.message ); // 상태에 실려 텍스트로 그대로 렌더되는 경로(`{{error.message}}` → `_global.*Error`)가 있으므로 // 여기서 번역해 둔다. 키 문자열이 화면에 노출되면 안 된다. if (this.translationEngine && this.translationContext && errorMessage.startsWith('$t:')) { errorMessage = this.translationEngine.resolveTranslations( errorMessage, this.translationContext ); } const errorContextData: ErrorContext = { status: errorStatus, message: errorMessage, errors: responseData.errors || apiResponse.errors, data: responseData, statusText: (actionError.originalError as any)?.statusText, // API 응답의 error_code (예: 428 'identity_verification_required') 를 노출 — // 코어 toast 핸들러가 IDV 가드 토스트를 코드로 식별해 중복 억제하는 데 사용. error_code: responseData.error_code ?? apiResponse.error_code, }; // 미등록 핸들러는 표시 계층으로 내보내지 않는다. // // 확장 번들이 로드되지 않으면 그 확장 소유 핸들러(예: sirsoft-ecommerce.initPreferredCurrency) // 가 등록되지 않는다. 이때 errorHandling 정책을 태우면 `Unknown action handler: {내부식별자}` // 라는 raw 영문 문구가 토스트로 사용자에게 노출된다. 사용자가 조치할 수 있는 일이 아니며 // 내부 식별자 노출 자체가 결함이다. 확장 부재는 조용한 기능 열화로 끝내고 warn 만 남긴다. // (throw 는 그대로 유지되어 호출부의 기존 흐름은 바뀌지 않는다.) // @since engine-v1.53.0 if (actionError.unknownHandler) { logger.warn( `Unknown action handler "${action.handler}" — skipped (extension not loaded?). ` + 'Not surfaced to the user.' ); throw actionError; } // 에러 핸들링 우선순위: // 1. action.errorHandling[코드] → action.errorHandling[default] // 2. action.onError // 3. 레이아웃/템플릿/시스템 기본값 (ErrorHandlingResolver) // ErrorHandlingResolver를 통해 핸들러 결정 const resolver = getErrorHandlingResolver(); const result = resolver.resolve(errorStatus, { errorHandling: action.errorHandling, onError: action.onError as any, // ActionDefinition[] → ErrorHandlerConfig[] 변환 }); if (result.handler) { // DevTools: Stale Closure 감지 (apiCall 에러 발생 후) if (devTools?.isEnabled() && devToolsActionId && action.handler === 'apiCall') { const G7Core = (window as any).G7Core; const currentState: Record = {}; if (G7Core?.state?.get) { currentState['_global'] = G7Core.state.get(); } if (context.state) { currentState['_local'] = context.state; } // Stale Closure 감지 - 캡처된 상태와 현재 상태 비교 devTools.detectStaleClosure?.( devToolsActionId, `${action.handler} → onError`, currentState, 'callback-state-capture', devToolsActionId ); } // 핸들러가 결정되면 실행 const errorContext = { ...context, data: { ...context.data, error: errorContextData, }, }; try { // ErrorHandlerConfig를 ActionDefinition으로 변환하여 실행 const handlerAction: ActionDefinition = { type: 'click', handler: result.handler.handler as ActionType, target: result.handler.target, params: result.handler.params, actions: result.handler.actions as ActionDefinition[], }; await this.executeAction(handlerAction, errorContext); return { success: false, error: actionError }; } catch (handlerError) { logger.error('Error executing error handler:', handlerError); return { success: false, error: actionError }; } } // DevTools: 에러 로깅 (resolvedParams 포함) if (devToolsActionId && devTools) { devTools.logAction({ id: devToolsActionId, type: action.handler, params: this.sanitizeForDevTools(action.params), resolvedParams: devToolsResolvedParams, startTime, endTime: performance.now(), duration: performance.now() - startTime, status: 'error', error: { name: actionError.name, message: actionError.message, stack: actionError.stack, }, }); } // 핸들러를 찾지 못한 경우 에러를 throw throw actionError; } finally { // API 호출 후 로딩 상태 해제 (컴포넌트별 개별 관리) if (action.handler === 'apiCall' && context.setState && actionId) { const currentLoadingActions = context.state?.loadingActions || {}; const { [actionId]: _, ...remainingLoadingActions } = currentLoadingActions; // loadingActions 는 apiCall 을 발화한 컴포넌트 자신의 일시적 표시 플래그다. // 저장소 B 는 페이지 단위 공유 슬롯이라 미러하면 ① 한 컴포넌트의 로딩 플래그가 // 다른 컴포넌트로 새고 ② __g7ForcedLocalFields 가 그 값을 모든 컴포넌트에 강제하는데, // 해제는 키 생략으로 하므로 깊은 병합에서 지워지지 않아 스피너가 영구히 남는다. // 소비자는 자기 렌더 컨텍스트로만 읽는다. // audit:allow local-store-write-must-mirror 컴포넌트별 일시 표시 플래그 (위 사유) context.setState({ loadingActions: remainingLoadingActions }); } } } /** * navigate 액션을 처리합니다. * * @param target 이동할 경로 (기본 경로) * @param params 파라미터 (mergeQuery, query, replace 등) * @param context 액션 컨텍스트 */ private async handleNavigate( target: string, params: Record, context: ActionContext ): Promise { let finalPath = target; // query 파라미터 처리 // mergeQuery: true 는 query 키 없이도 병합을 수행한다 (@since engine-v1.54.2). // 이전에는 `if (params.query)` 게이트에 걸려 `mergeQuery: true` 만 적은 액션이 병합 // 자체를 건너뛰고 쿼리를 통째로 잃었다 — 작성자 관점에서 가장 자연스러운 형태가 // 정반대로 동작하던 함정이라 게이트를 넓힌다. if (params.query || params.mergeQuery === true) { if (params.mergeQuery === true) { // mergeQuery가 true이면 기존 쿼리스트링과 병합 finalPath = this.buildMergedQueryPath(target, params.query ?? {}); } else { // mergeQuery가 false이거나 없으면 새 쿼리스트링으로 대체 const queryString = new URLSearchParams(); for (const [key, value] of Object.entries(params.query)) { if (value !== undefined && value !== null && value !== '') { // 배열인 경우 각 요소를 개별 파라미터로 추가 (예: sales_status[]=a&sales_status[]=b) // 키에 []가 없으면 추가하여 Laravel이 배열로 인식하도록 함 if (Array.isArray(value)) { const arrayKey = key.endsWith('[]') ? key : `${key}[]`; for (const item of value) { if (item !== undefined && item !== null && item !== '') { queryString.append(arrayKey, String(item)); } } } else { queryString.set(key, String(value)); } } } const qs = queryString.toString(); if (qs) { finalPath = `${target}?${qs}`; } } } // DEBUG: navigate 호출 전 로깅 logger.log('handleNavigate:', { target, params, finalPath, replace: params.replace, windowLocationSearch: window.location.search, }); // replace: true인 경우 - URL만 교체하고 데이터 소스 refetch (컴포넌트 리마운트 없음) // 같은 페이지에서 검색/필터 변경 시 사용 // params.transition_overlay_target 으로 transition_overlay.target 동적 override 지원 (@since engine-v1.36.0) // 명명 주의: `overlay_target` 같은 광범위 키를 피하고 `transition_overlay` 스키마와 1:1 매핑되는 명시적 이름 사용. // 미래 다른 overlay 시스템(modal/drawer/tooltip 등)은 각자 독립 키 이름 사용 권장. if (params.replace === true) { const G7Core = (window as any).G7Core; if (G7Core?.updateQueryParams) { const transitionOverlayTarget = typeof (params as any).transition_overlay_target === 'string' ? (params as any).transition_overlay_target : undefined; await G7Core.updateQueryParams( finalPath, transitionOverlayTarget ? { transitionOverlayTarget } : undefined ); logger.log( 'handleNavigate: Used updateQueryParams for replace mode', transitionOverlayTarget ? { transitionOverlayTarget } : undefined ); // 이동 후 스크롤 위치 적용 (기본: 상단) this.applyScrollOption(params.scroll, params.scrollBehavior, 'top'); return; } // G7Core.updateQueryParams가 없으면 fallback으로 React Router 사용 logger.warn('handleNavigate: G7Core.updateQueryParams not available, falling back to React Router'); } // 미등록 라우트 fallback — 기본 openWindow, fallback: false 또는 커스텀 지정 가능 // @since engine-v1.40.0: 관리자 ↔ 사용자 경로 교차 이동 시 404 대신 새 창 열기 // 중요: 동기 검사로 fallback 대상 여부만 판단 — 일치/부재 시 즉시 빠져나가 기존 동기 실행 경로 유지 const fallbackAction = this.resolveNavigateFallbackAction(finalPath, params); if (fallbackAction) { logger.warn( `handleNavigate: No route matched, falling back to "${fallbackAction.handler}"` ); await this.dispatchAction( { type: 'click', handler: fallbackAction.handler, params: fallbackAction.params } as ActionDefinition, context ); this.applyScrollOption(params.scroll, params.scrollBehavior, 'top'); return; } // navigate 함수 확인 if (!context.navigate) { throw new ActionError( 'Navigate function is not provided in context' ); } // 일반 navigate (페이지 전환) - React Router 사용 context.navigate(finalPath, { replace: params.replace === true }); // 이동 후 스크롤 위치 적용 (기본: 상단) this.applyScrollOption(params.scroll, params.scrollBehavior, 'top'); } /** * navigate 대상 경로가 routes.json에 없는 경우 실행할 fallback 액션을 결정합니다. * 매칭되거나 fallback이 비활성화된 경우 null을 반환합니다. * * 동기 검사로 동작 — 기존 navigate 동기 실행 경로를 보존하기 위해 await을 피함. * * 동작: * - `params.fallback === false` → null (fallback 비활성화) * - `params.fallback` 미지정 → 기본 openWindow * - `params.fallback: string` → 해당 핸들러명 * - `params.fallback: { handler, params }` → 상세 지정 * * @param finalPath query 병합이 완료된 최종 경로 * @param params navigate params * @returns fallback 액션 정의 또는 null (정상 navigate 진행) * @since engine-v1.40.0 */ private resolveNavigateFallbackAction( finalPath: string, params: Record ): { handler: string; params: Record } | null { const fallbackOption = params.fallback; // 명시적 비활성화 if (fallbackOption === false) { return null; } // replace: true는 쿼리 갱신 전용으로 실제 경로 이동이 아니므로 fallback 대상 아님 if (params.replace === true) { return null; } const templateApp = (window as any).__templateApp; const router = templateApp?.getRouter?.(); if (!router || typeof router.match !== 'function') { return null; } // 라우트가 아직 로드되지 않은 경우 fallback 미적용 — 초기 부팅 단계 또는 테스트 환경 보호 if (typeof router.getRoutes === 'function' && router.getRoutes().length === 0) { return null; } const pathname = finalPath.split('?')[0]; if (router.match(pathname)) { // 현재 템플릿에 등록된 경로 — 정상 navigate return null; } // 미등록 경로 — fallback 핸들러 결정 return this.resolveNavigateFallback(fallbackOption, finalPath, params); } /** * navigate fallback 옵션을 정규화하여 { handler, params } 구조로 변환합니다. * * @param fallbackOption params.fallback 원본 값 (undefined | string | object) * @param finalPath query 병합 완료된 경로 * @param originalParams 원래 navigate params * @returns fallback 핸들러 정의 * @since engine-v1.40.0 */ private resolveNavigateFallback( fallbackOption: any, finalPath: string, _originalParams: Record ): { handler: string; params: Record } { // 기본값: openWindow with target '_self' — 같은 탭에서 이동. // 이전 기본값(`'_blank'`)은 admin↔user 교차 이동 시 새 탭이 열려 사용자 흐름이 // 끊기는 결함을 유발. 명시적으로 새 탭이 // 필요한 호출처는 `params.fallback: { handler: 'openWindow', params: { target: '_blank' } }` // 로 지정. if (fallbackOption == null) { return { handler: 'openWindow', params: { path: finalPath, target: '_self' }, }; } // 문자열: 핸들러명만 지정 if (typeof fallbackOption === 'string') { return { handler: fallbackOption, params: { path: finalPath }, }; } // 객체: 상세 지정 { handler, params } if (typeof fallbackOption === 'object' && typeof fallbackOption.handler === 'string') { return { handler: fallbackOption.handler, params: { path: finalPath, ...(fallbackOption.params || {}), }, }; } // 알 수 없는 형태 — 기본값으로 폴백 logger.warn('resolveNavigateFallback: unrecognized fallback option, using openWindow', fallbackOption); return { handler: 'openWindow', params: { path: finalPath, target: '_blank' }, }; } /** * 이동 후 스크롤 위치를 적용합니다. (@since engine-v1.37.0) * * **단축 문법**: * - `"top"` (기본) → `#app` 내부의 모든 스크롤 컨테이너 + window 를 상단으로 리셋 * - `"preserve"` → 스크롤 위치 유지 (no-op) * - `number` → window 를 (0, n) 으로 * - `{ x, y }` → window 를 (x, y) 로 * - `"#id"` / `".class"` → 해당 엘리먼트로 `scrollIntoView` * * **확장 객체 문법** (@since engine-v1.37.0): * ```ts * { * container?: string; // 스크롤 컨테이너 선택자 (생략 시 window) * to?: string | number | { x?, y? } | 'top'; // 이동 대상 (생략 시 'top') * block?: 'start' | 'center' | 'end' | 'nearest'; // scrollIntoView block (기본 'start') * offset?: number; // sticky 헤더 보정 (px, 양수 = 위쪽 여유) * } * ``` * * 새 레이아웃이 DOM에 반영된 뒤 스크롤되도록 requestAnimationFrame으로 다음 tick에 실행합니다. * * @param scroll 스크롤 옵션 * @param scrollBehavior 스크롤 애니메이션 ('instant' | 'smooth') * @param defaultValue 옵션 미지정 시 기본 동작 ('top' | 'preserve') */ private applyScrollOption( scroll: unknown, scrollBehavior: unknown, defaultValue: 'top' | 'preserve' ): void { const effective = scroll === undefined ? defaultValue : scroll; if (effective === 'preserve') { return; } // 기본값 'instant' — CSS scroll-behavior: smooth 가 전역 적용된 환경에서도 // 페이지 전환 시 즉시 스크롤되도록 'auto' 대신 'instant' 사용. // 'smooth' 명시 시에만 부드러운 스크롤 적용. const behavior = ( scrollBehavior === 'smooth' ? 'smooth' : 'instant' ) as ScrollBehavior; // 확장 객체 형태 감지: container/to/block/offset 중 하나라도 있으면 확장 형태 const isExtendedForm = (v: unknown): boolean => { if (typeof v !== 'object' || v === null) return false; const obj = v as Record; return 'container' in obj || 'to' in obj || 'block' in obj || 'offset' in obj; }; // 컨테이너에 특정 Y 좌표로 스크롤 const scrollTargetTo = ( target: HTMLElement | Window, y: number, x: number = 0 ) => { if (target === window) { window.scrollTo({ top: y, left: x, behavior }); } else { (target as HTMLElement).scrollTo({ top: y, left: x, behavior }); } }; // 엘리먼트를 지정된 컨테이너 안으로 스크롤 (block/offset 적용) const scrollElementIntoTarget = ( el: HTMLElement, container: HTMLElement | Window, block: ScrollLogicalPosition, offset: number ) => { if (container === window) { // window 컨텍스트: 네이티브 scrollIntoView 사용 후 offset 보정 el.scrollIntoView({ behavior, block }); if (offset) { window.scrollBy({ top: -offset, left: 0, behavior }); } return; } const c = container as HTMLElement; const elRect = el.getBoundingClientRect(); const cRect = c.getBoundingClientRect(); const relativeTop = c.scrollTop + (elRect.top - cRect.top); let top: number; if (block === 'center') { top = relativeTop - (c.clientHeight - el.clientHeight) / 2; } else if (block === 'end') { top = relativeTop - (c.clientHeight - el.clientHeight); } else { // 'start' | 'nearest' → 기본 시작 top = relativeTop; } top -= offset; c.scrollTo({ top: Math.max(0, top), left: 0, behavior }); }; // #app 내부의 모든 스크롤 컨테이너를 상단으로 리셋 const resetAllScrollContainers = () => { window.scrollTo({ top: 0, left: 0, behavior }); const root = document.getElementById('app') ?? document.body; if (!root) return; const candidates = root.querySelectorAll('*'); candidates.forEach((el) => { if (el.scrollTop === 0 && el.scrollLeft === 0) return; const style = window.getComputedStyle(el); const oy = style.overflowY; const ox = style.overflowX; const scrollable = oy === 'auto' || oy === 'scroll' || ox === 'auto' || ox === 'scroll'; if (scrollable) { el.scrollTo({ top: 0, left: 0, behavior }); } }); }; const run = () => { try { // --- 확장 객체 문법 --- if (isExtendedForm(effective)) { const ext = effective as { container?: string; to?: unknown; block?: ScrollLogicalPosition; offset?: number; }; const block: ScrollLogicalPosition = ext.block ?? 'start'; const offset: number = typeof ext.offset === 'number' ? ext.offset : 0; // 컨테이너 해석: 지정되면 해당 엘리먼트, 아니면 window let container: HTMLElement | Window = window; if (typeof ext.container === 'string' && ext.container.length > 0) { const el = document.querySelector(ext.container) as HTMLElement | null; if (!el) { logger.warn( `applyScrollOption: container not found: ${ext.container}` ); return; } container = el; } const to = ext.to ?? 'top'; // to: 'top' → 컨테이너(또는 window)를 상단으로 if (to === 'top') { if (container === window) { resetAllScrollContainers(); } else { scrollTargetTo(container, 0); } return; } // to: number → Y 좌표 if (typeof to === 'number') { scrollTargetTo(container, to - offset); return; } // to: { x, y } → 좌표 if ( typeof to === 'object' && to !== null && ('x' in (to as object) || 'y' in (to as object)) ) { const { x = 0, y = 0 } = to as { x?: number; y?: number }; scrollTargetTo(container, y - offset, x); return; } // to: '#id' / '.class' → 엘리먼트로 스크롤 if ( typeof to === 'string' && (to.startsWith('#') || to.startsWith('.')) ) { const el = document.querySelector(to) as HTMLElement | null; if (el) { scrollElementIntoTarget(el, container, block, offset); } else { logger.warn(`applyScrollOption: target element not found: ${to}`); } return; } logger.warn('applyScrollOption: invalid "to" value in extended form', to); return; } // --- 단축 문법 --- if (effective === 'top') { resetAllScrollContainers(); return; } if (typeof effective === 'number') { window.scrollTo({ top: effective, left: 0, behavior }); return; } if ( typeof effective === 'object' && effective !== null && ('x' in (effective as object) || 'y' in (effective as object)) ) { const { x = 0, y = 0 } = effective as { x?: number; y?: number }; window.scrollTo({ top: y, left: x, behavior }); return; } if ( typeof effective === 'string' && (effective.startsWith('#') || effective.startsWith('.')) ) { const el = document.querySelector(effective); if (el) { (el as HTMLElement).scrollIntoView({ behavior, block: 'start' }); } return; } } catch (err) { logger.warn('applyScrollOption: failed to apply scroll', err); } }; if (typeof window.requestAnimationFrame === 'function') { window.requestAnimationFrame(run); } else { run(); } } /** * openWindow 액션을 처리합니다. * * 지정된 경로로 이동합니다. `params.target` 으로 창 동작을 제어: * - `'_blank'`(기본): 새 탭/창 열기 (`window.open(path, '_blank')`) * - `'_self'`: 같은 탭에서 이동 (`window.location.assign(path)`) * * navigate fallback 으로 호출되는 경우(현재 템플릿 라우트에 없는 경로 → * openWindow) 기본값이 `_blank` 라 의도치 않게 새 탭이 열리는 결함이 있다. * fallback 호출 측에서 `params.target: '_self'` 를 명시하거나, 사용자 액션 * 정의에서 같은 탭 이동을 원하면 `target: '_self'` 명시. * * @param target 열 경로 (action target) * @param params 파라미터 (query, target 등) */ private async handleOpenWindow( target: string, params: Record ): Promise { let finalPath = target; // query 파라미터 처리 if (params.query) { const queryString = new URLSearchParams(); for (const [key, value] of Object.entries(params.query)) { if (value !== null && value !== undefined && value !== '') { queryString.set(key, String(value)); } } const qs = queryString.toString(); if (qs) { finalPath = `${target}?${qs}`; } } const windowTarget = params.target === '_self' ? '_self' : '_blank'; logger.log('handleOpenWindow:', { target, params, finalPath, windowTarget }); if (windowTarget === '_self') { window.location.assign(finalPath); } else { window.open(finalPath, '_blank'); } } /** * navigateBack 액션을 처리합니다. * * 브라우저 히스토리에서 뒤로 이동합니다. */ private async handleNavigateBack(): Promise { logger.log('handleNavigateBack'); window.history.back(); } /** * navigateForward 액션을 처리합니다. * * 브라우저 히스토리에서 앞으로 이동합니다. */ private async handleNavigateForward(): Promise { logger.log('handleNavigateForward'); window.history.forward(); } /** * replaceUrl 액션을 처리합니다. * * URL만 변경하고 데이터소스 refetch나 컴포넌트 리마운트를 수행하지 않습니다. * 리스트 항목 선택 시 URL에 상태를 반영할 때 사용합니다. * * @param target 변경할 경로 * @param params 파라미터 (query, mergeQuery) */ private async handleReplaceUrl( target: string, params: Record ): Promise { let finalPath = target; // query 파라미터 처리 (navigate와 동일 로직 — 게이트도 동일하게 유지, @since engine-v1.54.2) if (params.query || params.mergeQuery === true) { if (params.mergeQuery === true) { finalPath = this.buildMergedQueryPath(target, params.query ?? {}); } else { const queryString = new URLSearchParams(); for (const [key, value] of Object.entries(params.query)) { if (value !== undefined && value !== null && value !== '') { if (Array.isArray(value)) { const arrayKey = key.endsWith('[]') ? key : `${key}[]`; for (const item of value) { if (item !== undefined && item !== null && item !== '') { queryString.append(arrayKey, String(item)); } } } else { queryString.set(key, String(value)); } } } const qs = queryString.toString(); if (qs) { finalPath = `${target}?${qs}`; } } } logger.log('handleReplaceUrl:', { target, params, finalPath }); // URL만 변경 (데이터소스 refetch 없음, 컴포넌트 리마운트 없음) window.history.replaceState(null, '', finalPath); // 이동 후 스크롤 위치 적용 (기본: preserve — URL만 교체하는 용도이므로 유지) this.applyScrollOption(params.scroll, params.scrollBehavior, 'preserve'); } /** * 기존 URL의 쿼리 파라미터와 새 파라미터를 병합합니다. * * @param basePath 기본 경로 (쿼리스트링 없이) * @param newParams 병합할 새 파라미터 */ private buildMergedQueryPath( basePath: string, newParams: Record ): string { // 현재 URL의 쿼리 파라미터 가져오기 const currentParams = new URLSearchParams(window.location.search); // 새 파라미터 병합 (기존 값 덮어쓰기) for (const [key, value] of Object.entries(newParams)) { if (value === null || value === undefined || value === '') { // 빈 값이면 파라미터 제거 currentParams.delete(key); } else if (Array.isArray(value)) { // 배열인 경우 기존 값 제거 후 각 요소를 개별 파라미터로 추가 // 키에 []가 없으면 추가하여 Laravel이 배열로 인식하도록 함 const arrayKey = key.endsWith('[]') ? key : `${key}[]`; currentParams.delete(key); currentParams.delete(arrayKey); for (const item of value) { if (item !== undefined && item !== null && item !== '') { currentParams.append(arrayKey, String(item)); } } } else { currentParams.set(key, String(value)); } } // 쿼리스트링 생성 const queryString = currentParams.toString(); // basePath에서 기존 쿼리스트링 제거 const pathWithoutQuery = basePath.split('?')[0]; return queryString ? `${pathWithoutQuery}?${queryString}` : pathWithoutQuery; } /** * CSRF 토큰을 가져옵니다 (Laravel Sanctum). */ private async ensureCsrfToken(): Promise { try { await fetch('/sanctum/csrf-cookie', { credentials: 'include', }); } catch (error) { throw new ActionError( 'Failed to fetch CSRF token', undefined, error instanceof Error ? error : undefined ); } } /** * 쿠키에서 CSRF 토큰을 추출합니다. */ private getCsrfTokenFromCookie(): string | null { const name = 'XSRF-TOKEN'; const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) { const token = parts.pop()?.split(';').shift(); return token ? decodeURIComponent(token) : null; } return null; } /** * 객체를 query string으로 변환합니다. * * 배열은 key[]=value1&key[]=value2 형식으로 직렬화됩니다. * null, undefined, 빈 문자열은 제외됩니다. * * @param obj 변환할 객체 * @returns query string (? 제외) */ private buildQueryString(obj: Record): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(obj)) { if (value === null || value === undefined || value === '') { continue; } if (Array.isArray(value)) { // 배열은 key[]=value 형식으로 추가 for (const item of value) { if (item !== null && item !== undefined && item !== '') { params.append(`${key}[]`, String(item)); } } } else if (typeof value === 'object') { // 중첩 객체는 JSON 문자열로 변환 params.append(key, JSON.stringify(value)); } else { params.append(key, String(value)); } } return params.toString(); } /** * ensureIdentityVerified 액션 — API 호출 전 본인인증을 선제 보장합니다. * * 레이아웃에서 `{ handler: 'ensureIdentityVerified', params: { purpose: '...' } }` 로 호출. * 정책 resolve 는 GET /api/identity/policies/resolve (v1.1) 로 미래 확장 예정. * 현 버전은 POST /api/identity/challenges 로 즉시 challenge 를 시작하고, * IdentityGuardInterceptor.handle 과 동일한 launcher 플로우를 재사용합니다. * * @param params 액션 파라미터 (purpose 필수, target 선택 — 비로그인 흐름의 인증 대상) * @returns 사용자가 verify 에 성공하면 true, 취소 시 false */ private async handleEnsureIdentityVerified(params: Record): Promise { const purpose = typeof params.purpose === 'string' ? params.purpose : 'sensitive_action'; // params.target: 선제 가드 호출에서 흐름이 직접 인증 대상을 선언 (apiCall 의 identity_target 과 동일 채널). const target = params.target && typeof params.target === 'object' ? (params.target as { email?: string; phone?: string }) : undefined; const verified = await IdentityGuardInterceptor.handle( { success: false, error_code: 'identity_verification_required', message: '', verification: { policy_key: (params.policy_key as string) ?? '', purpose, provider_id: (params.provider_id as string) ?? null, render_hint: (params.render_hint as string) ?? null, return_request: null, // pre-emptive 호출은 재실행할 원 요청 없음 }, }, undefined, target ); return verified !== null; } /** * resolveIdentityChallenge 액션 — 본인인증 모달/풀페이지/외부 SDK callback 이 * `IdentityGuardInterceptor` 의 deferred resolver 에 verify 결과를 통보합니다. * * 레이아웃 JSON 호출 예시: * - verify 성공: `{ handler: 'resolveIdentityChallenge', params: { result: 'verified', token: '...' } }` * - 사용자 취소: `{ handler: 'resolveIdentityChallenge', params: { result: 'cancelled' } }` * - verify 실패: `{ handler: 'resolveIdentityChallenge', params: { result: 'failed', failureCode: 'INVALID_CODE' } }` * * 부작용은 인터셉터의 글로벌 deferred resolver 1회 호출뿐입니다. * setState / 렌더 사이클 / 폼 자동바인딩 / localDynamicState 와 무관합니다. * * @param params 결과 정보 (result 필수) * @returns 항상 true — resolver 호출 자체는 실패하지 않으며 실패 정보는 result.status 로 전달됨 * @since engine-v1.46.0 */ private handleResolveIdentityChallenge(params: Record): boolean { const rawResult = typeof params.result === 'string' ? params.result : 'cancelled'; let result: | { status: 'verified'; token: string; providerData?: Record } | { status: 'pending'; pollUrl: string; pollIntervalMs?: number; expiresAt: string } | { status: 'cancelled' } | { status: 'failed'; failureCode: string; reason?: string }; switch (rawResult) { case 'verified': { const token = typeof params.token === 'string' ? params.token : ''; if (!token) { logger.warn( 'resolveIdentityChallenge: result=verified 인데 token 이 비어있습니다. failed 로 강등합니다.' ); result = { status: 'failed', failureCode: 'MISSING_TOKEN' }; break; } result = { status: 'verified', token, providerData: params.providerData && typeof params.providerData === 'object' ? (params.providerData as Record) : undefined, }; break; } case 'pending': { const pollUrl = typeof params.pollUrl === 'string' ? params.pollUrl : ''; const expiresAt = typeof params.expiresAt === 'string' ? params.expiresAt : ''; if (!pollUrl || !expiresAt) { logger.warn( 'resolveIdentityChallenge: result=pending 인데 pollUrl/expiresAt 누락 — failed 로 강등합니다.' ); result = { status: 'failed', failureCode: 'MALFORMED_PENDING' }; break; } result = { status: 'pending', pollUrl, pollIntervalMs: typeof params.pollIntervalMs === 'number' ? params.pollIntervalMs : undefined, expiresAt, }; break; } case 'failed': result = { status: 'failed', failureCode: typeof params.failureCode === 'string' ? params.failureCode : 'UNKNOWN', reason: typeof params.reason === 'string' ? params.reason : undefined, }; break; case 'cancelled': default: result = { status: 'cancelled' }; break; } IdentityGuardInterceptor.resolveDeferred(result); return true; } /** * apiCall 액션을 처리합니다. * * @param target API 엔드포인트 * @param params 요청 파라미터 * @param context 액션 컨텍스트 * @param authMode 인증 모드 ('none' | 'required' | 'optional') * @param identityTarget IDV 428 인터셉트 시 challenge 에 사용할 인증 대상 (이메일·전화) */ private async handleApiCall( target: string, params: Record, _context: ActionContext, authMode: 'none' | 'required' | 'optional' = 'none', identityTarget?: { email?: string; phone?: string } ): Promise { const { method = 'GET', body, headers, contentType } = params; // CSRF token 가져오기 (POST, PUT, DELETE 등) if (method !== 'GET' && method !== 'HEAD') { await this.ensureCsrfToken(); } // CSRF 토큰을 쿠키에서 추출하여 헤더에 추가 const csrfToken = this.getCsrfTokenFromCookie(); // authMode에 따라 Bearer 토큰 추가 // - 'none': 토큰 미포함 // - 'required': 토큰 필수 (토큰이 있으면 포함) // - 'optional': 토큰이 있으면 포함, 없으면 미포함 let authHeader: Record = {}; if (authMode === 'required' || authMode === 'optional') { const apiClient = getApiClient(); const token = apiClient.getToken(); if (token) { authHeader = { Authorization: `Bearer ${token}` }; } } // GET 요청 시 query 또는 body를 query string으로 변환 let finalTarget = target; const queryData = params.query || (method === 'GET' ? body : null); if (queryData && typeof queryData === 'object') { const queryString = this.buildQueryString(queryData); if (queryString) { // target에 이미 query string이 있는 경우 &로 연결, 없으면 ?로 시작 const separator = target.includes('?') ? '&' : '?'; finalTarget = `${target}${separator}${queryString}`; } } // 전역 헤더 추출 (패턴 매칭) // Stale Closure 방지: G7Core.state.getGlobal/getLocal()로 최신 상태 조회 // (cartKey 재발급 등 중간에 상태가 변경된 경우에도 최신 값 사용) const currentGlobalState = (window as any).G7Core?.state?.getGlobal?.() || _context.state?._global || {}; const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || _context.state?._local || {}; const expressionContext: Record = { _global: currentGlobalState, _local: currentLocalState, }; const globalHeadersResolved = this.getMatchingGlobalHeaders(target, expressionContext); // g7_locale이 설정되어 있으면 Accept-Language 헤더로 전송 const localeHeader: Record = {}; if (typeof window !== 'undefined') { const locale = localStorage.getItem('g7_locale'); if (locale) { localeHeader['Accept-Language'] = locale; } } // multipart/form-data 여부 판단 (레이아웃 JSON의 contentType 파라미터 기반) const isMultipart = contentType === 'multipart/form-data'; const options: RequestInit = { method, headers: { // multipart/form-data는 Content-Type을 설정하지 않음 (브라우저가 boundary 포함하여 자동 설정) ...(isMultipart ? {} : { 'Content-Type': 'application/json' }), Accept: 'application/json', ...localeHeader, // g7_locale → Accept-Language ...(csrfToken && { 'X-XSRF-TOKEN': csrfToken }), // CSRF 토큰을 헤더에 포함 ...authHeader, // Bearer 토큰 (auth_required가 true인 경우) ...globalHeadersResolved, // 전역 헤더 (패턴 매칭) ...headers, // 개별 헤더가 globalHeaders를 덮어씀 }, credentials: 'include', // 쿠키 전송 활성화 }; if (body && method !== 'GET') { if (isMultipart) { // multipart/form-data: FormData 객체로 변환 const formData = new FormData(); for (const [key, value] of Object.entries(body)) { if (value instanceof File || value instanceof Blob) { formData.append(key, value); } else if (value !== null && value !== undefined) { formData.append( key, typeof value === 'object' ? JSON.stringify(value) : String(value) ); } } options.body = formData; } else { options.body = JSON.stringify(body); } } // DevTools 요청 추적 시작 const devTools = getDevTools(); let requestId: string | null = null; if (devTools?.isEnabled()) { requestId = devTools.trackRequest(finalTarget, method); } try { let response = await fetch(finalTarget, options); // 응답 본문 파싱 (성공/실패 모두) let responseData: any; try { responseData = await response.json(); } catch { responseData = null; } // HTTP 428 + identity_verification_required → IDV Guard 인터셉트 // Challenge 모달 launcher 호출 후 성공 시 원 요청을 재실행합니다. if (IdentityGuardInterceptor.isIdentityRequired(response.status, responseData)) { // retry fetch 가 원 요청의 body/headers/credentials 를 그대로 재사용해야 백엔드가 // 빈 body 로 422 를 던지지 않음 (회원가입 등 모든 POST 흐름의 핵심 회귀 방지) const replayed = await IdentityGuardInterceptor.handle( responseData, { body: options.body, headers: options.headers, credentials: options.credentials, }, identityTarget ); if (replayed) { response = replayed; try { responseData = await response.json(); } catch { responseData = null; } } } // DevTools 요청 완료 추적 (성공/에러 모두 상태 코드와 응답 기록) if (requestId && devTools?.isEnabled()) { devTools.completeRequest(requestId, response.status, responseData); requestId = null; // 중복 기록 방지 } if (!response.ok) { const errorData = responseData || {}; // API 응답 데이터를 포함한 에러 객체 생성 const apiError: any = new Error( errorData.message || `API call failed: ${response.statusText}` ); apiError.response = errorData; // API 응답 전체를 포함 apiError.status = response.status; apiError.statusText = response.statusText; throw new ActionError( errorData.message || `API call failed: ${response.statusText}`, undefined, apiError ); } return responseData; } catch (error) { // 네트워크 오류 등으로 fetch 자체가 실패한 경우 if (requestId && devTools?.isEnabled()) { devTools.failRequest(requestId, error instanceof Error ? error.message : String(error)); } throw error; } } /** * login 액션을 처리합니다. * * AuthManager를 통해 로그인하고 토큰을 저장합니다. * * @param target 인증 타입 (admin 또는 user) * @param params 요청 파라미터 (body에 email, password 포함) * @param context 액션 컨텍스트 */ private async handleLogin( target: string, params: Record, _context: ActionContext ): Promise { const { body } = params; if (!body || !body.email || !body.password) { throw new ActionError( 'Login requires email and password in body params' ); } const authType = this.resolveAuthType(target); // 로그인 엔드포인트 결정 (globalHeaders 패턴 매칭용) // ApiClient는 baseURL이 '/api'이므로 실제 요청 경로에 '/api' prefix 추가 const loginEndpoint = authType === 'admin' ? '/api/auth/admin/login' : '/api/auth/login'; const authManager = AuthManager.getInstance(); try { // AuthManager.login()을 통해 로그인 및 토큰 저장 // globalHeaders가 있으면 options.headers로 전달 const result = await authManager.login( authType, { email: body.email, password: body.password }, this.buildAuthRequestOptions(loginEndpoint, _context) ); // 2단계 인증이 켜진 사이트에서는 아직 로그인이 끝나지 않았다. 레이아웃이 그 사실을 // 알 통로가 없으면 인증번호 입력 단계로 넘어갈 방법이 없다. if (result.status === 'two_factor_required') { return { user: null, two_factor_required: true, challenge_id: result.challenge.challengeId, provider_id: result.challenge.providerId, expires_at: result.challenge.expiresAt, }; } // `user` 는 종전 계약 그대로 유지한다 — 기존 레이아웃이 `response.user` 를 읽는다. return { user: result.user, two_factor_required: false }; } catch (error: any) { throw this.toLoginActionError(error, 'Login failed'); } } /** * loginTwoFactor 액션을 처리합니다. * * 비밀번호 확인 단계가 돌려준 challenge 와 사용자가 받은 인증번호로 로그인을 완료합니다. * * @param target 인증 타입 (admin 또는 user) * @param params 요청 파라미터 (body에 challenge_id, code 포함) * @param context 액션 컨텍스트 * @since engine-v1.65.0 */ private async handleLoginTwoFactor( target: string, params: Record, context: ActionContext ): Promise { const { body } = params; if (!body || !body.challenge_id || !body.code) { throw new ActionError( 'loginTwoFactor requires challenge_id and code in body params' ); } const authType = this.resolveAuthType(target); const endpoint = authType === 'admin' ? '/api/auth/admin/login/two-factor' : '/api/auth/login/two-factor'; try { const user = await AuthManager.getInstance().completeTwoFactor( authType, { challengeId: String(body.challenge_id), code: String(body.code) }, this.buildAuthRequestOptions(endpoint, context) ); return { user }; } catch (error: any) { throw this.toLoginActionError(error, 'Login failed'); } } /** * loginTwoFactorResend 액션을 처리합니다. * * 서버가 기존 challenge 를 취소하고 새로 발행하므로, 레이아웃은 반환된 새 * `challenge_id` 로 반드시 교체해야 합니다. * * @param target 인증 타입 (admin 또는 user) * @param params 요청 파라미터 (body에 challenge_id 포함) * @param context 액션 컨텍스트 * @since engine-v1.65.0 */ private async handleLoginTwoFactorResend( target: string, params: Record, context: ActionContext ): Promise { const { body } = params; if (!body || !body.challenge_id) { throw new ActionError( 'loginTwoFactorResend requires challenge_id in body params' ); } const authType = this.resolveAuthType(target); const endpoint = authType === 'admin' ? '/api/auth/admin/login/two-factor/resend' : '/api/auth/login/two-factor/resend'; try { const challenge = await AuthManager.getInstance().resendTwoFactor( authType, { challengeId: String(body.challenge_id) }, this.buildAuthRequestOptions(endpoint, context) ); return { two_factor_required: true, challenge_id: challenge.challengeId, provider_id: challenge.providerId, expires_at: challenge.expiresAt, }; } catch (error: any) { throw this.toLoginActionError(error, 'Resend failed'); } } /** * 액션 target 을 인증 타입으로 해석합니다. * * @param target 액션 target * @returns 인증 타입 */ private resolveAuthType(target: string): AuthType { return target === 'user' ? 'user' : 'admin'; } /** * 인증 요청에 실을 globalHeaders 옵션을 만듭니다. * * 세 인증 핸들러가 같은 규칙을 공유하도록 단일 지점에 둔다 — 갈라지면 한 경로에만 * 공통 헤더가 빠져 그 요청만 조용히 다르게 나간다. * * @param endpoint 요청 경로 (globalHeaders 패턴 매칭용, '/api' prefix 포함) * @param context 액션 컨텍스트 * @returns headers 옵션 (매칭된 헤더가 없으면 undefined) */ private buildAuthRequestOptions( endpoint: string, context: ActionContext ): { headers: Record } | undefined { // Stale Closure 방지: G7Core.state.getGlobal/getLocal()로 최신 상태 조회 // (cartKey 재발급 등 중간에 상태가 변경된 경우에도 최신 값 사용) const currentGlobalState = (window as any).G7Core?.state?.getGlobal?.() || context.state?._global || {}; const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || context.state?._local || {}; const expressionContext: Record = { _global: currentGlobalState, _local: currentLocalState, }; const resolved = this.getMatchingGlobalHeaders(endpoint, expressionContext); return Object.keys(resolved).length > 0 ? { headers: resolved } : undefined; } /** * AuthManager 오류를 액션 오류로 재포장합니다. * * `code` 를 보존해야 호출자가 네트워크 실패와 HTTP 오류를 구분해 다국어 문구로 * 안내할 수 있다 — axios 오류는 `TypeError` 가 아니다. * * @param error 원본 오류 * @param fallbackMessage 서버 메시지가 없을 때 쓸 기본 문구 * @returns 액션 오류 */ private toLoginActionError(error: any, fallbackMessage: string): ActionError { // AuthManager에서 이미 API 응답 메시지를 추출한 에러를 throw하므로 // error.message에 실제 서버 응답 메시지가 들어있음 const errorMessage = error?.message || fallbackMessage; const apiError: any = new Error(errorMessage); apiError.response = error?.response?.data || {}; apiError.status = error?.status || error?.response?.status || 500; apiError.code = error?.code; return new ActionError(errorMessage, undefined, apiError); } /** * logout 액션을 처리합니다. * * AuthManager를 통해 로그아웃하고 토큰을 삭제합니다. * * @param target 사용하지 않음 * @param context 액션 컨텍스트 */ private async handleLogout( _target: string, _context: ActionContext ): Promise { const authManager = AuthManager.getInstance(); await authManager.logout(); } /** * setState 액션을 처리합니다. * * target이 'global'이면 전역 상태 업데이트, 그 외에는 컴포넌트 로컬 상태 업데이트 * * 깊은 병합 지원: * - { formData: { name: "value" } } → formData.name만 업데이트, 다른 필드 유지 * - "..." spread 연산자도 여전히 지원 (하위 호환성) * * Dot notation 경로 지원: * - target: "_local.formData.name" → _local.formData.name에 value 설정 * - 부분 업데이트로 다른 필드는 유지됨 * * @param params 상태 업데이트 파라미터 (target, payload 포함) * @param context 액션 컨텍스트 * @returns 병합된 상태 (sequence 핸들러에서 context.state 동기화에 사용) */ private async handleSetState( params: Record, context: ActionContext ): Promise | undefined> { logger.log('[handleSetState] START, params:', params); const { target = 'component', scope, merge, __render, ...payload } = params; // DevTools 추적 시작 const devTools = getDevTools(); const G7Core = (window as any).G7Core; // $parent 또는 $root 타겟 처리 (Phase 4: $parent 바인딩 컨텍스트) // 예: "$parent._local", "$parent._global", "$parent._local.form.name", "$root._local" if (typeof target === 'string' && (target.startsWith('$parent.') || target.startsWith('$root.'))) { return this.handleParentScopeSetState(target, payload, merge, context); } // sequence 내에서 실행될 때는 context.state를 우선 사용 // 이를 통해 비동기 React 상태 업데이트를 기다리지 않고도 // 이전 setState의 결과를 올바르게 참조할 수 있음 const currentState = (context.state && Object.keys(context.state).length > 0) ? context.state : (G7Core?.state?.get() || {}); // 상태 경로 결정 const statePath = target === 'global' ? '_global' : target.startsWith('_local.') ? target : '_local'; // 이전 값 캡처 const oldValue = statePath === '_global' ? currentState._global : statePath.startsWith('_local.') ? this.getNestedProperty(currentState._local || {}, statePath.slice(7)) : currentState._local; // DevTools: 상태 변경 시작 const setStateId = devTools?.isEnabled() ? devTools.startStateChange( statePath, oldValue, payload, { actionId: context.actionId, handlerType: 'setState', source: `target=${target}`, } ) : undefined; // params는 이미 resolveParams에서 처리되었으므로 추가 평가 불필요 // merge 옵션: "replace" | "shallow" | "deep" (기본값) // - replace: 기존 상태 완전 무시, 새 값으로 교체 // - shallow: 최상위 키만 덮어쓰기 (DynamicRenderer에서 처리) // - deep: 재귀적 깊은 병합 (ActionDispatcher에서 수행) const mergeMode: 'replace' | 'shallow' | 'deep' = merge === 'replace' ? 'replace' : merge === 'shallow' ? 'shallow' : 'deep'; const resolvedPayload = mergeMode !== 'deep' ? { ...payload, __mergeMode: mergeMode as 'replace' | 'shallow', ...(setStateId ? { __setStateId: setStateId } : {}) } : { ...payload, ...(setStateId ? { __setStateId: setStateId } : {}) }; if (target === 'global') { // 전역 상태 업데이트 if (!this.globalStateUpdater) { logger.warn('Global state updater is not set'); if (setStateId && devTools) devTools.completeStateChange(setStateId); return undefined; } // 전역 상태는 항상 G7Core.state.get()에서 최신 값을 가져옴 // G7Core.state.get()은 이미 _global의 내용물을 직접 반환함 (templateApp.getGlobalState()) // globalStateUpdater 호출 후 즉시 G7Core.state.get()에 반영되므로 // sequence 내에서도 항상 최신 상태를 참조함 (별도 동기화 불필요) // 주의: currentState._global 병합 시 sequence 내 다른 핸들러(closeModal 등)가 // 변경한 값을 stale 값으로 덮어쓰는 버그 발생 (2026-01-30 수정) const currentGlobal = G7Core?.state?.get() || {}; // dot notation 키 처리를 위해 deepMergeWithState 적용 // __setStateId, __mergeMode는 메타데이터이므로 병합에서 제외 후 다시 추가 const { __setStateId: _setStateId, __mergeMode: _mergeMode, ...payloadWithoutMeta } = resolvedPayload as any; const mergedPayload = mergeMode === 'deep' ? this.deepMergeWithState(payloadWithoutMeta, currentGlobal) : payloadWithoutMeta; // replace, shallow: 병합 없이 payload 그대로 const finalPayload = { ...mergedPayload, ...(setStateId ? { __setStateId: setStateId } : {}), ...(mergeMode !== 'deep' ? { __mergeMode: mergeMode as 'replace' | 'shallow' } : {}), }; logger.log('setState global:', finalPayload); // engine-v1.42.0: __render 옵션 전파 (executeAction에서 action.render → __render로 변환) this.globalStateUpdater(finalPayload, { render: __render }); // DevTools: 상태 변경 완료 (global은 즉시 완료) // Note: 실제 렌더링은 비동기로 발생하므로 setTimeout으로 완료 처리 if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); // 전역 상태 payload를 반환하여 sequence에서 _global 동기화에 사용 // __mergeMode를 포함하여 sequence에서 올바른 병합 전략을 선택할 수 있도록 함 return { __target: 'global', ...(mergeMode !== 'deep' ? { __mergeMode: mergeMode } : {}), ...mergedPayload }; } else if (target === 'isolated') { // 격리된 상태 업데이트 (isolatedState 속성으로 생성된 독립 스코프) const isolatedContext = context.isolatedContext; if (isolatedContext) { // IsolatedStateContext.mergeState를 통해 상태 업데이트 const { __mergeMode, __setStateId, ...cleanPayload } = resolvedPayload as any; isolatedContext.mergeState(cleanPayload, mergeMode); logger.log('[handleSetState] isolated state updated:', cleanPayload, 'mergeMode:', mergeMode); // DevTools: 상태 변경 완료 if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return { __target: 'isolated', ...cleanPayload }; } else { // 폴백: IsolatedStateContext가 없으면 local로 처리 logger.warn( '[handleSetState] isolated target used but no IsolatedStateContext found. ' + 'Make sure the component has "isolatedState" attribute. Falling back to local state.' ); // local 상태로 폴백 if (context.setState) { // engine-v1.17.10: pendingLocal 우선 사용 (Form 자동 바인딩 경합 방지) const pendingLocal = (window as any).__g7PendingLocalState; const currentState = pendingLocal || context.state || {}; const finalPayload = mergeMode === 'deep' ? this.deepMergeWithState(resolvedPayload, currentState) : resolvedPayload; // replace, shallow: DynamicRenderer에서 처리 this.writeLocalState(context, finalPayload); if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return finalPayload; } else { if (setStateId && devTools) devTools.completeStateChange(setStateId); throw new ActionError( 'isolated target used but no IsolatedStateContext found and no setState function in context' ); } } } else if (target === 'local' || target === 'component' || target === '_local') { // 컴포넌트 로컬 상태 업데이트 // merge: "shallow" 옵션이면 얕은 병합 (DynamicRenderer에서 처리) // 기본값은 깊은 병합 (여기서 수행) // scope 옵션: 'parent' 또는 'root'인 경우 레이아웃 컨텍스트 스택에서 타겟 컨텍스트 사용 // 모달 등 별도 스코프에서 부모 레이아웃의 상태를 업데이트할 때 유용 if (scope === 'parent' || scope === 'root') { const layoutContextStack: Array<{ state: Record; setState: (updates: any) => void }> = (window as any).__g7LayoutContextStack || []; if (layoutContextStack.length > 0) { // parent: 스택의 마지막 (바로 이전 컨텍스트) // root: 스택의 첫 번째 (최상위 컨텍스트) const targetContext = scope === 'parent' ? layoutContextStack[layoutContextStack.length - 1] : layoutContextStack[0]; if (targetContext?.setState) { const { __mergeMode, __setStateId, ...cleanPayload } = resolvedPayload as any; const finalPayload = mergeMode === 'deep' ? this.deepMergeWithState(cleanPayload, targetContext.state || {}) : cleanPayload; // replace, shallow: 병합 없이 payload 그대로 logger.log(`[handleSetState] scope=${scope}: 타겟 컨텍스트에 상태 업데이트`, finalPayload); // scope:'parent'|'root' 은 `_local` 정본이 아니라 레이아웃 컨텍스트 스택의 // 부모/루트 슬롯을 노린다. 여기서 저장소 B 를 함께 쓰면 모달의 setState 가 // 페이지 _local 을 오염시킨다(사례 29). // audit:allow local-store-write-must-mirror 부모/루트 슬롯 전용 (위 사유) targetContext.setState(finalPayload); if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return finalPayload; } } // 스택에 컨텍스트가 없으면 경고 후 current로 폴백 logger.warn(`[handleSetState] scope=${scope}: 레이아웃 컨텍스트 스택이 비어있습니다. current로 폴백합니다.`); } // scope: 'current' (기본값) 또는 폴백 // engine-v1.17.2: _isDispatchFallbackContext가 true면 컴포넌트 setState가 아닌 전역 폴백(setGlobalState)임 // 이 경우 context.setState를 호출하면 _global이 업데이트되므로, // globalStateUpdater({ _local: ... }) 경로를 사용해야 _local이 올바르게 업데이트됨 // (비동기 콜백에서 G7Core.dispatch 호출 시 발생하는 문제 해결) const isRealComponentContext = context.setState && !(context as any)._isDispatchFallbackContext; logger.log('[handleSetState] isRealComponentContext:', isRealComponentContext, 'context.setState:', !!context.setState, '_isDispatchFallbackContext:', (context as any)._isDispatchFallbackContext); if (isRealComponentContext) { // 컴포넌트 컨텍스트가 있으면 직접 setState 호출 logger.log('[handleSetState] Using COMPONENT setState path'); logger.log('[handleSetState] resolvedPayload:', resolvedPayload); logger.log('[handleSetState] context.state:', context.state); logger.log('[handleSetState] mergeMode:', mergeMode); // engine-v1.17.10: Form 자동 바인딩 후 onComplete에서 호출 시, // context.state는 아직 이전 값(React setState 비동기). // __g7PendingLocalState에 자동 바인딩이 반영된 최신 상태가 있으므로 우선 사용. const pendingLocal = (window as any).__g7PendingLocalState; const currentState = pendingLocal || context.state || {}; // engine-v1.24.6: context.setState에는 변경 필드만 전달 (전체 _local 축적 방지) // 이전: deep 모드에서 deepMerge(resolvedPayload, currentState) = 전체 _local 스냅샷을 전달 // → localDynamicState에 전체 _local 축적 → SPA 이동 후 stale 필드가 dataContext._local override // 수정: resolvedPayload(변경 필드)만 전달, handleLocalSetState가 effectivePrev와 병합 처리 // 주의: deepMergeWithState({}, payload)로 dot notation 변환 수행 (전체 상태 병합은 하지 않음) const convertedPayload = mergeMode === 'deep' ? this.deepMergeWithState(resolvedPayload, {}) : resolvedPayload; logger.log('[handleSetState] convertedPayload (변경 필드만, dot notation 변환):', convertedPayload); context.setState!(convertedPayload); // __g7PendingLocalState에는 전체 병합 결과 저장 (getLocal() 동기화용) // context.setState와 분리: setState는 변경분만, pending은 전체 상태 const fullMergedState = mergeMode === 'deep' ? this.deepMergeWithState(resolvedPayload, currentState) : mergeMode === 'shallow' ? { ...(context.state || {}), ...resolvedPayload } : resolvedPayload; // replace const { __mergeMode: _pmm, __setStateId: _pssid, ...pendingExpected } = fullMergedState as any; (window as any).__g7PendingLocalState = pendingExpected; logger.log('[handleSetState] __g7PendingLocalState updated:', pendingExpected); // engine-v1.50.0: 컴포넌트 setState(target:"_local")를 canonical source(_global._local)에도 동기화. // // 배경: 이 COMPONENT path 는 context.setState(저장소 A: React localDynamicState)만 갱신하고 // _global._local(저장소 B)은 갱신하지 않았다. Form 자동바인딩은 이미 양쪽을 동기화하지만 // (DynamicRenderer.performStateUpdate → setLocal render:false), 명시적 setState target:"_local" // (필터 라디오/체크박스 등)은 B를 갱신하지 않아 비대칭이 있었다. 그 결과: // - 사용자가 필터 선택 → A만 갱신, B 는 init_actions 기본값 잔존 // - 검색(navigate replace:true) → updateQueryParams refetch → updateTemplateData 가 // currentDataContext._local 을 stale B(_global._local)로 되돌림 → 선택한 필터가 풀림 // - 새로고침은 handleRouteChange 가 query 기반으로 B 를 재구성하므로 정상 // GLOBAL STATE UPDATER path(아래 else if)는 이미 globalStateUpdater 로 B 를 갱신하므로, // COMPONENT path 도 동일하게 맞춰 일관성을 확보한다. // // render: false 로 호출하여 추가 React 렌더를 유발하지 않는다 (값만 저장, setGlobalState 라인 ~3211). // context.setState 가 이미 저장소 A 렌더 1회를 트리거하므로 클릭당 렌더 횟수는 변하지 않는다. // scope: 'parent' | 'root' 및 isolated 타깃은 이 분기(scope:'current', isRealComponentContext)에 // 도달하기 전에 별도 처리되므로 모달 컨텍스트 오염(트러블슈팅 사례 29)에 영향이 없다. // // [안전성 의존 관계 — 엔진 수정 시 함께 검토할 것] // pendingExpected 의 base 는 currentState = (__g7PendingLocalState ?? context.state) 다. // 이 동기화는 B(_global._local)를 patch 머지가 아니라 통째 교체한다(setGlobalState 가 _local 키를 얕은 병합). // setLocal(G7CoreGlobals)이 fresh globalLocal 을 baseline 으로 addMissingLeafKeys 보호를 쓰는 것과 달리, // 여기서는 그 보호가 없으므로 currentState 가 정합한 _local 전체여야 손실이 없다. // 이 정합성은 DynamicRenderer 의 _localInit pending 사전설정 useLayoutEffect(engine-v1.27.0/v1.49.2)에 의존한다: // context.state(=extendedDataContext._local)가 stale dynamicState overlay 로 오염될 수 있는 구간 // (_localInit 미반영 시점)에 __g7PendingLocalState 를 fresh B baseline 으로 미리 채워두므로, // currentState 가 pending(정상)을 우선 사용하여 stale context.state 를 건너뛴다. // 그 사전설정 경로를 변경/제거하면 사례 13/22(stale 배열의 globalLocal 통째 교체 오염) 재발 가능성을 함께 점검해야 한다. // engine-v1.63.3 (공개 이슈 #130): B 동기화의 base 를 A 계열 전체 스냅샷에서 // live B(_global._local) + 변경 키로 바꾼다. // // 위 [안전성 의존 관계] 주석이 전제한 "currentState 가 정합한 _local 전체" 는 성립하지 // 않는 구간이 있다. CKEditor 등 selfManaged 플러그인이 setLocal({render:false}) 로 // B 에만 본문을 쓰면 React 렌더가 일어나지 않아 extendedDataContext useMemo 가 // 재계산되지 않고(deps 에 __g7ForcedLocalFields 가 없다 — window 전역이라 deps 가 될 수 없다), // context.state 는 본문 타이핑 이전 스냅샷으로 고정된다. 그 상태에서 폭 변경 리렌더가 // __g7PendingLocalState 를 null 로 지우면(DynamicRenderer.tsx:1110) currentState 가 // stale A 로 떨어지고, 그것을 B 에 통째로 덮어써 본문이 사라진다. // // 정답 선례는 같은 파일의 dot-notation path(engine-v1.58.2, :4235~)다. 그것이 이미 // "live B base + 변경 키" 로 같은 문제를 풀었고 주석에서 이 COMPONENT path 를 위험으로 // 지목했다. 이번 수정은 그 정책을 이 경로에 맞춘다. // // 제외 조건: // - merge:"replace" — 의도적 리셋이므로 live B 를 base 로 삼지 않는다 (사례 17) // - 모달 컨텍스트 스택이 있음 — 모달의 setState 가 페이지 _local 을 흡수하는 것을 막는다 // (사례 29, setLocal v1.24.7 가드와 동형) // 한계: 스택은 openModal/closeModal 핸들러에서만 push/pop 되므로(:4836·:4911) // setState 플래그로 여는 모달과 G7Core.modal.open() 은 depth 0 으로 보인다. 이는 // v1.24.7 가드가 이미 가진 사각과 동일하며, 그 경우에도 새 동작은 "B 통째 교체" 가 // 아니라 "B 에 병합" 이라 사례 29 대비 악화되지 않는다. // - __templateApp 부재 — 종전 전체 스냅샷 폴백 (v1.50.4 호환) // // merge:"shallow" 는 제외하지 않는다. 현행 shallow 는 pending 을 무시하고 context.state // 만 base 로 쓰므로(:4110) 리프 컴포넌트의 부분 상태가 base 가 되어 오히려 이 결함에 // 더 노출돼 있다. const modalDepth = ((window as any).__g7LayoutContextStack || []).length; let canonicalMerged: Record | undefined; if (this.globalStateUpdater) { const canonicalLocal = modalDepth === 0 ? (window as any).__templateApp?.getGlobalState?.()?._local : undefined; const canUseCanonical = mergeMode !== 'replace' && !!canonicalLocal && typeof canonicalLocal === 'object' && !Array.isArray(canonicalLocal); if (canUseCanonical) { // convertedPayload 를 쓰면 안 된다 — 그것은 빈 base 위의 변환이라 // {"form.title":"X"} → {form:{title:"X"}} 가 되고, 얕게 얹으면 B.form 이 통째 // 교체되어 고치려던 결함을 재생산한다. deepMergeWithState 는 createNestedUpdate 로 // 형제 키를 유지한 채 leaf 만 바꾼다. result={...currentState}(:4521) 로 시작하고 // deepMergeInto 가 참조 동일성 가드(:4608)로 매 레벨 방어 복사하므로 live B 를 // 변이하지 않는다. const merged = mergeMode === 'deep' ? this.deepMergeWithState(resolvedPayload, canonicalLocal as Record) : { ...(canonicalLocal as Record), ...resolvedPayload }; const { __mergeMode: _cmm, __setStateId: _cssid, ...rest } = merged as any; canonicalMerged = rest; } else if (mergeMode !== 'replace') { logger.log('[handleSetState] canonical _local 미사용 → 전체 스냅샷 폴백', { modalDepth, hasTemplateApp: !!(window as any).__templateApp }); } // B 쓰기에도 A 전용 키를 보충한다 — setLocal 선례(G7CoreGlobals 의 // addMissingLeafKeys(globalLocal, dynamicLocal))와 대칭. // addMissingLeafKeys 는 base 에 이미 있는 값을 절대 덮지 않으므로 사례 13/22 위험이 없다. const syncedLocal = canonicalMerged ? addMissingLeafKeys(canonicalMerged, pendingExpected) : pendingExpected; this.globalStateUpdater({ _local: syncedLocal }, { render: false }); logger.log('[handleSetState] _global._local synced (render:false):', syncedLocal); } // engine-v1.17.5: dataKey 자동 바인딩이 있는 컴포넌트에서 setState 핸들러 호출 시 // dynamicState(Form 자동 바인딩)가 stale 값을 가질 수 있음 // extendedDataContext 병합 순서: dataContext._local → dynamicState → __g7ForcedLocalFields // __g7ForcedLocalFields에 업데이트된 필드를 저장하여 최우선으로 적용 // // engine-v1.17.9: resolvedPayload(변경된 필드만) 사용 — finalPayload(전체 상태 스냅샷) 사용 금지 // deep 모드에서 finalPayload = deepMergeWithState(resolvedPayload, currentState) → 전체 상태 포함 // 이 전체 스냅샷을 forcedLocalFields에 넣으면, sequence 내 후속 커스텀 핸들러가 // localDynamicState에 설정한 값을 forcedLocalFields의 stale 값이 덮어쓰는 문제 발생 // (예: selectedOptionItems: [] 가 [newItem]을 덮어씀) const { __mergeMode: _mm, __setStateId: _ssid, ...cleanPayloadForForced } = resolvedPayload as any; if (mergeMode === 'replace') { // replace 모드: 기존 forcedFields 무시, payload만으로 리셋 (window as any).__g7ForcedLocalFields = cleanPayloadForForced; } else { const existingForced = (window as any).__g7ForcedLocalFields || {}; (window as any).__g7ForcedLocalFields = this.deepMergeWithState(cleanPayloadForForced, existingForced); } logger.log('[handleSetState] __g7ForcedLocalFields updated:', cleanPayloadForForced); // DevTools: 렌더링 완료 후 상태 변경 완료 (DynamicRenderer에서 처리되지만 fallback으로 setTimeout 사용) if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); // sequence에서 _local 동기화에 사용하기 위해 __target 마커 추가 // // engine-v1.63.3 (공개 이슈 #130): 반환값도 live B 기반으로 신선화한다. // 요청 body 는 저장소 B 를 읽지 않는다 — handleApiCall 의 getLocal() 은 // getMatchingGlobalHeaders 전용이고, body 는 sequence 의 currentState(= 이 반환값)에서 // 온다(handleSequence:5342·5379·5428, 트러블슈팅 사례 15 계약). 따라서 B 쓰기만 고치면 // B 는 지켜지지만 422 는 그대로 난다. // // base 는 live B(충돌 leaf 는 B 승), extra 는 A 기반 스냅샷에서 B 에 없는 키만 보충 // — 사례 13(engine-v1.41.0) 정책 재사용. React 전용 키(loadingActions, DataGrid 선택 등)를 // 잃지 않는다. __mergeMode 재부착은 handleSequence:5431~5438 계약 보존용. const returnedState = canonicalMerged ? addMissingLeafKeys(canonicalMerged, pendingExpected) : fullMergedState; return { __target: 'local', ...returnedState, ...(mergeMode !== 'deep' ? { __mergeMode: mergeMode } : {}), }; } else if (this.globalStateUpdater) { // init_actions 등에서 componentContext가 없는 경우 globalStateUpdater를 통해 _local 업데이트 logger.log('[handleSetState] Using GLOBAL STATE UPDATER path for _local'); // engine-v1.17.2: sequence 내 연속 setState 시 이전 setState 결과 참조 지원 // globalStateUpdater는 비동기이므로 __g7PendingLocalState를 우선 사용 const pendingLocal = (window as any).__g7PendingLocalState; const currentLocal = pendingLocal || currentState._local || {}; logger.log('[handleSetState] currentLocal (with pending):', currentLocal); const finalLocal = mergeMode === 'deep' ? this.deepMergeWithState(resolvedPayload, currentLocal) : resolvedPayload; // replace, shallow: DynamicRenderer에서 처리 logger.log('[handleSetState] finalLocal (merged):', finalLocal); // engine-v1.42.0: __render 옵션 전파 this.globalStateUpdater({ _local: finalLocal, }, { render: __render }); // engine-v1.17.2: 다음 setState에서 최신 _local 참조 가능하도록 pending 상태 업데이트 (window as any).__g7PendingLocalState = finalLocal; logger.log('[handleSetState] __g7PendingLocalState updated for globalStateUpdater path'); // DevTools: 상태 변경 완료 if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); // sequence에서 _local 동기화에 사용하기 위해 __target 마커 추가 return { __target: 'local', ...finalLocal }; } else { if (setStateId && devTools) devTools.completeStateChange(setStateId); throw new ActionError( 'setState function is not provided in context and globalStateUpdater is not set' ); } } else if (target.startsWith('_local.')) { // Dot notation 경로 지원: _local.formData.name 형식 // _local. 제거 후 경로 파싱 const path = target.slice(7); // "_local." 제거 // __mergeMode, __setStateId를 제외한 payload에서 value 추출 const { __mergeMode, __setStateId, ...cleanPayload } = resolvedPayload as any; const value = cleanPayload.value !== undefined ? cleanPayload.value : Object.values(cleanPayload)[0]; if (context.setState) { // 컴포넌트 컨텍스트가 있으면 직접 setState 호출 // engine-v1.17.10: pendingLocal 우선 사용 (Form 자동 바인딩 경합 방지) const pendingLocal = (window as any).__g7PendingLocalState; const currentState = pendingLocal || context.state || {}; const update = this.createNestedUpdate(path, value, currentState); const mergedState = { ...currentState, ...update }; context.setState(update); // engine-v1.58.2: dot notation 경로도 canonical source(_global._local)에 동기화한다. // // 배경: 이 분기는 context.setState(저장소 A: React localDynamicState)만 갱신하고 // _global._local(저장소 B)은 갱신하지 않았다. 커스텀 핸들러(모듈/플러그인)가 상태를 // 읽는 유일한 공개 통로인 G7Core.state.getLocal() 은 B 를 읽으므로, dot notation 으로 // 기록된 값은 핸들러에게 undefined 로 보였다 (체크아웃 간편결제 선택 → PG 플러그인이 // 선택값을 못 읽어 통합결제창이 열린 결함). target: "local" 형태는 engine-v1.50.0 에서 // 이미 B 를 동기화하므로, 남아 있던 비대칭을 해소한다. // // [동기화 기준을 전체 스냅샷이 아니라 "live B + 변경 키"로 잡은 이유] // COMPONENT path(target:"local")는 currentState(=pending ?? context.state) 전체 스냅샷을 // B 에 통째로 넘긴다. context.state 는 클릭된 리프 컴포넌트의 부분 상태일 수 있어 // B 의 다른 키를 잃을 수 있고(engine-v1.50.0 주석의 안전성 의존 관계), 앞선 // 커스텀 핸들러 setLocal 결과를 stale base 로 되돌릴 수 있다(트러블슈팅 사례 24). // 여기서는 live B 를 base 로 삼고 변경된 최상위 키만 얹으므로 두 위험이 모두 없다. // B 를 못 읽는 환경(테스트 등 __templateApp 부재)에서만 전체 스냅샷으로 폴백한다. // // [__g7PendingLocalState 를 쓰지 않는 이유] // pending 에 B 기반 전체 스냅샷을 넣으면 handleLocalSetState 의 effectivePrev 병합에서 // React 전용 상태(DataGrid expandedRows 등)를 초기값으로 덮어쓴다(트러블슈팅 사례 22). // setGlobalState 는 globalState 를 동기 대입하므로 pending 없이도 같은 tick 의 // getLocal() 이 즉시 최신값을 읽는다 — 오염 경로에 진입할 이유가 없다. // // scope: 'parent' | 'root' 은 이 분기가 구현하지 않는 타깃이므로 동기화 대상에서 제외한다 // (모달에서 부모 스코프를 노린 setState 가 페이지 저장소를 오염시키는 것 방지 — 사례 29). if (this.globalStateUpdater && scope !== 'parent' && scope !== 'root') { const canonicalLocal = (window as any).__templateApp?.getGlobalState?.()?._local; const syncedLocal = canonicalLocal && typeof canonicalLocal === 'object' ? { ...canonicalLocal, ...update } : mergedState; this.globalStateUpdater({ _local: syncedLocal }, { render: false }); logger.log('[handleSetState] _global._local synced for dot notation (render:false):', syncedLocal); } if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return mergedState; } else { if (setStateId && devTools) devTools.completeStateChange(setStateId); throw new ActionError( 'setState function is not provided in context' ); } } else { // 컴포넌트 로컬 상태 업데이트 (기본) // merge: "shallow" 옵션이면 얕은 병합, 기본값은 깊은 병합 if (!context.setState) { if (setStateId && devTools) devTools.completeStateChange(setStateId); throw new ActionError( 'setState function is not provided in context' ); } // engine-v1.17.10: pendingLocal 우선 사용 (Form 자동 바인딩 경합 방지) const pendingLocal = (window as any).__g7PendingLocalState; const currentState = pendingLocal || context.state || {}; const finalPayload = mergeMode === 'deep' ? this.deepMergeWithState(resolvedPayload, currentState) : resolvedPayload; // replace, shallow: DynamicRenderer에서 처리 this.writeLocalState(context, finalPayload, undefined, { scope }); // DevTools: 상태 변경 완료 if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return finalPayload; } } /** * $parent 또는 $root 스코프의 상태 업데이트를 처리합니다. * * 모달 등 자식 레이아웃에서 부모 레이아웃의 상태를 직접 수정할 때 사용합니다. * `__g7LayoutContextStack`에서 부모/루트 컨텍스트를 가져와 상태를 업데이트합니다. * * @param target 타겟 문자열 (예: "$parent._local", "$parent._global", "$parent._local.form.name") * @param payload 업데이트할 데이터 * @param merge 병합 모드 ('shallow' | undefined) * @param context 현재 액션 컨텍스트 * @returns 업데이트된 상태 또는 undefined * * @example * // 부모의 _local 전체 업데이트 * { "handler": "setState", "params": { "target": "$parent._local", "form": { "name": "value" } } } * * @example * // 부모의 _local 내 특정 경로 업데이트 * { "handler": "setState", "params": { "target": "$parent._local.form.name", "value": "newValue" } } * * @example * // 부모의 _global 업데이트 * { "handler": "setState", "params": { "target": "$parent._global", "modalResult": { "confirmed": true } } } * * @since engine-v1.16.0 */ private async handleParentScopeSetState( target: string, payload: Record, merge: string | undefined, context: ActionContext ): Promise | undefined> { const devTools = getDevTools(); const G7Core = (window as any).G7Core; const mergeMode: 'replace' | 'shallow' | 'deep' = merge === 'replace' ? 'replace' : merge === 'shallow' ? 'shallow' : 'deep'; // 타겟 파싱: "$parent._local" → scope='$parent', stateType='_local', path=undefined // "$parent._local.form.name" → scope='$parent', stateType='_local', path='form.name' const isParent = target.startsWith('$parent.'); const scopePrefix = isParent ? '$parent.' : '$root.'; const afterScope = target.slice(scopePrefix.length); // "_local" 또는 "_local.form.name" 또는 "_global" let stateType: '_local' | '_global'; let nestedPath: string | undefined; if (afterScope === '_local' || afterScope === '_global') { stateType = afterScope as '_local' | '_global'; nestedPath = undefined; } else if (afterScope.startsWith('_local.')) { stateType = '_local'; nestedPath = afterScope.slice(7); // "_local." 제거 } else if (afterScope.startsWith('_global.')) { stateType = '_global'; nestedPath = afterScope.slice(8); // "_global." 제거 } else { logger.warn(`[handleParentScopeSetState] 지원하지 않는 타겟 형식: ${target}. _local 또는 _global으로 시작해야 합니다.`); return undefined; } // 레이아웃 컨텍스트 스택에서 부모/루트 컨텍스트 가져오기 const layoutContextStack: Array<{ state: Record; setState: (updates: any) => void; dataContext?: Record; }> = (window as any).__g7LayoutContextStack || []; if (layoutContextStack.length === 0) { logger.warn(`[handleParentScopeSetState] 레이아웃 컨텍스트 스택이 비어있습니다. target=${target}`); return undefined; } // parent: 스택의 마지막 (바로 이전 컨텍스트) // root: 스택의 첫 번째 (최상위 컨텍스트) const targetContext = isParent ? layoutContextStack[layoutContextStack.length - 1] : layoutContextStack[0]; if (!targetContext) { logger.warn(`[handleParentScopeSetState] 타겟 컨텍스트를 찾을 수 없습니다. target=${target}`); return undefined; } // DevTools 추적 const oldValue = stateType === '_global' ? targetContext.state?._global : targetContext.state?._local; const setStateId = devTools?.isEnabled() ? devTools.startStateChange( target, oldValue, payload, { actionId: (context as any).actionId, handlerType: 'setState', source: `target=${target}`, } ) : undefined; // 메타데이터 제거 const { __mergeMode, __setStateId, ...cleanPayload } = payload as any; if (stateType === '_global') { // $parent._global 또는 $root._global 업데이트 // 전역 상태는 globalStateUpdater를 통해 업데이트 if (!this.globalStateUpdater) { logger.warn('[handleParentScopeSetState] globalStateUpdater가 설정되지 않았습니다.'); if (setStateId && devTools) devTools.completeStateChange(setStateId); return undefined; } const currentGlobal = G7Core?.state?.get() || {}; if (nestedPath) { // $parent._global.path.to.field 형식 - 중첩 경로 업데이트 const value = cleanPayload.value !== undefined ? cleanPayload.value : Object.values(cleanPayload)[0]; const nestedUpdate = this.createNestedUpdate(nestedPath, value, currentGlobal); const finalPayload = { ...currentGlobal, ...nestedUpdate }; logger.log(`[handleParentScopeSetState] ${target}: 전역 상태 중첩 경로 업데이트`, finalPayload); this.globalStateUpdater(finalPayload); } else { // $parent._global 형식 - 전체 병합 const finalPayload = mergeMode === 'deep' ? this.deepMergeWithState(cleanPayload, currentGlobal) : cleanPayload; // replace, shallow: 병합 없이 payload 그대로 logger.log(`[handleParentScopeSetState] ${target}: 전역 상태 업데이트 (mergeMode=${mergeMode})`, finalPayload); this.globalStateUpdater(mergeMode !== 'deep' ? { ...finalPayload, __mergeMode: mergeMode } : finalPayload); } if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return { __target: target, ...cleanPayload }; } else { // $parent._local 또는 $root._local 업데이트 if (!targetContext.setState) { logger.warn(`[handleParentScopeSetState] 타겟 컨텍스트에 setState가 없습니다. target=${target}`); if (setStateId && devTools) devTools.completeStateChange(setStateId); return undefined; } const currentLocal = targetContext.state || {}; if (nestedPath) { // $parent._local.path.to.field 형식 - 중첩 경로 업데이트 const value = cleanPayload.value !== undefined ? cleanPayload.value : Object.values(cleanPayload)[0]; const update = this.createNestedUpdate(nestedPath, value, currentLocal); const mergedState = { ...currentLocal, ...update }; logger.log(`[handleParentScopeSetState] ${target}: 로컬 상태 중첩 경로 업데이트`, update); // $parent/$root 스코프는 `_local` 정본이 아니라 레이아웃 컨텍스트 스택의 부모/루트 // 슬롯을 대상으로 한다. 저장소 B 를 함께 쓰면 모달의 setState 가 페이지 _local 을 // 오염시킨다(사례 29). 스택 state·dataContext 동기화는 바로 아래에서 직접 수행한다. // audit:allow local-store-write-must-mirror 부모/루트 슬롯 전용 (위 사유) targetContext.setState(update); // 스택의 state와 dataContext._local 모두 업데이트하여 // 다음 setState 호출 시 currentLocal이 최신 값을 읽고, // 모달에서 $parent._local 바인딩이 최신 값을 읽도록 함 targetContext.state = mergedState; if (targetContext.dataContext) { targetContext.dataContext._local = mergedState; } // ParentContextProvider를 통해 모달만 선택적으로 리렌더링 // 전체 앱 리렌더링 없이 모달만 업데이트됨 triggerModalParentUpdate(); if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return mergedState; } else { // $parent._local 형식 - 전체 병합 // DynamicRenderer.handleLocalSetState에서 __mergeMode를 참조하여 병합 처리 const setStatePayload = mergeMode !== 'deep' ? { ...cleanPayload, __mergeMode: mergeMode } : cleanPayload; // 실제 상태 결과 계산 (스택 동기화용, 메타데이터 없음) const expectedState = mergeMode === 'replace' ? cleanPayload : mergeMode === 'shallow' ? { ...currentLocal, ...cleanPayload } : this.deepMergeWithState(cleanPayload, currentLocal); logger.log(`[handleParentScopeSetState] ${target}: 로컬 상태 업데이트 (mergeMode=${mergeMode})`, expectedState); // $parent/$root 스코프는 `_local` 정본이 아니라 레이아웃 컨텍스트 스택의 부모/루트 // 슬롯을 대상으로 한다. 저장소 B 를 함께 쓰면 모달의 setState 가 페이지 _local 을 // 오염시킨다(사례 29). 스택 state·dataContext 동기화는 바로 아래에서 직접 수행한다. // audit:allow local-store-write-must-mirror 부모/루트 슬롯 전용 (위 사유) targetContext.setState(setStatePayload); // 스택의 state와 dataContext._local 모두 업데이트하여 // 다음 setState 호출 시 currentLocal이 최신 값을 읽고, // 모달에서 $parent._local 바인딩이 최신 값을 읽도록 함 targetContext.state = expectedState; if (targetContext.dataContext) { targetContext.dataContext._local = expectedState; } // ParentContextProvider를 통해 모달만 선택적으로 리렌더링 // 전체 앱 리렌더링 없이 모달만 업데이트됨 triggerModalParentUpdate(); if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0); return expectedState; } } } /** * payload를 현재 상태와 깊은 병합합니다. * * 중첩 객체의 경우 기존 상태의 다른 필드를 유지하면서 지정된 필드만 업데이트합니다. * 단, 'errors' 키는 항상 완전히 교체됩니다 (validation 에러는 병합하면 안됨). * * @param payload 업데이트할 데이터 * @param currentState 현재 상태 * @returns 병합된 업데이트 객체 * * @example * // currentState: { formData: { name: "old", email: "a@b.com" }, hasChanges: false } * // payload: { formData: { name: "new" }, hasChanges: true } * // result: { formData: { name: "new", email: "a@b.com" }, hasChanges: true } * * @example errors는 완전 교체 * // currentState: { errors: { name: ["필수"], email: ["필수"] } } * // payload: { errors: { email: ["필수"] } } * // result: { errors: { email: ["필수"] } } -- name 에러 제거됨 */ private deepMergeWithState( payload: Record, currentState: Record ): Record { // 깊은 병합에서 제외할 키 목록 (완전 교체되어야 하는 필드들) const replaceOnlyKeys = ['errors']; // '...' 키가 있으면 먼저 spread 처리 let processedPayload = payload; if ('...' in payload) { const spreadValue = payload['...']; if (spreadValue && typeof spreadValue === 'object' && !Array.isArray(spreadValue)) { // spread 값을 먼저 펼치고, 나머지 키를 덮어쓰기 const { '...': _, ...rest } = payload; processedPayload = { ...spreadValue, ...rest }; } else { // spread 값이 유효하지 않으면 '...' 키만 제거 const { '...': _, ...rest } = payload; processedPayload = rest; } } // 기존 상태를 기반으로 시작 (모든 기존 필드 유지) const result: Record = { ...currentState }; for (const [key, value] of Object.entries(processedPayload)) { // errors 등 특정 키는 항상 완전히 교체 (validation 에러 등은 병합하면 안됨) if (replaceOnlyKeys.includes(key)) { result[key] = value; continue; } // Dot notation 키 처리: "form.language_currency.currencies" 형태 // 단, spread 연산자 '...'는 제외 if (key.includes('.') && key !== '...') { // 중요: 여러 dot notation 키가 같은 루트를 공유할 때 (예: form.a, form.b) // 이전 키의 변경사항이 유지되도록 result를 기준으로 nestedUpdate 생성 const nestedUpdate = this.createNestedUpdate(key, value, result); // 결과를 깊은 병합 this.deepMergeInto(result, nestedUpdate, result); continue; } // File, Blob, Date 등 non-plain 객체는 재귀 병합하지 않고 직접 할당 // (spread 연산자로 복사하면 내부 데이터가 소실됨) if ( value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null ) { result[key] = value; continue; } // 값이 일반 객체이고, 현재 상태에도 해당 키가 객체로 존재하면 재귀적 깊은 병합 if ( value !== null && typeof value === 'object' && !Array.isArray(value) && currentState[key] !== null && typeof currentState[key] === 'object' && !Array.isArray(currentState[key]) ) { // 중첩 객체: 재귀적으로 깊은 병합 수행 result[key] = this.deepMergeWithState(value, currentState[key]); } else { // 기본값, 배열, null 등: 그대로 덮어쓰기 result[key] = value; } } return result; } /** * source 객체를 target 객체에 깊은 병합합니다 (in-place). * * @param target 병합 대상 객체 * @param source 병합할 소스 객체 * @param currentState 현재 상태 (깊은 병합 시 기존 값 참조용) */ private deepMergeInto( target: Record, source: Record, currentState: Record ): void { for (const [key, value] of Object.entries(source)) { // value가 null이면 그대로 덮어쓰기 (null로 초기화하는 경우) if (value === null) { target[key] = value; continue; } // target[key]가 null이면 source 값으로 교체 if (target[key] === null) { target[key] = value; continue; } if ( typeof value === 'object' && !Array.isArray(value) && target[key] !== undefined && target[key] !== null && typeof target[key] === 'object' && !Array.isArray(target[key]) ) { // 원본 상태와 같은 참조면 shallow copy 후 병합 (원본 변이 방지) if (target[key] === currentState[key]) { target[key] = { ...target[key] }; } // 두 값이 모두 객체인 경우 재귀적으로 병합 this.deepMergeInto(target[key], value, currentState[key] || {}); } else if ( typeof value === 'object' && !Array.isArray(value) && target[key] === undefined && currentState[key] !== null && typeof currentState[key] === 'object' && !Array.isArray(currentState[key]) ) { // target에 없지만 currentState에 있으면 currentState와 병합 target[key] = { ...currentState[key], ...value }; } else { target[key] = value; } } } /** * dot notation 경로를 기반으로 중첩 업데이트 객체를 생성합니다. * * 부분 업데이트를 수행하여 다른 필드는 유지합니다. * * @param path dot notation 경로 (예: "formData.name") * @param value 설정할 값 * @param currentState 현재 상태 * @returns 업데이트 객체 * * @example * createNestedUpdate("formData.name", "John", { formData: { name: "", email: "" } }) * // 결과: { formData: { name: "John", email: "" } } */ private createNestedUpdate( path: string, value: any, currentState: Record ): Record { const keys = path.split('.'); if (keys.length === 1) { // 단일 키인 경우 return { [keys[0]]: value }; } // 중첩 경로인 경우: 깊은 복사 후 부분 업데이트 const result: Record = {}; let current = result; let stateCursor: Record | null | undefined = currentState; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; // 현재 상태의 해당 키 값을 얕은 복사 // stateCursor가 null이거나 객체가 아닌 경우 빈 객체로 처리 const stateValue: unknown = stateCursor?.[key]; const isPlainObject = stateValue !== null && stateValue !== undefined && typeof stateValue === 'object' && !Array.isArray(stateValue); if (isPlainObject) { current[key] = { ...(stateValue as Record) }; } else { current[key] = {}; } current = current[key]; // stateCursor 업데이트: 객체인 경우만 진행, 아니면 빈 객체로 stateCursor = isPlainObject ? (stateValue as Record) : {}; } // 마지막 키에 값 설정 current[keys[keys.length - 1]] = value; return result; } /** * dot notation 경로에서 중첩 객체를 생성합니다. * * resultTo 패턴에서 핸들러 결과를 상태에 저장할 때 사용합니다. * createNestedUpdate와 달리 현재 상태 없이 순수하게 중첩 객체만 생성합니다. * * @param path dot notation 경로 (예: "productColumns", "calculatedPrices.123") * @param value 저장할 값 * @returns 중첩 객체 * * @example * buildNestedUpdate("productColumns", [...]) * // 결과: { productColumns: [...] } * * buildNestedUpdate("calculatedPrices.123", { KRW: 1000 }) * // 결과: { calculatedPrices: { "123": { KRW: 1000 } } } */ private buildNestedUpdate(path: string, value: any): Record { const keys = path.split('.'); if (keys.length === 1) { return { [keys[0]]: value }; } // 중첩 경로인 경우 객체 생성 const result: Record = {}; let current = result; for (let i = 0; i < keys.length - 1; i++) { current[keys[i]] = {}; current = current[keys[i]]; } current[keys[keys.length - 1]] = value; return result; } /** * setError 액션을 처리합니다. * * 컴포넌트의 에러 상태를 설정합니다. * - target이 {{}} 바인딩이면: context.data에서 값 추출 (예: {{error.message}}) * - target이 $t:로 시작하면: 다국어 키로 번역 * - 그 외: 문자열 그대로 사용 * * params.stateTarget으로 에러를 저장할 상태 스코프를 지정할 수 있습니다: * - 'local' (기본값): _local.apiError에 저장 * - 'global': _global.apiError에 저장 * - 'isolated': _isolated.apiError에 저장 * * @param target 에러 메시지, 다국어 키, 또는 바인딩 표현식 * @param params 추가 파라미터 (stateTarget: 'local' | 'global' | 'isolated') * @param context 액션 컨텍스트 */ private async handleSetError( target: string, params: any, context: ActionContext ): Promise { let errorMessage = target; // {{}} 바인딩 처리 (예: {{error.message}}, {{response.error}}) errorMessage = this.resolveValue(errorMessage, context.data); // $t: 다국어 구문 처리 if (this.translationEngine && this.translationContext && errorMessage.startsWith('$t:')) { errorMessage = this.translationEngine.resolveTranslations( errorMessage, this.translationContext, context.data ); } // stateTarget에 따라 에러 저장 위치 결정 const stateTarget = params?.stateTarget || 'local'; if (stateTarget === 'isolated' && context.isolatedContext) { // 격리된 상태에 저장 context.isolatedContext.mergeState({ apiError: errorMessage }); logger.log('[handleSetError] Error set to isolated state:', errorMessage); } else if (stateTarget === 'global' && this.globalStateUpdater) { // 전역 상태에 저장 this.globalStateUpdater({ apiError: errorMessage }); logger.log('[handleSetError] Error set to global state:', errorMessage); } else if (context.setState) { // 로컬 상태에 저장 (기본값) this.writeLocalState(context, { apiError: errorMessage }); logger.log('[handleSetError] Error set to local state:', errorMessage); } else { logger.warn('[handleSetError] Cannot set error: no state updater available'); } } /** * openModal 액션을 처리합니다. * * 모달 스택에 모달 ID를 추가하여 모달을 엽니다. * 멀티 모달(중첩 모달)을 지원합니다. * * @param target 모달 ID * @param context 액션 컨텍스트 */ private async handleOpenModal( target: string, context: ActionContext ): Promise { if (!this.globalStateUpdater) { logger.warn('Global state updater is not set for openModal'); return; } // 현재 모달 스택 가져오기 const currentStack = context.data?._global?.modalStack || []; // 이미 열려있는 모달이면 스택에 추가하지 않음 if (currentStack.includes(target)) { logger.warn(`Modal "${target}" is already open`); return; } // 레이아웃 컨텍스트 스택에 현재 컨텍스트 push (scope: 'parent' 및 $parent 바인딩 지원용) // 모달에서 부모 레이아웃의 상태에 접근할 수 있도록 함 // // context.setState/context.state가 없는 경우 (DataSourceManager onSuccess 등): // G7Core.state.getLocal()로 현재 상태를 가져오고 globalStateUpdater를 통한 proxy setState 사용 const G7Core = (window as any).G7Core; const hasDirectContext = context.setState && context.state !== undefined; const fallbackState = !hasDirectContext ? (G7Core?.state?.getLocal?.() || {}) : undefined; const hasFallbackContext = !hasDirectContext && fallbackState !== undefined && this.globalStateUpdater; if (hasDirectContext || hasFallbackContext) { const layoutContextStack: Array<{ state: Record; setState: (updates: any) => void; dataContext?: Record; // $parent 바인딩용 전체 데이터 컨텍스트 }> = (window as any).__g7LayoutContextStack || []; // 상태와 setState 결정 const effectiveState = hasDirectContext ? context.state : fallbackState!; const effectiveSetState = hasDirectContext ? context.setState! : (updates: any) => { // DataSourceManager onSuccess 등에서 context.setState가 없는 경우 // globalStateUpdater를 통해 _local 업데이트 this.globalStateUpdater!({ _local: updates }); }; layoutContextStack.push({ state: effectiveState, setState: effectiveSetState, // $parent 바인딩을 위해 필요한 상태만 저장 (API 응답 데이터 제외) // context.data에 포함된 product_labels 등 대량 API 데이터는 복사하지 않음 // 이를 통해 메모리 사용량을 크게 줄임 (120MB → 수 KB) // sequence 내에서 setState 후 호출 시 context.state에 최신 _local 상태가 있음 // context.state를 우선 사용하여 stale closure 문제 방지 dataContext: { _local: effectiveState || context.data?._local, // hasDirectContext: 기존 동작 유지 (context.data?._global만 사용) // hasFallbackContext: context.data?._global이 없으므로 G7Core.state.get() 사용 _global: hasDirectContext ? context.data?._global : (context.data?._global || G7Core?.state?.get?.()), _computed: context.data?._computed, }, }); (window as any).__g7LayoutContextStack = layoutContextStack; logger.log(`[handleOpenModal] 레이아웃 컨텍스트 스택에 push, 스택 크기: ${layoutContextStack.length}, state:`, effectiveState, hasDirectContext ? '(direct)' : '(fallback via G7Core)'); } else { logger.warn(`[handleOpenModal] 컨텍스트 스택에 push 실패 - setState: ${!!context.setState}, state: ${context.state !== undefined}, G7Core: ${!!G7Core?.state?.getLocal}`); } // 모달 스택에 새 모달 추가 (스택 방식으로 중첩 지원) const newStack = [...currentStack, target]; this.globalStateUpdater({ modalStack: newStack, // 하위 호환성을 위해 activeModal도 유지 (최상위 모달) activeModal: target, }); // DevTools: 모달 열림 추적 const devTools = getDevTools(); if (devTools?.isEnabled()) { const parentModalId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined; devTools.trackModalOpen?.({ modalId: target, modalName: target, scopeType: 'isolated', parentModalId, initialState: context.state ? { ...context.state } : {}, }); } } /** * closeModal 액션을 처리합니다. * * 모달 스택에서 최상위 모달을 제거합니다. * 스택이 비어있지 않으면 이전 모달이 다시 표시됩니다. * * @param context 액션 컨텍스트 (선택적) */ private async handleCloseModal(context?: ActionContext): Promise { if (!this.globalStateUpdater) { logger.warn('Global state updater is not set for closeModal'); return; } // 현재 모달 스택 가져오기 const currentStack = context?.data?._global?.modalStack || []; // 닫히는 모달 ID (스택의 마지막 항목) const closingModalId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : null; // 스택에서 최상위 모달 제거 const newStack = currentStack.slice(0, -1); // 스택이 비어있으면 activeModal도 null로 설정 const newActiveModal = newStack.length > 0 ? newStack[newStack.length - 1] : null; // 레이아웃 컨텍스트 스택에서 pop (scope: 'parent' 및 $parent 바인딩 지원용) const layoutContextStack: Array<{ state: Record; setState: (updates: any) => void; dataContext?: Record; }> = (window as any).__g7LayoutContextStack || []; if (layoutContextStack.length > 0) { // 메모리 정리: pop 전에 dataContext 참조 해제 const poppedContext = layoutContextStack[layoutContextStack.length - 1]; if (poppedContext) { poppedContext.dataContext = undefined; } layoutContextStack.pop(); (window as any).__g7LayoutContextStack = layoutContextStack; logger.log(`[handleCloseModal] 레이아웃 컨텍스트 스택에서 pop, 스택 크기: ${layoutContextStack.length}`); } // DevTools: 모달 닫힘 추적 if (closingModalId) { const devTools = getDevTools(); if (devTools?.isEnabled()) { devTools.trackModalClose?.(closingModalId, context?.state); } } this.globalStateUpdater({ modalStack: newStack, activeModal: newActiveModal, }); } /** * showAlert 액션을 처리합니다. * * @param target 알림 메시지 * @param context 액션 컨텍스트 */ private async handleShowAlert( target: string, context: ActionContext ): Promise { let message = target; // $t: 다국어 구문 처리 if (this.translationEngine && this.translationContext && target.startsWith('$t:')) { message = this.translationEngine.resolveTranslations( target, this.translationContext, context.data ); } alert(message); } /** * toast 액션을 처리합니다. * * 토스트 알림을 표시하며, 여러 토스트가 스택으로 쌓입니다. * * @param params 토스트 파라미터 (type, message, icon, duration) * @param context 액션 컨텍스트 */ private async handleToast( params: Record, context: ActionContext ): Promise { const { type = 'info', message, icon, duration } = params; // [IDV 가드 토스트 중복 억제] // 본인확인은 성공했으나 부가 목적(성인인증 등)을 충족하지 못해 challenge 가 실패로 끝나면, // provider 가 "성인 인증이 필요합니다" 같은 고유 사유를 이미 토스트로 표출한다. 그 직후 // 원 요청의 onError 가 generic IDV 가드 토스트("본인 확인이 필요합니다")를 중복 발화하는데, // 이 토스트가 (1) error 타입이고 (2) 원인이 IDV 가드 응답(error_code) 이며 // (3) provider 가 도메인 안내 표출 신호를 남긴 경우 1회 skip 한다. // 일반 본인인증 실패(본인확인 자체 실패/취소)는 신호가 없어 그대로 표출된다. if (type === 'error') { const errorCtx = (context.data as Record | undefined)?.error as | { error_code?: string; data?: { error_code?: string } } | undefined; const isIdentityGuardError = errorCtx?.error_code === 'identity_verification_required' || errorCtx?.data?.error_code === 'identity_verification_required'; if (isIdentityGuardError && IdentityGuardInterceptor.consumeDomainNoticeShown()) { logger.log('[Toast] IDV 도메인 안내 표출됨 — 중복 가드 토스트 1건 skip'); return; } } let resolvedMessage = message; // $t: 다국어 구문 처리 if (this.translationEngine && this.translationContext && message?.startsWith('$t:')) { resolvedMessage = this.translationEngine.resolveTranslations( message, this.translationContext, context.data ); } // 전역 상태에 토스트 메시지 추가 (배열로 스택 관리) if (this.globalStateUpdater) { const toastId = `toast_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; const newToast = { id: toastId, type, message: resolvedMessage, ...(icon && { icon }), ...(duration && { duration }), }; // 현재 toasts 배열 가져오기 (G7Core.state.get() 사용) const G7Core = (window as any).G7Core; const currentState = G7Core?.state?.get(); const currentToasts = currentState?.toasts || []; // 기존 toasts 배열에 새 토스트 추가 const newToasts = [...currentToasts, newToast]; this.globalStateUpdater({ toasts: newToasts, }); } else { // globalStateUpdater가 없으면 콘솔에 출력 logger.log(`[Toast ${type}] ${resolvedMessage}`); } } /** * switch 액션을 처리합니다. * * 두 가지 방식으로 케이스 키를 결정합니다: * 1. params.value가 지정된 경우: 해당 값을 케이스 키로 사용 (데이터 바인딩 지원) * 2. params.value가 없는 경우: $args[0] 값을 케이스 키로 사용 (기존 방식) * * 매칭되는 케이스가 없으면 "default" 케이스를 찾아 실행합니다. * * @example params.value 방식 (플러그인 환경설정 기반 분기) * ```json * { * "type": "click", * "handler": "switch", * "params": { * "value": "{{_global.plugins['sirsoft-daum_postcode']?.display_mode ?? 'layer'}}" * }, * "cases": { * "popup": { "handler": "callExternal", "params": { ... } }, * "layer": { "handler": "callExternalEmbed", "params": { ... } }, * "default": { "handler": "toast", "params": { "message": "Unknown mode" } } * } * } * ``` * * @example $args[0] 방식 (DataGrid 행 액션) * ```json * { * "event": "onRowAction", * "type": "action", * "handler": "switch", * "cases": { * "view": { "handler": "navigate", "params": { "path": "/users/{{$args[1].id}}" } }, * "edit": { "handler": "navigate", "params": { "path": "/users/{{$args[1].id}}/edit" } }, * "delete": { "handler": "openModal", "target": "delete_modal" } * } * } * ``` * * @param action 액션 정의 (cases 포함) * @param context 액션 컨텍스트 */ private async handleSwitch( action: ActionDefinition, context: ActionContext ): Promise { const { cases } = action; if (!cases) { logger.warn('switch handler requires cases property'); return; } // 파라미터 바인딩 (params.value에 데이터 바인딩 적용) const resolvedParams = this.resolveParams(action.params, context.data); // 케이스 키 결정: params.value가 있으면 사용, 없으면 $args[0] 사용 let caseKey: any; if (resolvedParams?.value !== undefined) { caseKey = resolvedParams.value; logger.log('switch handler: using params.value as case key:', caseKey); } else { caseKey = context.data?.$args?.[0]; logger.log('switch handler: using $args[0] as case key:', caseKey); } if (caseKey === undefined || caseKey === null) { // default 케이스 확인 if (cases['default']) { logger.log('switch handler: case key is undefined, using default case'); return await this.executeAction(cases['default'], context); } logger.warn('switch handler: case key is not defined and no default case'); return; } // 케이스 키를 문자열로 변환 const caseKeyStr = String(caseKey); // 해당 케이스의 액션 찾기 let caseAction = cases[caseKeyStr]; // 매칭되는 케이스가 없으면 default 케이스 사용 if (!caseAction && cases['default']) { logger.log(`switch handler: no case found for key "${caseKeyStr}", using default case`); caseAction = cases['default']; } if (!caseAction) { logger.warn(`switch handler: no case found for key "${caseKeyStr}" and no default case`); return; } // 케이스 액션 실행 return await this.executeAction(caseAction, context); } /** * conditions 액션을 처리합니다. * * if/else if/else 체인을 통해 조건부 액션 실행을 지원합니다. * 첫 번째 매칭되는 브랜치의 then 액션이 실행됩니다. * * AND/OR 그룹 조건과 중첩 조건도 지원합니다. * * @example * ```json * { * "type": "click", * "handler": "conditions", * "conditions": [ * { * "if": "{{$args[0] === 'edit'}}", * "then": { "handler": "navigate", "params": { "path": "/edit/{{row.id}}" } } * }, * { * "if": "{{$args[0] === 'delete'}}", * "then": [ * { "handler": "setState", "params": { "target": "_local", "deleteTargetId": "{{row.id}}" } }, * { "handler": "openModal", "params": { "id": "delete_confirm_modal" } } * ] * }, * { * "then": { "handler": "toast", "params": { "message": "알 수 없는 액션" } } * } * ] * } * ``` * * @since engine-v1.10.0 */ private async handleConditions( action: ActionDefinition, context: ActionContext ): Promise { const { conditions } = action; if (!conditions || !Array.isArray(conditions)) { logger.warn('conditions handler requires conditions array'); return; } // 조건 평가를 위한 바인딩 컨텍스트 생성 const bindingContext = context.data || {}; // 조건 브랜치 평가 const result = evaluateConditionBranches(conditions, bindingContext, this.bindingEngine); if (!result.matched) { logger.log('conditions handler: no matching branch found'); return undefined; } const branch = conditions[result.branchIndex]; logger.log(`conditions handler: matched branch ${result.branchIndex}`); if (!branch.then) { logger.warn('conditions handler: matched branch has no "then" action'); return; } // then이 배열이면 sequence처럼 순차 실행 if (Array.isArray(branch.then)) { return await this.handleSequence( { ...action, actions: branch.then as ActionDefinition[] }, context ); } // 단일 액션 실행 return await this.executeAction(branch.then as ActionDefinition, context); } /** * sequence 액션을 처리합니다. * * 여러 액션을 순차적으로 실행하며, 이전 액션의 결과를 다음 액션에서 사용할 수 있습니다. * - $prev: 직전 액션의 결과 * - $results: 모든 이전 결과 배열 [result0, result1, ...] * - $results[0], $results[1] 등으로 특정 결과 접근 * * @example * ```json * { * "type": "click", * "handler": "sequence", * "actions": [ * { "handler": "setState", "params": { "target": "global", "selectedModule": "{{row}}" } }, * { "handler": "openModal", "target": "module_install_modal" } * ] * } * ``` * * @example 결과 전달 예시 * ```json * { * "handler": "sequence", * "actions": [ * { "handler": "apiCall", "target": "/api/users/1" }, * { "handler": "setState", "params": { "user": "{{$prev.data}}" } }, * { "handler": "toast", "params": { "message": "사용자: {{$prev.name}}" } } * ] * } * ``` * * @param action 액션 정의 (actions 배열 포함) * @param context 액션 컨텍스트 * @returns 모든 액션 결과 배열 */ /** * startInterval 핸들러. * * `params.id` (필수) 로 타이머를 등록하고 `params.intervalMs` 주기로 `params.actions` 를 실행합니다. * 같은 id 로 재등록 시 기존 타이머를 자동 정리합니다. * * @since engine-v1.45.0 */ private handleStartInterval(params: Record, context: ActionContext): { success: boolean; id?: string } { const id = typeof params.id === 'string' ? params.id : ''; const intervalMs = Number(params.intervalMs ?? 1000); const actions = Array.isArray(params.actions) ? params.actions : []; if (id === '' || !actions.length || intervalMs <= 0) { logger.warn('startInterval requires non-empty id, positive intervalMs, and actions array'); return { success: false }; } // 같은 id 에 이미 등록된 타이머가 있으면 먼저 중단 (idempotent) const existing = this.intervals.get(id); if (existing) { clearInterval(existing); this.intervals.delete(id); } const timerId = setInterval(() => { for (const act of actions) { this.executeAction(act as ActionDefinition, context).catch((err) => { logger.error(`[startInterval:${id}] tick action failed`, err); }); } }, intervalMs); this.intervals.set(id, timerId); return { success: true, id }; } /** * stopInterval 핸들러 — startInterval 로 등록한 타이머를 `params.id` 로 찾아 중단합니다. * * @since engine-v1.45.0 */ private handleStopInterval(params: Record): { success: boolean; id?: string } { const id = typeof params.id === 'string' ? params.id : ''; if (id === '') { logger.warn('stopInterval requires id parameter'); return { success: false }; } const timerId = this.intervals.get(id); if (timerId) { clearInterval(timerId); this.intervals.delete(id); } return { success: true, id }; } /** * 등록된 모든 interval 을 중단합니다. 페이지 전환 등에서 호출해 누수를 방지합니다. * * @since engine-v1.45.0 */ public stopAllIntervals(): void { for (const timerId of this.intervals.values()) { clearInterval(timerId); } this.intervals.clear(); } private async handleSequence( action: ActionDefinition, context: ActionContext ): Promise { // 레이아웃 JSON 컴파일 시 actions가 params.actions로 배치되는 경우를 처리 const actions = action.actions || (action.params as any)?.actions; if (!actions || !Array.isArray(actions) || actions.length === 0) { logger.warn('sequence handler requires non-empty actions array'); return []; } // DevTools: Sequence 실행 시작 const devTools = getDevTools(); const sequenceId = devTools?.isEnabled() ? devTools.startSequenceExecution?.({ eventType: action.type, }) || '' : ''; // sequence 내 커스텀 핸들러 상태 동기화용 변수 초기화 // setLocal()에서 설정한 __g7SequenceLocalSync를 사용하여 후속 액션에 최신 상태 전달 (window as any).__g7SequenceLocalSync = undefined; const results: any[] = []; let prevResult: any = undefined; // sequence 내에서 상태 변경을 추적하기 위한 로컬 상태 복사본 // 이를 통해 setState 후 다음 액션에서 업데이트된 상태를 참조할 수 있음 let currentState = context.state ? { ...context.state } : {}; // isolated 상태 추적 (isolatedState 속성이 있는 컴포넌트에서 제공된 경우) let isolatedState = context.isolatedContext?.state ? { ...context.isolatedContext.state } : {}; // computed 상태 추적: _local 변경 시 _computed 재계산하여 다음 액션에서 최신 값 참조 let currentComputed = context.data?._computed || {}; // DevTools: 현재 상태 스냅샷 준비 헬퍼 const G7Core = (window as any).G7Core; const getStateSnapshot = () => ({ _global: G7Core?.state?.get() || {}, _local: { ...currentState }, _isolated: Object.keys(isolatedState).length > 0 ? { ...isolatedState } : undefined, }); for (let i = 0; i < actions.length; i++) { const currentAction = actions[i]; const actionStartTime = Date.now(); // DevTools: 액션 실행 전 상태 캡처 if (sequenceId && devTools?.isEnabled()) { devTools.captureSequenceActionBefore?.( sequenceId, i, currentAction.handler || 'unknown', currentAction.params || {}, getStateSnapshot() ); } // 컨텍스트에 이전 결과 정보와 최신 상태 추가 const sequenceContext: ActionContext = { ...context, state: currentState, // 최신 상태 반영 data: { ...context.data, _local: currentState, // sequence 내 setState 후 최신 _local 상태 반영 _computed: currentComputed, // sequence 내 setState 후 재계산된 _computed 반영 $computed: currentComputed, // _computed alias $prev: prevResult, // 직전 액션 결과 $results: [...results], // 모든 이전 결과 배열 (복사본) _isolated: isolatedState, // 최신 isolated 상태 반영 }, // 업데이트된 isolated 상태를 가진 컨텍스트 전달 isolatedContext: context.isolatedContext ? { ...context.isolatedContext, state: isolatedState, } : null, }; try { // 액션 실행 const result = await this.executeAction(currentAction, sequenceContext); // 결과 저장 prevResult = result.data; results.push(result.data); // setState 액션인 경우 currentState 동기화 // handleSetState가 반환한 병합된 상태를 사용하여 // 다음 액션에서 올바른 상태를 참조할 수 있도록 함 if (currentAction.handler === 'setState' && result.data !== undefined) { // handleSetState가 __target: 'global'을 포함한 경우 _global에 병합 if (result.data.__target === 'global') { const { __target, __mergeMode, ...globalPayload } = result.data; if (__mergeMode === 'replace') { // replace: 기존 _global 완전 교체 currentState = { ...currentState, _global: globalPayload }; } else { // shallow/deep: 기존 _global에 병합 (deep인 경우 이미 fully merged) currentState = { ...currentState, _global: { ...(currentState._global || {}), ...globalPayload } }; } logger.log('[handleSequence] global state synchronized (mergeMode=%s):', __mergeMode || 'deep', currentState._global); } else if (result.data.__target === 'isolated') { // isolated 상태 동기화 const { __target, __mergeMode, ...isolatedPayload } = result.data; if (__mergeMode === 'replace') { isolatedState = isolatedPayload; } else { isolatedState = { ...isolatedState, ...isolatedPayload }; } logger.log('[handleSequence] isolated state synchronized (mergeMode=%s):', __mergeMode || 'deep', isolatedState); } else if (result.data.__target === 'local') { // local 상태 동기화 - currentState는 이미 _local의 내용물이므로 직접 병합 // 주의: _local로 감싸면 중첩 발생 ($parent._local._local 문제) const { __target, __mergeMode, __setStateId, ...localPayload } = result.data; if (__mergeMode === 'replace') { // replace: 기존 local 상태 완전 교체 currentState = localPayload; } else { // shallow/deep: 기존 local 상태에 병합 (deep인 경우 이미 fully merged) currentState = { ...currentState, ...localPayload }; } logger.log('[handleSequence] local state synchronized (mergeMode=%s):', __mergeMode || 'deep', currentState); } else { // __target이 없는 경우 (하위 호환성) currentState = result.data; logger.log('[handleSequence] state synchronized (legacy):', currentState); } // _computed 재계산: _local 변경 시 _computed도 업데이트해야 다음 액션에서 최신 값 참조 가능 const computedDefinitions = context.data?._computedDefinitions; if (computedDefinitions && Object.keys(computedDefinitions).length > 0) { const newComputed: Record = {}; const computedContext = { ...context.data, _local: currentState, _computed: newComputed, $computed: newComputed, _isolated: isolatedState, }; for (const [key, expression] of Object.entries(computedDefinitions)) { if (typeof expression === 'string') { const trimmed = expression.trim(); if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) { try { const innerExpr = trimmed.slice(2, -2).trim(); // 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — 렌더 경로의 // computed 재계산(DynamicRenderer)과 같은 규칙이다. 한쪽만 고치면 // 같은 computed 가 렌더 직후와 액션 직후에 다른 값이 된다. // @since engine-v1.54.10 newComputed[key] = hasPipes(innerExpr) ? this.bindingEngine.evaluatePipeExpression( innerExpr, computedContext, { skipCache: true } ) : this.bindingEngine.evaluateExpression( innerExpr, computedContext, { skipCache: true } ); } catch (e) { // 평가 실패 시 기존 값 유지 newComputed[key] = currentComputed[key]; } } } } currentComputed = newComputed; logger.log('[handleSequence] _computed recalculated after setState:', currentComputed); } } // 비-setState 핸들러 후 상태 동기화 // 커스텀 핸들러가 G7Core.state.setLocal()을 호출한 경우, // __g7SequenceLocalSync에 최신 상태(deepMerge 완료 스냅샷)가 저장되어 있으므로 // 이를 currentState에 반영하여 후속 액션이 올바른 상태를 참조하도록 함 // // 주의: __g7PendingLocalState는 사용 불가 — setLocal() 내의 setGlobalState() → // import().then() → updateTemplateData() → root.render() → useLayoutEffect에서 // null로 클리어됨 (await 해제 시 마이크로태스크 플러시로 인해) if (currentAction.handler !== 'setState') { const syncState = (window as any).__g7SequenceLocalSync; if (syncState && syncState !== currentState) { currentState = syncState; // 사용 후 클리어 (같은 sequence 내 다음 비-setState 핸들러에서 stale 값 방지) (window as any).__g7SequenceLocalSync = undefined; logger.log('[handleSequence] currentState synchronized from __g7SequenceLocalSync after custom handler:', currentAction.handler); // _computed 재계산: 상태가 변경되었으므로 _computed도 업데이트 const computedDefinitions = context.data?._computedDefinitions; if (computedDefinitions && Object.keys(computedDefinitions).length > 0) { const newComputed: Record = {}; const computedContext = { ...context.data, _local: currentState, _computed: newComputed, $computed: newComputed, _isolated: isolatedState, }; for (const [key, expression] of Object.entries(computedDefinitions)) { if (typeof expression === 'string') { const trimmed = expression.trim(); if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) { try { const innerExpr = trimmed.slice(2, -2).trim(); // 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — 위 setState // 직후 재계산 블록과 같은 규칙이다. @since engine-v1.54.10 newComputed[key] = hasPipes(innerExpr) ? this.bindingEngine.evaluatePipeExpression( innerExpr, computedContext, { skipCache: true } ) : this.bindingEngine.evaluateExpression( innerExpr, computedContext, { skipCache: true } ); } catch (e) { // 평가 실패 시 기존 값 유지 newComputed[key] = currentComputed[key]; } } } } currentComputed = newComputed; logger.log('[handleSequence] _computed recalculated after custom handler:', currentComputed); } } } // DevTools: 액션 실행 후 상태 캡처 if (sequenceId && devTools?.isEnabled()) { devTools.captureSequenceActionAfter?.( sequenceId, i, getStateSnapshot(), Date.now() - actionStartTime, result.data ); } } catch (error) { // DevTools: 에러 발생 시 상태 캡처 if (sequenceId && devTools?.isEnabled()) { devTools.captureSequenceActionAfter?.( sequenceId, i, getStateSnapshot(), Date.now() - actionStartTime, undefined, error as Error ); devTools.endSequenceExecution?.(sequenceId, error as Error); } logger.error(`sequence handler: action[${i}] failed:`, error); // 에러 발생 시 중단하고 에러를 상위로 전파 throw error; } } // sequence 종료 시 동기화 변수 클리어 (window as any).__g7SequenceLocalSync = undefined; // DevTools: Sequence 실행 완료 if (sequenceId && devTools?.isEnabled()) { devTools.endSequenceExecution?.(sequenceId); } return results; } /** * parallel 액션을 처리합니다. * * 여러 액션을 병렬로 실행하며, 모든 액션이 완료될 때까지 기다립니다. * Promise.all을 사용하여 모든 액션을 동시에 실행합니다. * * 주의: 병렬 실행이므로 액션 간 실행 순서가 보장되지 않습니다. * 순서가 중요한 경우 sequence 핸들러를 사용하세요. * * @example * ```json * { * "handler": "parallel", * "actions": [ * { "handler": "toast", "params": { "type": "success", "message": "완료!" } }, * { "handler": "refetchDataSource", "params": { "dataSourceId": "modules" } }, * { "handler": "refetchDataSource", "params": { "dataSourceId": "admin_menu" } } * ] * } * ``` * * @example onSuccess에서 사용 * ```json * { * "handler": "apiCall", * "target": "/api/admin/modules/activate", * "onSuccess": [ * { * "handler": "parallel", * "actions": [ * { "handler": "toast", "params": { "type": "success", "message": "활성화 완료" } }, * { "handler": "refetchDataSource", "params": { "dataSourceId": "modules" } }, * { "handler": "refetchDataSource", "params": { "dataSourceId": "admin_menu" } } * ] * } * ] * } * ``` * * @param action 액션 정의 (actions 배열 포함) * @param context 액션 컨텍스트 * @returns 모든 액션 결과 배열 (PromiseSettledResult 형식) */ private async handleParallel( action: ActionDefinition, context: ActionContext ): Promise[]> { // 레이아웃 JSON 컴파일 시 actions가 params.actions로 배치되는 경우를 처리 const actions = action.actions || (action.params as any)?.actions; if (!actions || !Array.isArray(actions) || actions.length === 0) { logger.warn('parallel handler requires non-empty actions array'); return []; } logger.log(`parallel handler: executing ${actions.length} actions in parallel`); // isolated 상태 스냅샷 (병렬 실행 시 모든 액션이 동일한 초기 상태 참조하도록) const isolatedContextSnapshot = context.isolatedContext ? { ...context.isolatedContext, state: { ...context.isolatedContext.state }, } : null; // 모든 액션을 병렬로 실행 const promises = actions.map((currentAction, index) => { const actionContext: ActionContext = { ...context, isolatedContext: isolatedContextSnapshot, }; return this.executeAction(currentAction, actionContext) .catch(error => { // 개별 액션 에러를 로깅하지만 다른 액션은 계속 실행 logger.error(`parallel handler: action[${index}] failed:`, error); throw error; }); }); // Promise.allSettled를 사용하여 모든 액션 완료 대기 // 일부 액션이 실패해도 다른 액션은 계속 실행됨 const results = await Promise.allSettled(promises); // 실패한 액션이 있는지 확인하고 로깅 const failedCount = results.filter(r => r.status === 'rejected').length; if (failedCount > 0) { logger.warn(`parallel handler: ${failedCount}/${actions.length} actions failed`); } return results; } /** * 확장 자산 URL(js/css)이 주입 허용 대상인지 검사하고, 아니면 차단합니다. * * 확장 활성화 응답이 지시하는 URL 을 그대로 `