@jogak/ui 0.1.0-alpha.7 → 0.1.0-alpha.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jogak/ui",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.8",
4
4
  "description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
5
5
  "keywords": [
6
6
  "jogak",
@@ -65,8 +65,8 @@
65
65
  "prism-react-renderer": "^2.4.1",
66
66
  "tailwindcss": "^4.0.0",
67
67
  "@tailwindcss/vite": "^4.0.0",
68
- "@jogak/core": "0.1.0-alpha.7",
69
- "@jogak/react": "0.1.0-alpha.7"
68
+ "@jogak/core": "0.1.0-alpha.8",
69
+ "@jogak/react": "0.1.0-alpha.8"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^20.14.0",
package/src/app/App.tsx CHANGED
@@ -21,15 +21,20 @@ export interface JogakAppProps {
21
21
  readonly metas?: readonly RegistryEntryMeta[]
22
22
  readonly codeTheme?: string
23
23
  /**
24
- * 알파.7: Preview 영역 격리 모드. default `'none'`.
24
+ * 알파.8: Preview 영역 격리 모드. default `'iframe'`.
25
25
  *
26
- * - `'none'` — Preview 콘텐츠를 chrome 같은 document렌더 (알파.6까지의 동작).
27
- * - `'shadow'` — ShadowRoot 안에 마운트. 사용자 globalCss/reset이 chrome 침범 차단.
28
- * - `'iframe'` — 별도 document(iframe)마운트. 가장 강한 격리.
26
+ * - `'iframe'` (default) 사용자 vite 정상 client(iframe)마운트. 사용자 utility 정상 컴파일.
27
+ * - `'shadow'` (deprecated) — ShadowRoot 안에 마운트. 사용자 utility 미적용.
28
+ * - `'none'` (deprecated) chrome 같은 document에 렌더. 알파.6까지의 동작.
29
29
  *
30
30
  * 자세한 트레이드오프는 `@jogak/ui` README의 "previewIsolation 사용 가이드" 참조.
31
31
  */
32
32
  readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
33
+ /**
34
+ * 알파.8: 사용자 vite spawn URL. iframe `src` base로 사용.
35
+ * 빈 문자열 시 fallback (jogak SPA Vite scope의 preview-frame.tsx).
36
+ */
37
+ readonly userViteUrl?: string
33
38
  }
34
39
 
35
40
  function readUrlParams(): { entryId: string; jogakName: string | null } | null {
@@ -52,7 +57,8 @@ export function JogakApp({
52
57
  entries,
53
58
  metas,
54
59
  codeTheme = 'vsDark',
55
- previewIsolation = 'none',
60
+ previewIsolation = 'iframe',
61
+ userViteUrl = '',
56
62
  }: JogakAppProps = {}): ReactElement {
57
63
  // ── 4가지 모드 결정 (계약 §5.2) ─────────────────────────────────────
58
64
  // 1) entries가 주어지면: 새 ComponentRegistry에 register (eager, 기존 동작)
@@ -156,6 +162,7 @@ export function JogakApp({
156
162
  codeTheme={codeTheme}
157
163
  onResolveJogak={handleResolveJogak}
158
164
  previewIsolation={previewIsolation}
165
+ userViteUrl={userViteUrl}
159
166
  />
160
167
  ) : (
161
168
  <div className="jogak:flex jogak:items-center jogak:justify-center jogak:h-full jogak:text-[var(--jogak-color-fg-subtle)]">
package/src/app/main.tsx CHANGED
@@ -1,16 +1,23 @@
1
1
  import { StrictMode } from 'react'
2
2
  import { createRoot } from 'react-dom/client'
3
3
  import 'virtual:jogak'
4
- import { _jogakCodeTheme, _jogakPreviewIsolation } from 'virtual:jogak'
4
+ import {
5
+ _jogakCodeTheme,
6
+ _jogakPreviewIsolation,
7
+ _jogakUserViteUrl,
8
+ } from 'virtual:jogak'
5
9
  import '../styles/jogak.css'
6
- // 알파.6: 사용자 globalCss opt-in.
7
- // JogakPluginOptions.globalCss=false (default) → 빈 모듈 (no-op, SPA 번들 영향 zero).
8
- // true / string / string[] → plugin이 사용자 css를 import한다.
9
- // jogak.css 뒤에 둬서 사용자가 jogak chrome 기본값을 명시적으로 override 가능 —
10
- // 단, jogak utility는 prefix=jogak로 격리되어 사용자 utility와 충돌하지 않는다.
11
- import 'virtual:jogak/global-css'
12
10
  import { JogakApp } from './App.js'
13
11
 
12
+ // 알파.8: 사용자 globalCss는 사용자 vite scope(iframe entry)에서 처리되므로
13
+ // jogak SPA outer document에는 import하지 않는다 — chrome 격리 보존.
14
+ //
15
+ // 'none' 모드(deprecated): 알파.7.1 동작 유지가 필요한 사용자만 명시 사용.
16
+ // 이 경우만 outer document에 사용자 globalCss inject.
17
+ if (_jogakPreviewIsolation === 'none') {
18
+ await import('virtual:jogak/global-css')
19
+ }
20
+
14
21
  const rootEl = document.getElementById('root')
15
22
  if (rootEl === null) throw new Error('#root element not found')
16
23
 
@@ -19,6 +26,7 @@ createRoot(rootEl).render(
19
26
  <JogakApp
20
27
  codeTheme={_jogakCodeTheme}
21
28
  previewIsolation={_jogakPreviewIsolation}
29
+ userViteUrl={_jogakUserViteUrl}
22
30
  />
23
31
  </StrictMode>,
24
32
  )
@@ -1,79 +1,92 @@
1
- import { useEffect, useRef } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import type { ReactElement } from 'react'
3
3
  import type { RegistryEntry } from '@jogak/core'
4
4
 
5
5
  export interface IframeMountProps {
6
6
  readonly entry: RegistryEntry
7
7
  readonly args: Readonly<Record<string, unknown>>
8
+ /**
9
+ * 알파.8: 사용자 vite spawn URL (예: `http://localhost:5174`).
10
+ * 빈 문자열 시 fallback (jogak SPA Vite scope의 `/preview-frame.html`).
11
+ */
12
+ readonly userViteUrl: string
8
13
  readonly className?: string
9
14
  readonly 'data-testid'?: string
10
15
  }
11
16
 
12
- interface SetPropsArgs {
13
- readonly entry: RegistryEntry
14
- readonly args: Readonly<Record<string, unknown>>
15
- }
16
-
17
- declare global {
18
- interface Window {
19
- __jogak_setProps__?: (args: SetPropsArgs) => void
20
- __jogak_unmount__?: () => void
21
- }
22
- }
23
-
24
17
  /**
25
- * 알파.7: previewIsolation='iframe' 모드의 mount 컴포넌트.
18
+ * 알파.8: previewIsolation='iframe' 모드의 mount 컴포넌트.
26
19
  *
27
- * - `<iframe src="/preview-frame.html">`을 마운트.
28
- * - iframe load `iframe.contentWindow.__jogak_setProps__({ entry, args })`를
29
- * 호출해 entry/args를 주입한다 (postMessage 미사용 — 동일 origin이므로
30
- * contentWindow 직접 접근 가능).
31
- * - entry/args 변경 시 setProps 재호출 (load 완료 이후).
20
+ * 통신:
21
+ * - 사용자 vite spawn URL이 주어지면(`userViteUrl !== ''`) iframe src를
22
+ * `${userViteUrl}/__jogak_preview__/index.html` (cross-origin)로 설정.
23
+ * - 동일 origin fallback 시 `/preview-frame.html` (jogak SPA Vite scope).
32
24
  *
33
- * HMR:
34
- * - iframe document 자체도 Vite dev server module을 import하므로 사용자 컴포넌트
35
- * 파일 변경 fast refresh가 iframe 안에서 작동.
36
- * - previewIsolation 모드 자체 변경은 가상 모듈 invalidate → full reload.
25
+ * 양쪽 모두 postMessage로 통신:
26
+ * - 부모 → iframe: `{ type: 'jogak:setProps', entryId, args }` | `{ type: 'jogak:unmount' }`
27
+ * - iframe 부모: `{ type: 'jogak:ready' }` | `{ type: 'jogak:rendered', entryId }`
37
28
  *
38
- * sandbox 미설정:
39
- * - 사용자 컴포넌트가 fetch/clipboard/storage 자유롭게 사용해야 하므로 sandbox X.
29
+ * `entry`는 객체가 아닌 **id만 전달** — iframe 안에서 `defaultRegistry.requestEntry(id)`로
30
+ * dynamic import. 사용자 vite scope의 entry 가상 모듈이 사용자 컴포넌트를 fetch하므로
31
+ * 사용자 plugins(@tailwindcss/vite, custom alias 등)이 정상 작동.
40
32
  */
41
33
  export function IframeMount({
42
34
  entry,
43
35
  args,
36
+ userViteUrl,
44
37
  className,
45
38
  'data-testid': dataTestId,
46
39
  }: IframeMountProps): ReactElement {
47
40
  const iframeRef = useRef<HTMLIFrameElement | null>(null)
48
- const readyRef = useRef(false)
41
+ const [ready, setReady] = useState(false)
42
+
43
+ const src =
44
+ userViteUrl !== ''
45
+ ? `${userViteUrl}/__jogak_preview__/index.html`
46
+ : '/preview-frame.html'
49
47
 
50
- // iframe load첫 setProps
48
+ // postMessage 리스너 — iframe contentWindow 일치성 검증 처리.
51
49
  useEffect(() => {
52
- const iframe = iframeRef.current
53
- if (iframe === null) return
54
- const handleLoad = (): void => {
55
- readyRef.current = true
56
- iframe.contentWindow?.__jogak_setProps__?.({ entry, args })
50
+ const handler = (event: MessageEvent): void => {
51
+ const iframe = iframeRef.current
52
+ if (iframe === null) return
53
+ if (event.source !== iframe.contentWindow) return
54
+ const data = event.data
55
+ if (data == null || typeof data !== 'object') return
56
+ if (data.type === 'jogak:ready') setReady(true)
57
57
  }
58
- iframe.addEventListener('load', handleLoad)
58
+ window.addEventListener('message', handler)
59
59
  return () => {
60
- iframe.removeEventListener('load', handleLoad)
61
- // unmount 시 iframe 안 react root도 정리 (best-effort)
62
- iframe.contentWindow?.__jogak_unmount__?.()
60
+ window.removeEventListener('message', handler)
63
61
  }
64
- // eslint-disable-next-line react-hooks/exhaustive-deps
65
62
  }, [])
66
63
 
67
- // entry/args 변경 시 setProps 재호출 (load 후에만)
64
+ // iframe ready 또는 entry/args 변경 시 setProps.
68
65
  useEffect(() => {
69
- if (!readyRef.current) return
70
- iframeRef.current?.contentWindow?.__jogak_setProps__?.({ entry, args })
71
- }, [entry, args])
66
+ if (!ready) return
67
+ const iframe = iframeRef.current
68
+ if (iframe === null) return
69
+ iframe.contentWindow?.postMessage(
70
+ { type: 'jogak:setProps', entryId: entry.id, args },
71
+ '*',
72
+ )
73
+ }, [ready, entry, args])
74
+
75
+ // unmount 시 unmount 메시지 (race 회피 microtask defer).
76
+ useEffect(() => {
77
+ const iframe = iframeRef.current
78
+ return () => {
79
+ if (iframe === null) return
80
+ queueMicrotask(() => {
81
+ iframe.contentWindow?.postMessage({ type: 'jogak:unmount' }, '*')
82
+ })
83
+ }
84
+ }, [])
72
85
 
73
86
  return (
74
87
  <iframe
75
88
  ref={iframeRef}
76
- src="/preview-frame.html"
89
+ src={src}
77
90
  title="Preview"
78
91
  className={className}
79
92
  data-testid={dataTestId}
@@ -6,26 +6,25 @@ export interface ShadowMountProps {
6
6
  readonly children: ReactNode
7
7
  readonly className?: string
8
8
  readonly style?: CSSProperties
9
- /** 외부 테스트 hook (호스트 div에 부여). */
10
9
  readonly 'data-testid'?: string
11
10
  }
12
11
 
13
12
  /**
14
- * 알파.7: previewIsolation='shadow' 모드의 mount 컴포넌트.
13
+ * 알파.7.1: previewIsolation='shadow' 모드의 mount 컴포넌트.
15
14
  *
16
- * 역할:
17
- * - 호스트 `<div>`에 `attachShadow({ mode: 'open' })`로 ShadowRoot를 부착.
18
- * - `createPortal`로 children을 ShadowRoot에 렌더 (React tree 유지).
19
- * - 외부 document의 모든 `<style>` / cross-origin 가능 stylesheet를
20
- * `adoptedStyleSheets`로 ShadowRoot에 share (jogak.css + virtual:jogak/global-css
21
- * 둘 다 자동 포함).
22
- * - Vite dev에서 `<style>` HMR 시 MutationObserver로 ShadowRoot도 갱신.
15
+ * 책임: 양방향 격리만 제공 (Preview ↔ outer document 양방향 cascade 차단).
16
+ * - 사용자 globalCss는 main.tsx 가드로 outer document에 inject되지 않음.
17
+ * - shadow root 안에는 jogak chrome css도 사용자 css도 없음 ( 외부에서 격리).
18
+ * - 사용자 컴포넌트의 utility class 컴파일은 결함 B (알파.8 사이클).
23
19
  *
24
- * Radix portal 한계 (사용자 인지 필요):
25
- * - 사용자 컴포넌트가 `Dialog.Portal` / `Popover.Portal` 등을 default로 쓰면
26
- * portal target`document.body` ShadowRoot 외부. utility class는 외부
27
- * document에 정의되어 적용됨, z-index/focus/event boundary가 분리될 있음.
28
- * - 회피: 사용자가 명시적으로 `<Portal container={shadowRootEl}>`을 전달.
20
+ * 알파.7 결함 정정:
21
+ * - `syncStyleSheets`/`MutationObserver`/`adoptedStyleSheets` 흡수 로직 제거.
22
+ * 알파.7outer document 사용자 css가 있는 chrome을 침범했고, shadow
23
+ * 안의 흡수 로직은 의미가 없었음. 알파.7.1: outer에 사용자 css 자체가 없음.
24
+ *
25
+ * Radix portal 한계 (사용자 인지 필요, README 명시):
26
+ * - default Portal target = document.body (shadow 외부). 사용자가 명시적으로
27
+ * `<Portal container={shadowRootEl}>`을 전달해야 portal 내용도 격리됨.
29
28
  */
30
29
  export function ShadowMount({
31
30
  children,
@@ -39,16 +38,9 @@ export function ShadowMount({
39
38
  useEffect(() => {
40
39
  const host = hostRef.current
41
40
  if (host === null) return
42
- let sr: ShadowRoot
43
- if (host.shadowRoot !== null) {
44
- sr = host.shadowRoot
45
- } else {
46
- sr = host.attachShadow({ mode: 'open' })
47
- }
41
+ const sr = host.shadowRoot ?? host.attachShadow({ mode: 'open' })
48
42
  setShadowRoot(sr)
49
- syncStyleSheets(sr)
50
- const observer = observeDocumentStyles(sr)
51
- return () => { observer.disconnect() }
43
+ // shadow root는 host element와 함께 GC — 명시 detach 불필요.
52
44
  }, [])
53
45
 
54
46
  return (
@@ -63,39 +55,3 @@ export function ShadowMount({
63
55
  </div>
64
56
  )
65
57
  }
66
-
67
- /**
68
- * 외부 document의 모든 stylesheet를 ShadowRoot에 share한다.
69
- *
70
- * - `adoptedStyleSheets` API를 사용 (Chromium/Safari/Firefox 모던 브라우저 지원).
71
- * - cross-origin sheet는 `cssRules` 접근 시 SecurityError → catch 후 skip.
72
- * - Vite dev의 `<style>` HMR이 자주 발생할 수 있어 `replaceSync`를 통한 새
73
- * `CSSStyleSheet` 인스턴스 생성으로 처리.
74
- */
75
- function syncStyleSheets(shadowRoot: ShadowRoot): void {
76
- const sheets: CSSStyleSheet[] = []
77
- for (const sheet of Array.from(document.styleSheets)) {
78
- try {
79
- const rules = sheet.cssRules
80
- const cs = new CSSStyleSheet()
81
- const cssText = Array.from(rules).map((r) => r.cssText).join('\n')
82
- cs.replaceSync(cssText)
83
- sheets.push(cs)
84
- } catch {
85
- // cross-origin sheet — skip
86
- }
87
- }
88
- shadowRoot.adoptedStyleSheets = sheets
89
- }
90
-
91
- /**
92
- * document.head의 변화(`<style>` HMR add/remove)를 관찰해 ShadowRoot의
93
- * adoptedStyleSheets를 다시 sync.
94
- */
95
- function observeDocumentStyles(shadowRoot: ShadowRoot): MutationObserver {
96
- const observer = new MutationObserver(() => {
97
- syncStyleSheets(shadowRoot)
98
- })
99
- observer.observe(document.head, { childList: true, subtree: true })
100
- return observer
101
- }
@@ -24,13 +24,18 @@ export interface PreviewProps {
24
24
  */
25
25
  readonly onResolveJogak?: (entryId: string, jogakName: string) => void
26
26
  /**
27
- * 알파.7: Preview 영역 격리 모드. default `'none'`.
27
+ * 알파.8: Preview 영역 격리 모드. default `'iframe'`.
28
28
  *
29
- * - `'none'` — 기존 동작 (chrome과 같은 document, 알파.6 chrome 보호 rule 적용).
30
- * - `'shadow'` — ShadowRoot에 마운트. 사용자 globalCss reset이 chrome 침범 차단.
31
- * - `'iframe'` — `/preview-frame.html` iframe마운트. 강한 격리.
29
+ * - `'iframe'` (default) 사용자 vite scope에 마운트. 사용자 utility 정상 컴파일.
30
+ * - `'shadow'` (deprecated) — ShadowRoot에 마운트. 사용자 utility 미적용.
31
+ * - `'none'` (deprecated) chrome과 같은 document렌더.
32
32
  */
33
33
  readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
34
+ /**
35
+ * 알파.8: 사용자 vite spawn URL. iframe `src` base.
36
+ * 빈 문자열 시 fallback (jogak SPA Vite scope의 `/preview-frame.html`).
37
+ */
38
+ readonly userViteUrl?: string
34
39
  }
35
40
 
36
41
  type ViewportKey = 'mobile' | 'tablet' | 'desktop'
@@ -120,7 +125,8 @@ export function Preview({
120
125
  onReset,
121
126
  codeTheme,
122
127
  onResolveJogak,
123
- previewIsolation = 'none',
128
+ previewIsolation = 'iframe',
129
+ userViteUrl = '',
124
130
  }: PreviewProps): ReactElement {
125
131
  const state = useEntry(entryId)
126
132
  const [viewport, setViewport] = useState<ViewportKey>('desktop')
@@ -187,6 +193,7 @@ export function Preview({
187
193
  onBottomTabChange={setBottomTab}
188
194
  prismTheme={prismTheme}
189
195
  previewIsolation={previewIsolation}
196
+ userViteUrl={userViteUrl}
190
197
  />
191
198
  )
192
199
  }
@@ -275,6 +282,7 @@ interface ReadyFrameProps {
275
282
  readonly onBottomTabChange: (tab: 'controls' | 'actions') => void
276
283
  readonly prismTheme: PrismTheme
277
284
  readonly previewIsolation: 'none' | 'shadow' | 'iframe'
285
+ readonly userViteUrl: string
278
286
  }
279
287
 
280
288
  function ReadyFrame({
@@ -292,6 +300,7 @@ function ReadyFrame({
292
300
  onBottomTabChange,
293
301
  prismTheme,
294
302
  previewIsolation,
303
+ userViteUrl,
295
304
  }: ReadyFrameProps): ReactElement {
296
305
  // jogakName이 비어있으면 (deep link `?entry=...&jogak` 누락) 첫 jogak로 보정.
297
306
  const resolvedJogakName = jogakName ?? entry.jogaks[0]?.name ?? null
@@ -364,6 +373,7 @@ function ReadyFrame({
364
373
  source={entry.source}
365
374
  theme={prismTheme}
366
375
  previewIsolation={previewIsolation}
376
+ userViteUrl={userViteUrl}
367
377
  />
368
378
  </div>
369
379
  </div>
@@ -514,18 +524,19 @@ interface JogakRendererProps {
514
524
  readonly source: string | undefined
515
525
  readonly theme: PrismTheme
516
526
  readonly previewIsolation: 'none' | 'shadow' | 'iframe'
527
+ readonly userViteUrl: string
517
528
  }
518
529
 
519
530
  /**
520
- * 알파.7: previewIsolation 모드별로 사용자 콘텐츠 마운트 방식을 분기한다.
531
+ * 알파.8: previewIsolation 모드별로 사용자 콘텐츠 마운트 방식을 분기한다.
521
532
  *
522
- * - `'none'` — 같은 document에 직접 마운트 (알파.6까지의 동작 그대로).
523
- * - `'shadow'` — `<ShadowMount>` 안에 마운트해 ShadowRoot 격리.
524
- * - `'iframe'` — `<IframeMount>`로 별도 document에 마운트.
533
+ * - `'iframe'` (default) 사용자 vite scope의 `<IframeMount>`로 별도 document.
534
+ * - `'shadow'` (deprecated) — `<ShadowMount>` 안에 마운트.
535
+ * - `'none'` (deprecated) 같은 document에 직접 마운트.
525
536
  *
526
537
  * Show source 토글, 코드 패널 등 chrome 부분은 모드 무관하게 외부에 둔다.
527
538
  */
528
- function JogakRenderer({ entry, args, source, theme, previewIsolation }: JogakRendererProps): ReactElement {
539
+ function JogakRenderer({ entry, args, source, theme, previewIsolation, userViteUrl }: JogakRendererProps): ReactElement {
529
540
  const [showCode, setShowCode] = useState(false)
530
541
 
531
542
  const previewBody = (
@@ -534,6 +545,7 @@ function JogakRenderer({ entry, args, source, theme, previewIsolation }: JogakRe
534
545
  entry={entry}
535
546
  args={args}
536
547
  previewIsolation={previewIsolation}
548
+ userViteUrl={userViteUrl}
537
549
  />
538
550
  <button
539
551
  type="button"
@@ -575,13 +587,14 @@ interface PreviewMountProps {
575
587
  readonly entry: RegistryEntry
576
588
  readonly args: Readonly<Record<string, unknown>>
577
589
  readonly previewIsolation: 'none' | 'shadow' | 'iframe'
590
+ readonly userViteUrl: string
578
591
  }
579
592
 
580
593
  const PREVIEW_HOST_CLASS =
581
594
  'jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] ' +
582
595
  'jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:pb-9'
583
596
 
584
- function PreviewMount({ entry, args, previewIsolation }: PreviewMountProps): ReactElement {
597
+ function PreviewMount({ entry, args, previewIsolation, userViteUrl }: PreviewMountProps): ReactElement {
585
598
  if (previewIsolation === 'shadow') {
586
599
  return (
587
600
  <ShadowMount
@@ -598,13 +611,14 @@ function PreviewMount({ entry, args, previewIsolation }: PreviewMountProps): Rea
598
611
  <IframeMount
599
612
  entry={entry}
600
613
  args={args}
614
+ userViteUrl={userViteUrl}
601
615
  data-testid="preview-content"
602
616
  className={`${PREVIEW_HOST_CLASS} jogak:block jogak:w-full jogak:bg-transparent jogak:min-h-[256px]`}
603
617
  />
604
618
  )
605
619
  }
606
620
 
607
- // 'none' — 기존 동작 그대로
621
+ // 'none' — deprecated 경로 (알파.7.1 동등 동작 보존, back-compat)
608
622
  return <NoneAdapterContent entry={entry} args={args} />
609
623
  }
610
624
 
@@ -615,7 +629,11 @@ function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Reado
615
629
  const container = containerRef.current
616
630
  if (container === null) return
617
631
  reactAdapter.render(entry, args, container)
618
- return () => { reactAdapter.unmount(container) }
632
+ return () => {
633
+ // 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
634
+ // 회피 — fiber commit 끝난 직후로 defer.
635
+ queueMicrotask(() => { reactAdapter.unmount(container) })
636
+ }
619
637
  // eslint-disable-next-line react-hooks/exhaustive-deps
620
638
  }, [entry])
621
639
 
@@ -645,7 +663,10 @@ function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Rea
645
663
  const c = ref.current
646
664
  if (c === null) return
647
665
  reactAdapter.render(entry, args, c)
648
- return () => { reactAdapter.unmount(c) }
666
+ return () => {
667
+ // 알파.7.1: unmount race 회피
668
+ queueMicrotask(() => { reactAdapter.unmount(c) })
669
+ }
649
670
  // eslint-disable-next-line react-hooks/exhaustive-deps
650
671
  }, [entry])
651
672
 
package/src/vite-env.d.ts CHANGED
@@ -4,10 +4,15 @@ declare module 'virtual:jogak' {
4
4
  /** 플러그인 설정에서 지정한 prism-react-renderer 테마 이름 */
5
5
  export const _jogakCodeTheme: string
6
6
  /**
7
- * 알파.7: Preview 영역 격리 모드 ('none' | 'shadow' | 'iframe').
8
- * `JogakPluginOptions.previewIsolation` (default 'none')의 literal emit.
7
+ * 알파.8: Preview 영역 격리 모드 ('none' | 'shadow' | 'iframe').
8
+ * `JogakPluginOptions.previewIsolation` (default 'iframe')의 literal emit.
9
9
  */
10
10
  export const _jogakPreviewIsolation: 'none' | 'shadow' | 'iframe'
11
+ /**
12
+ * 알파.8: 사용자 vite spawn URL. iframe `src` base로 사용 (예: `http://localhost:5174`).
13
+ * 빈 문자열 시 fallback (jogak SPA Vite scope의 preview-frame.tsx).
14
+ */
15
+ export const _jogakUserViteUrl: string
11
16
  }
12
17
 
13
18
  declare module 'virtual:jogak/global-css' {