@context-action/lit-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr.js","names":[],"sources":["../src/ssr.ts"],"sourcesContent":["/**\n * @fileoverview Minimal server-side rendering and client hydration entry for\n * Lit Elements and Lit templates (`@context-action/lit-ui/ssr`).\n *\n * The module is safe to import in Node, Edge, and Worker environments: it does\n * not read `window` or `document` at module evaluation time. Rendering uses\n * Lit's own SSR renderer, so `part`, `data-*`, declarative shadow roots, and\n * imported styles are preserved in the produced markup.\n *\n * @implements view-layer\n * @implements web-component-primitive\n * @memberof core-concepts\n */\n\nimport type { LitElement, TemplateResult } from 'lit';\nimport { render } from '@lit-labs/ssr';\nimport { collectResult, collectResultSync } from '@lit-labs/ssr/lib/render-result.js';\n\nexport { hydrateLit } from './hydrate.js';\n\n/**\n * Options for {@link renderLitElement}.\n */\nexport interface RenderLitElementOptions {\n /**\n * Explicit reactive property names to serialize as Lit property bindings.\n * Defaults to the element's `static elementProperties` map.\n */\n properties?: readonly string[];\n\n /**\n * Copy the element's current HTML attributes into the server markup.\n * Defaults to `true`.\n */\n includeAttributes?: boolean;\n\n /**\n * Serialize the element's current light DOM children inside the host tag so\n * `<slot>` projection survives SSR. Defaults to `true`.\n *\n * The light DOM is treated as trusted server input; complex properties on\n * nested custom elements must still be supplied through their own SSR path.\n */\n includeLightDom?: boolean;\n}\n\nconst PROPERTY_NAME_PATTERN = /^[A-Za-z_$][\\w$-]*$/;\n\ninterface ReflectivePropertyDeclaration {\n readonly attribute?: string | boolean;\n readonly reflect?: boolean;\n readonly type?: unknown;\n readonly converter?: {\n toAttribute?(value: unknown, type?: unknown): string | null;\n };\n}\n\nfunction reflectedAttribute(\n element: LitElement,\n propertyName: string,\n declaration: ReflectivePropertyDeclaration | undefined,\n): { name: string; value: string } | null {\n if (!declaration?.reflect || declaration.attribute === false) return null;\n\n const attributeName =\n typeof declaration.attribute === 'string'\n ? declaration.attribute\n : propertyName.toLowerCase();\n if (attributeName.length === 0) return null;\n\n const value = (element as unknown as Record<string, unknown>)[propertyName];\n if (value === undefined || value === null) return null;\n\n let attributeValue: string | null;\n if (declaration.converter?.toAttribute) {\n attributeValue = declaration.converter.toAttribute(value, declaration.type);\n } else if (declaration.type === Boolean) {\n attributeValue = value ? '' : null;\n } else {\n attributeValue = String(value);\n }\n\n return attributeValue === null\n ? null\n : { name: attributeName, value: attributeValue };\n}\n\nfunction isTemplateResult(value: unknown): value is TemplateResult {\n return (\n typeof value === 'object' &&\n value !== null &&\n '_$litType$' in (value as Record<PropertyKey, unknown>)\n );\n}\n\n/**\n * Collects a Lit SSR render result synchronously.\n *\n * @throws When the template contains a value that resolves asynchronously\n * (for example an asynchronous element renderer). Use\n * {@link renderLitTemplateAsync} instead.\n */\nexport function renderLitTemplate(template: TemplateResult): string {\n if (!isTemplateResult(template)) {\n throw new TypeError('renderLitTemplate() expects a Lit TemplateResult.');\n }\n return collectResultSync(render(template));\n}\n\n/**\n * Collects a Lit SSR render result, awaiting asynchronous renderer chunks.\n */\nexport async function renderLitTemplateAsync(template: TemplateResult): Promise<string> {\n if (!isTemplateResult(template)) {\n throw new TypeError('renderLitTemplateAsync() expects a Lit TemplateResult.');\n }\n return collectResult(render(template));\n}\n\nfunction escapeAttributeValue(value: string): string {\n return value\n .replaceAll('&', '&amp;')\n .replaceAll('\"', '&quot;')\n .replaceAll('<', '&lt;')\n .replaceAll('>', '&gt;');\n}\n\n/**\n * Creates a `TemplateResult` that reproduces an element instance's public\n * attributes and reactive properties.\n *\n * Lit does not accept a raw `LitElement` instance in `render()` (it would be\n * stringified). Constructing a template with `.property=${value}` bindings lets\n * Lit's ElementRenderer create its own server-side instance with the same input.\n */\nfunction createElementTemplate(\n element: LitElement,\n options?: RenderLitElementOptions,\n): TemplateResult {\n const tagName = element.localName;\n if (!tagName) {\n throw new Error(\n 'renderLitElement() requires a registered custom element. Call customElements.define() before rendering.',\n );\n }\n\n const registry = globalThis.customElements;\n if (!registry?.get(tagName)) {\n throw new Error(\n `renderLitElement(): custom element \"${tagName}\" is not registered. Call customElements.define() before rendering.`,\n );\n }\n\n const strings: string[] = [`<${tagName}`];\n const values: unknown[] = [];\n\n if (options?.includeAttributes !== false) {\n for (const attribute of Array.from(element.attributes)) {\n strings[strings.length - 1] += ` ${attribute.name}=\"${escapeAttributeValue(attribute.value)}\"`;\n }\n }\n\n const elementConstructor = element.constructor as unknown as {\n elementProperties?: Map<PropertyKey, ReflectivePropertyDeclaration>;\n };\n const elementProperties = elementConstructor.elementProperties;\n const propertyNames =\n options?.properties ??\n (elementProperties ? Array.from(elementProperties.keys(), String) : []);\n\n const emittedAttributeNames = new Set(\n Array.from(element.attributes, attribute => attribute.name),\n );\n const seen = new Set<string>();\n for (const propertyName of propertyNames) {\n if (seen.has(propertyName) || !PROPERTY_NAME_PATTERN.test(propertyName)) continue;\n seen.add(propertyName);\n\n const reflected = reflectedAttribute(\n element,\n propertyName,\n elementProperties?.get(propertyName),\n );\n if (reflected && !emittedAttributeNames.has(reflected.name)) {\n emittedAttributeNames.add(reflected.name);\n strings[strings.length - 1] +=\n ` ${reflected.name}=\"${escapeAttributeValue(reflected.value)}\"`;\n }\n\n strings[strings.length - 1] += ` .${propertyName}=\"`;\n values.push((element as unknown as Record<string, unknown>)[propertyName]);\n strings.push('\"');\n }\n\n strings[strings.length - 1] += `>`;\n if (options?.includeLightDom !== false) {\n strings[strings.length - 1] += element.innerHTML;\n }\n strings[strings.length - 1] += `</${tagName}>`;\n\n const templateStrings = strings as unknown as TemplateStringsArray;\n Object.defineProperty(templateStrings, 'raw', {\n configurable: false,\n enumerable: false,\n value: [...strings],\n });\n\n // Lit has no public API for constructing a template with dynamically named\n // property bindings. This shape is covered by the SSR unit and browser\n // hydration contracts so a Lit major upgrade fails loudly.\n return {\n '_$litType$': 1,\n strings: templateStrings,\n values,\n } as unknown as TemplateResult;\n}\n\n/**\n * Renders a Lit Element instance to server markup, including its declarative\n * shadow root and adopted styles.\n *\n * The element must already be registered with `customElements.define()`.\n * Reactive properties are serialized as Lit property bindings so complex\n * values survive SSR without attribute stringification.\n */\nexport function renderLitElement(\n element: LitElement,\n options?: RenderLitElementOptions,\n): string {\n return renderLitTemplate(createElementTemplate(element, options));\n}\n\n/**\n * Asynchronous variant of {@link renderLitElement} for custom element\n * renderers that yield asynchronous chunks.\n */\nexport async function renderLitElementAsync(\n element: LitElement,\n options?: RenderLitElementOptions,\n): Promise<string> {\n return renderLitTemplateAsync(createElementTemplate(element, options));\n}\n"],"mappings":"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"}
@@ -0,0 +1,133 @@
1
+ # DOM Tree Ownership Convention (Lit & Web Components)
2
+
3
+ 이 문서는 `@context-action/lit` 및 `@context-action/lit-ui`를 사용할 때 준수해야 하는 **DOM 트리 렌더링 소유권(DOM Rendering Ownership)** 원칙과 **Shadow DOM 캡슐화 경계(Encapsulation Boundaries)**를 정의합니다.
4
+
5
+ ---
6
+
7
+ ## 1. 하나의 영역에는 하나의 렌더링 책임자 (Single Ownership Rule)
8
+
9
+ ### 핵심 원칙
10
+ > **"하나의 DOM 서브트리에는 오직 하나의 렌더링 엔진(Owner)만이 구조와 속성을 변경할 수 있다."**
11
+
12
+ Web Component 환경에서 컴포넌트는 두 개의 독립적인 DOM 영역을 가집니다:
13
+
14
+ 1. **외부 Host 영역 (Light DOM)**:
15
+ - **소유자**: 상위 호스트 애플리케이션 (React, Vue, 바닐라 HTML 문서 등).
16
+ - **책임**: Custom Element의 배치(Layout), 외부 스타일(CSS grid/flex), 호스트 속성(Attribute), 그리고 `<slot>`으로 투영될 Light DOM 자식 노드의 배치를 관리합니다.
17
+ 2. **내부 렌더링 영역 (ShadowRoot / `this.renderRoot`)**:
18
+ - **소유자**: Custom Element 인스턴스 (`LitElement` 또는 `FormAssociatedLitElement`).
19
+ - **책임**: `render()` 메서드가 반환하는 `TemplateResult`(`html`...``)를 통해 Shadow DOM 내부의 모든 노드 생성, 갱신, 삭제를 독점적으로 통제합니다.
20
+
21
+ ```text
22
+ ┌─────────────────────────────────────────────────────────────┐
23
+ │ Host Application (React / HTML Page) │
24
+ │ - Owns: Custom Element tag placement (<lit-cart-badge>) │
25
+ │ - Controls via: Properties, Attributes, Events │
26
+ └──────────────────────────────┬──────────────────────────────┘
27
+ │
28
+ [Shadow Boundary] (mode: 'open')
29
+ │
30
+ ┌──────────────────────────────▼──────────────────────────────┐
31
+ │ LitElement (Internal Owner) │
32
+ │ - Owns: this.renderRoot (ShadowRoot) │
33
+ │ - Updates: Declarative TemplateResult via html`...` │
34
+ │ - Controllers: StoreController, ActionController (Headless) │
35
+ └─────────────────────────────────────────────────────────────┘
36
+ ```
37
+
38
+ ---
39
+
40
+ ## 2. Shadow DOM 캡슐화 경계 및 스타일 격리
41
+
42
+ ### 2.1 캡슐화 보장
43
+ * `LitElement`는 기본적으로 `mode: 'open'`인 `ShadowRoot`를 생성합니다.
44
+ * 컴포넌트 내부 스타일은 `static styles = css`...``를 통해 Constructable Stylesheet로 캡슐화되어 외부 CSS 오염과 충돌을 원천 차단합니다.
45
+ * 외부 소비자는 컴포넌트 내부 DOM 노드(`shadowRoot.querySelector(...)`, `shadowRoot.innerHTML`)를 검색하거나 직접 수정해서는 안 됩니다.
46
+
47
+ ### 2.2 허용된 외부 제어 계약 (Public Contract)
48
+ 외부와의 모든 상호작용은 반드시 W3C 표준 인터페이스를 통해서만 이루어집니다:
49
+
50
+ 1. **입력 (Input)**:
51
+ - **DOM Property**: 복합 객체, 스토어, 원시값 (`element.value = 10`, `element.cartStore = ...`).
52
+ - **HTML Attribute**: 원시 설정값 (`disabled`, `required`, `name="quantity"`).
53
+ 2. **명령 (Command)**:
54
+ - Custom Element의 공개 메서드 호출 또는 `ActionController`를 통한 액션 파이프라인 디스패치.
55
+ 3. **출력 (Output / Events)**:
56
+ - Shadow DOM 경계를 통과하는 표준 DOM 이벤트: `new CustomEvent('quantity-change', { bubbles: true, composed: true, detail: { ... } })`.
57
+ 4. **스타일 확장 (Styling Extension)**:
58
+ - **CSS Custom Properties**: 테마 토큰 주입 (예: `--stepper-border-color: #e2e8f0;`).
59
+ - **`::part()` 선택자**: 공개된 서브 파트 스타일링 (예: `element::part(button) { ... }`).
60
+ - **`<slot>` 프로젝션**: 부모의 Light DOM 노드를 지정된 위치에 투영.
61
+
62
+ ### 2.3 반영 루프 방지 (Event Loop Prevention)
63
+ * 프로그램에 의한 프로퍼티 입력(`element.value = ...`)은 변경 이벤트를 재발행하지 않습니다.
64
+ * `value-change`, `quantity-change`와 같은 변경 알림 이벤트는 **오직 사용자의 실제 상호작용(클릭, 키 입력)이 발생했을 때만** 발행하여 무한 이벤트 루프를 방지합니다.
65
+
66
+ ---
67
+
68
+ ## 3. 스토어 및 컨트롤러의 DOM 조작 절대 금지 (Headless Controllers)
69
+
70
+ Context-Action 프레임워크에서 상태 스토어(`ReadableStore`)와 반응형 컨트롤러(`StoreController`, `ActionController`)는 **100% 무상태-무DOM(Headless)** 객체입니다.
71
+
72
+ ### 🚫 엄격히 금지되는 안티패턴
73
+ 1. **스토어 내부에 DOM 노드 보관 금지**:
74
+ ```ts
75
+ // ❌ 절대 금지: 상태에 HTMLElement나 DOM 노드를 보관
76
+ interface BadState {
77
+ buttonElement: HTMLButtonElement; // FORBIDDEN!
78
+ }
79
+ ```
80
+ 2. **컨트롤러 콜백에서 직접 DOM 쿼리 및 수정 금지**:
81
+ ```ts
82
+ // ❌ 절대 금지: 컨트롤러가 DOM을 직접 변경
83
+ store.subscribe((val) => {
84
+ document.getElementById('badge').textContent = val; // FORBIDDEN!
85
+ this.shadowRoot.querySelector('.count').innerHTML = val; // FORBIDDEN!
86
+ });
87
+ ```
88
+ 3. **외부에서 Shadow DOM 노드 직접 조작 금지**:
89
+ ```ts
90
+ // ❌ 절대 금지: 외부 소비자가 내부 노드 침범
91
+ const el = document.querySelector('lit-quantity-stepper');
92
+ el.shadowRoot.querySelector('input').value = '99'; // FORBIDDEN!
93
+ ```
94
+
95
+ ### ✅ 올바른 단방향 흐름
96
+ ```text
97
+ Store Update -> StoreController Projection (Memoized)
98
+ -> host.requestUpdate()
99
+ -> Lit Batch Scheduler (Microtask)
100
+ -> element.render() (TemplateResult html`...`)
101
+ -> Lit Part API updates exact DOM text/attributes
102
+ ```
103
+
104
+ ---
105
+
106
+ ## 4. 책임의 분리 (Separation of Concerns)
107
+
108
+ | 영역 | 책임자 | 주 역할 | 허용되는 동작 | 금지되는 동작 |
109
+ |------|--------|---------|---------------|---------------|
110
+ | **상태 관리** | `StoreController`, `ReadableStore` | 비즈니스 상태 구독, 프로젝션 연산, 동등성 비교, 수명 관리 | `store.subscribe()`, `equalityFn`, `requestUpdate()` 호출 | DOM 탐색, HTML 마크업 생성, 스타일 접근 |
111
+ | **액션 조율** | `ActionController`, `ActionRegister` | 액션 디스패치, 비동기 파이프라인 조율, 로딩 상태, Abort 제어 | `dispatch()`, `activeCount`, `AbortSignal` 병합 | DOM 이벤트 직접 조작, UI 렌더링 |
112
+ | **렌더링** | `LitElement`, `FormAssociatedLitElement` | Shadow DOM 소유, 마크업 생성, 스타일 캡슐화, 사용자 이벤트 리스닝 | `render()`, `css`...``, `dispatchEvent()`, FACE API | 비즈니스 로직 직접 수행, 스토어 내부 직접 조작 |
113
+
114
+ ---
115
+
116
+ ## 5. 수명 주기와 자원 정리 (Lifecycle & Disposal)
117
+
118
+ ### 5.1 Disconnect는 파괴(Destroy)가 아닌 분리(Unbind)
119
+ * Custom Element가 DOM 트리에서 제거될 때(`disconnectedCallback`), 이는 인스턴스의 영구 소멸이 아닙니다.
120
+ * DOM 노드 이동(Reparenting, 드래그 앤 드롭, 탭 전환) 시 `disconnectedCallback` 직후 새로운 부모에서 `connectedCallback`이 호출될 수 있습니다.
121
+ * 따라서 `disconnectedCallback`에서는 **스토어 구독 해제(`unsubscribe()`)와 비동기 요청 취소(`abort()`)**만을 수행하고, 엘리먼트 자체의 내부 상태나 컨트롤러 인스턴스를 파기하지 않습니다.
122
+
123
+ ### 5.2 재연결 시 무결성 동기화 (Resynchronization)
124
+ * 엘리먼트가 다시 연결되면 `StoreController.hostConnected()`가 즉시 스토어를 재구독하고 스냅샷을 검사합니다.
125
+ * 분리되어 있던 동안 스토어 상태가 변경되었을 경우에만 `host.requestUpdate()`를 호출하여 최신 상태로 재동기화합니다.
126
+
127
+ ---
128
+
129
+ ## 6. 실패 처리와 보안 경계
130
+
131
+ 1. **템플릿 신뢰성**: Lit의 `html` 태그 템플릿은 정적 템플릿 스트링만을 신뢰하며, 동적 변수 바인딩은 안전하게 이스케이프 처리됩니다. 단, 원시 HTML을 주입하는 `unsafeHTML`은 오직 검증된 소스에서만 제한적으로 사용해야 합니다.
132
+ 2. **Shadow DOM과 접근 보안**: Shadow DOM은 CSS 및 DOM 캡슐화 경계이지 악의적인 스크립트를 방어하는 보안 샌드박스가 아닙니다. `mode: 'open'` 상태의 `shadowRoot`는 자바스크립트 실행 컨텍스트 내에서 접근 가능하므로 비밀 정보나 인증 토큰을 DOM 트리에 직접 노출하지 않습니다.
133
+ 3. **스타일 확장 계약 준수**: 외부 테마 주입이 필요한 경우 CSS Custom Property(`--*`) 또는 `::part()`를 공식 계약으로 노출하며, 내부 DOM 계층 구조에 의존하는 임의의 셀렉터 해킹을 방지합니다.
@@ -0,0 +1,246 @@
1
+ # ReactiveController vs React Hooks & 3-Layer Architecture in Lit
2
+
3
+ 이 문서는 React Hooks의 멘탈 모델과 Lit의 `ReactiveController` 패러다임을 심층 비교하고, `@context-action/lit` 및 `@context-action/lit-ui`를 활용하여 구축하는 **3계층 아키텍처(Action - Store - View)**를 설명합니다.
4
+
5
+ ---
6
+
7
+ ## 1. 멘탈 모델 비교: React Hooks vs Lit ReactiveControllers
8
+
9
+ React Hooks와 Lit ReactiveController는 모두 컴포넌트에 상태와 수명 주기를 합성(Composition)하는 도구이지만, 그 기반 철학은 근본적으로 다릅니다.
10
+
11
+ | 비교 항목 | React Hooks (`useEffect`, `useSyncExternalStore`) | Lit ReactiveControllers (`StoreController`, `ActionController`) |
12
+ |-----------|---------------------------------------------------|-----------------------------------------------------------------|
13
+ | **실행 패러다임** | **함수형 재실행 (Functional Re-execution)**<br>상태 변경마다 컴포넌트 함수 전체가 처음부터 끝까지 재호출됨 | **객체지향 수명주기 참여 (Object-Oriented Lifecycle Participant)**<br>클래스 인스턴스가 유지되며, 수명주기 이벤트 훅이 호출됨 |
14
+ | **호출 규칙** | **Strict Rules of Hooks**<br>루프, 조건문, 중첩 함수 내 호출 불가. 호출 순서가 고정되어야 함 | **Class Field / Constructor Initialization**<br>일반 클래스 필드로 인스턴스화. 조건부 생성이나 지연 생성도 유연하게 가능 |
15
+ | **클로저 함정 (Closure Traps)** | `useCallback`, `useMemo`, `useEffect` 의존성 배열(`[deps]`) 관리 필요. 누락 시 Stale State 버그 발생 | **클로저 함정 없음**<br>인스턴스 프로퍼티와 게터(`controller.value`)를 직접 참조하므로 항상 최신 상태 보장 |
16
+ | **렌더링 비용** | 전체 Virtual DOM diffing 트리 계산 발생 | TemplateResult의 동적 Part만 O(1)로 미세 갱신 (Virtual DOM 없음) |
17
+ | **연결/해제 수명** | `useEffect`의 cleanup 콜백이 commit phase 이후 비동기 실행 | 브라우저 표준 `connectedCallback` / `disconnectedCallback`과 동기적으로 실행 |
18
+
19
+ ### 핵심 차이 요약
20
+ * **React Hooks**는 컴포넌트의 렌더 함수가 계속 다시 도는 과정에서 "기억(Memory)"을 유지하기 위한 트릭입니다.
21
+ * **Lit ReactiveController**는 Custom Element 인스턴스에 붙어서 **DOM 수명 주기(Connect/Disconnect/Update)에 직접 동참하는 독립적인 협력 객체**입니다.
22
+
23
+ ---
24
+
25
+ ## 2. Lit에서의 3계층 아키텍처 (3-Layer Architecture)
26
+
27
+ Context-Action 프레임워크는 Lit 기반 컴포넌트에서도 완벽한 관심사 분리를 보장하는 **3계층 구조(Action - Store - View)**를 구현합니다.
28
+
29
+ ```text
30
+ ┌────────────────────────────────────────────────────────────────────────┐
31
+ │ 1. Action Layer (Logic) │
32
+ │ ActionRegister<A, R> ◄────── ActionController<A, R> │
33
+ │ - Pipeline handlers - Typed dispatch / dispatchWithResult │
34
+ │ - Priority orchestration - Coalesced isPending tracking │
35
+ │ - Business side-effects - Lifecycle AbortSignal propagation │
36
+ └───────────────────────────────────┬────────────────────────────────────┘
37
+ │ Updates domain state
38
+ ▼
39
+ ┌────────────────────────────────────────────────────────────────────────┐
40
+ │ 2. Store Layer (State) │
41
+ │ ReadableStore<T> ──────► StoreController<T, Selected> │
42
+ │ - Single source of truth - Synchronous initial snapshot │
43
+ │ - Subscription broadcaster - Selective projection & equalityFn │
44
+ │ - Immutable domain state - Leak-free unbind & resync on connect │
45
+ └───────────────────────────────────┬────────────────────────────────────┘
46
+ │ Triggers host.requestUpdate()
47
+ ▼
48
+ ┌────────────────────────────────────────────────────────────────────────┐
49
+ │ 3. View Layer (UI) │
50
+ │ LitElement / FormAssociatedLitElement │
51
+ │ - Shadow DOM encapsulation (mode: 'open', scoped CSS) │
52
+ │ - Declarative rendering via html`...` │
53
+ │ - Dispatches native CustomEvent for user interactions │
54
+ │ - Bridges to React 18/19 via createLitElementBridge │
55
+ └────────────────────────────────────────────────────────────────────────┘
56
+ ```
57
+
58
+ ### 2.1 Layer 1: Action Layer (액션 및 조율 계층)
59
+ * **`ActionRegister<A, TResultMap>`**: `@context-action/core`의 핵심 비즈니스 파이프라인. 액션 핸들러의 우선순위 실행, 인터셉터, 도메인 검증을 담당합니다.
60
+ * **`ActionController<A, TResultMap>`**: ActionRegister를 Lit의 ReactiveController로 감싸는 어댑터.
61
+ - `dispatch('actionName', payload)`: 타입 세이프한 디스패치.
62
+ - `isPending`: 0 ↔ >0 전환 시에만 `requestUpdate()`를 호출하여 불필요한 리렌더를 억제하고 로딩 UI를 제어 (Pending State Coalescing).
63
+ - `AbortSignal` 자동 주입: 호스트 엘리먼트가 DOM에서 분리되면 진행 중인 비동기 액션을 즉시 취소(`AbortError`).
64
+
65
+ ### 2.2 Layer 2: Store Layer (상태 및 프로젝션 계층)
66
+ * **`ReadableStore<T>`**: 도메인 상태의 원천(`getValue()` / `getSnapshot()`, `subscribe()`).
67
+ * **`StoreController<T, Selected>`**:
68
+ - `selector`: 필요한 데이터 슬라이스만 선택적으로 투영.
69
+ - `equalityFn`: 투영된 결과가 동일하면 `requestUpdate()`를 억제하여 고빈도 변경 상황에서도 렌더링 비용을 0으로 유지.
70
+ - `hostDisconnected()` 시 즉시 구독 해제하여 메모리 누수를 완벽 차단.
71
+ * **`ContextStoreController` / `ContextActionController`**: W3C Context Protocol (`@lit/context`)을 통해 Shadow DOM 경계를 넘어 상위 Provider로부터 스토어와 레지스터를 자동 주입(DI).
72
+
73
+ ### 2.3 Layer 3: View Layer (표현 및 커스텀 엘리먼트 계층)
74
+ * **`LitElement` / `FormAssociatedLitElement`**:
75
+ - 뷰 렌더링만을 담당하며 비즈니스 로직을 포함하지 않습니다.
76
+ - `render()` 메서드는 컨트롤러의 상태(`this.store.value`, `this.actions.isPending`)를 읽어 순수 HTML 템플릿(`TemplateResult`)을 반환합니다.
77
+ - 사용자의 인터랙션은 표준 `CustomEvent`(`bubbles: true, composed: true`)로 외부에 알립니다.
78
+
79
+ ---
80
+
81
+ ## 3. 실전 구현 예제 (Implementation Recipe)
82
+
83
+ ### 3.1 액션 및 스토어 정의 (Domain Logic)
84
+ ```ts
85
+ // cart-domain.ts
86
+ import { ActionRegister } from '@context-action/core';
87
+ import type { ReadableStore } from '@context-action/lit';
88
+
89
+ export interface CartItem {
90
+ id: string;
91
+ name: string;
92
+ price: number;
93
+ quantity: number;
94
+ }
95
+
96
+ export interface CartState {
97
+ items: CartItem[];
98
+ currency: string;
99
+ }
100
+
101
+ export interface CartActions {
102
+ addItem: { product: Omit<CartItem, 'quantity'> };
103
+ updateQuantity: { id: string; quantity: number };
104
+ clearCart: void;
105
+ }
106
+ ```
107
+
108
+ ### 3.2 3계층 Lit 컴포넌트 구현
109
+ ```ts
110
+ // lit-cart-badge.ts
111
+ import { LitElement, html, css } from 'lit';
112
+ import { customElement, property } from 'lit/decorators.js';
113
+ import { StoreController, ActionController } from '@context-action/lit';
114
+ import type { ReadableStore } from '@context-action/lit';
115
+ import type { ActionRegister } from '@context-action/core';
116
+ import type { CartState, CartActions } from './cart-domain.js';
117
+
118
+ @customElement('lit-cart-badge')
119
+ export class LitCartBadge extends LitElement {
120
+ static override styles = css`
121
+ :host {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ font-family: system-ui, sans-serif;
125
+ }
126
+ .badge {
127
+ background: #0070f3;
128
+ color: white;
129
+ padding: 4px 8px;
130
+ border-radius: 9999px;
131
+ font-size: 0.875rem;
132
+ font-weight: 600;
133
+ cursor: pointer;
134
+ border: none;
135
+ display: inline-flex;
136
+ align-items: center;
137
+ gap: 6px;
138
+ }
139
+ .badge:disabled {
140
+ opacity: 0.6;
141
+ cursor: not-allowed;
142
+ }
143
+ .spinner {
144
+ width: 12px;
145
+ height: 12px;
146
+ border: 2px solid white;
147
+ border-top-color: transparent;
148
+ border-radius: 50%;
149
+ animation: spin 0.8s linear infinite;
150
+ }
151
+ @keyframes spin {
152
+ to { transform: rotate(360deg); }
153
+ }
154
+ `;
155
+
156
+ // Controllers attached as persistent class members
157
+ protected cartStore: StoreController<CartState, number>;
158
+ protected cartActions: ActionController<CartActions>;
159
+
160
+ constructor(store: ReadableStore<CartState>, register: ActionRegister<CartActions>) {
161
+ super();
162
+
163
+ // 1. Store Layer: Selective projection with memoization
164
+ this.cartStore = new StoreController(this, store, {
165
+ selector: (state) => state.items.reduce((acc, item) => acc + item.quantity, 0),
166
+ equalityFn: Object.is, // Suppresses rerender if total count is unchanged
167
+ });
168
+
169
+ // 2. Action Layer: Typed dispatch and pending tracking
170
+ this.cartActions = new ActionController(this, register);
171
+ }
172
+
173
+ private handleClick = async () => {
174
+ // Dispatch custom event to notify outer container
175
+ this.dispatchEvent(
176
+ new CustomEvent('cart-badge-click', {
177
+ bubbles: true,
178
+ composed: true,
179
+ detail: { totalCount: this.cartStore.value },
180
+ })
181
+ );
182
+ };
183
+
184
+ // 3. View Layer: Pure declarative rendering
185
+ override render() {
186
+ const count = this.cartStore.value;
187
+ const isPending = this.cartActions.isPending;
188
+
189
+ return html`
190
+ <button
191
+ class="badge"
192
+ part="button"
193
+ ?disabled=${isPending}
194
+ @click=${this.handleClick}
195
+ >
196
+ <span>장바구니</span>
197
+ <span part="count">${count}</span>
198
+ ${isPending ? html`<span class="spinner" part="spinner"></span>` : ''}
199
+ </button>
200
+ `;
201
+ }
202
+ }
203
+ ```
204
+
205
+ ---
206
+
207
+ ## 4. 수명 주기 및 동작 차이 분석 (Deep Dive)
208
+
209
+ ### 4.1 언마운트 시 정리 (Unmount Cleanup)
210
+ * **React**: `useEffect` cleanup은 리액트 스케줄러가 DOM 노드를 제거한 뒤 비동기적으로 실행됩니다. 이 과정에서 취소되지 않은 비동기 작업이 setState를 호출해 경고를 유발하거나 메모리 누수를 남기기 쉽습니다.
211
+ * **Lit**: 브라우저 엔진이 DOM 트리를 떼어내는 순간 네이티브 `disconnectedCallback`에 의해 `hostDisconnected()`가 **동기적으로** 호출됩니다.
212
+ - `StoreController.hostDisconnected()`: 즉시 `unsubscribe()`를 호출하여 스토어의 리스너 셋에서 완전히 제거됩니다 (리스너 수 = 0).
213
+ - `ActionController.hostDisconnected()`: `this.#abortController.abort()`를 즉시 실행하여 진행 중이던 네트워크 요청 및 파이프라인 처리를 즉각 취소합니다.
214
+
215
+ ### 4.2 재연결 시 무결성 동기화 (Reconnection Resynchronization)
216
+ * React 컴포넌트는 트리에서 빠졌다가 다시 들어가면 컴포넌트가 파괴되고 새로 마운트됩니다.
217
+ * Lit Custom Element는 DOM 위치가 이동되어도 동일한 인스턴스가 유지됩니다.
218
+ * `StoreController.hostConnected()`가 호출되면:
219
+ 1. 스토어를 즉시 다시 구독합니다.
220
+ 2. 분리되어 있던 동안 변경된 최신 원본 상태를 읽어와 프로젝션을 재평가합니다.
221
+ 3. 이전 값과 달라졌을 때만 `host.requestUpdate()`를 요청하여 깜빡임 없는 완벽한 동기화를 달성합니다.
222
+
223
+ ### 4.3 선택적 프로젝션과 동등성 비교 (Selective Projection Memoization)
224
+ * React에서는 `useSelector(selector, shallowEqual)`나 `useSyncExternalStore`에서 항상 동일한 참조를 반환하도록 메모이제이션하지 않으면 무한 루프에 빠집니다.
225
+ * Lit의 `StoreController`는 스토어의 `subscribe` 리스너 내부에서 프로젝션과 동등성 검사를 직접 수행합니다:
226
+ ```ts
227
+ const nextRaw = this.#extractRawValue();
228
+ const nextSelected = this.#selector(nextRaw);
229
+
230
+ if (!this.#equalityFn(this.#selectedValue, nextSelected)) {
231
+ this.#selectedValue = nextSelected;
232
+ this.#host.requestUpdate();
233
+ }
234
+ ```
235
+ * 따라서 아무리 빈번하게 스토어가 갱신되어도, 컴포넌트가 바라보는 슬라이스가 동일하다면 `requestUpdate()`는 **단 한 번도 호출되지 않으며, Lit의 마이크로태스크 렌더 큐에 아무런 작업도 등록되지 않습니다.**
236
+
237
+ ### 4.4 지능형 펜딩 상태 합산 (Pending State Coalescing)
238
+ * 복수의 액션이 동시에 실행될 때, 각 액션 시작과 종료 시점마다 매번 렌더링을 요청하면 불필요한 레이아웃 스래싱이 발생합니다.
239
+ * `ActionController`는 활성 디스패치 카운터(`activeCount`)를 관리하여 `0 -> 1`(로딩 시작) 및 `1 -> 0`(로딩 완료) 경계에서만 `requestUpdate()`를 트리거합니다:
240
+ ```ts
241
+ const prevCount = this.#activeCount;
242
+ this.#activeCount++;
243
+ if (prevCount === 0) {
244
+ this.#host.requestUpdate();
245
+ }
246
+ ```