@mandujs/core 0.23.0 → 0.25.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.
@@ -1,7 +1,7 @@
1
1
  import { getRenderToString } from "./react-renderer";
2
2
  import { serializeProps } from "../client/serialize";
3
3
  import { createRequire } from "module";
4
- import type { ReactElement } from "react";
4
+ import React, { type ReactElement, type ReactNode } from "react";
5
5
  import type { BundleManifest } from "../bundler/types";
6
6
  import { isSafeManduUrl } from "../bundler/manifest-schema";
7
7
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
@@ -9,6 +9,21 @@ import { PORTS, TIMEOUTS } from "../constants";
9
9
  import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
11
  import { generateFastRefreshPreamble } from "../bundler/dev";
12
+ import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
13
+
14
+ /**
15
+ * Issue #192 — `@view-transition` at-rule block.
16
+ * Inert in browsers without CSS View Transitions (Firefox, Safari < 18.0):
17
+ * the at-rule is simply ignored, so there is no regression. Supporting
18
+ * browsers (Chrome/Edge ≥ 111, Safari 18.2+) play the default crossfade
19
+ * between cross-document navigations.
20
+ *
21
+ * `navigation: auto` is the only value we need — selective transitions
22
+ * are a per-route concern reserved for a future `transitions` config
23
+ * sub-block.
24
+ */
25
+ const VIEW_TRANSITION_STYLE_TAG =
26
+ "<style>@view-transition{navigation:auto}</style>";
12
27
 
13
28
  // Re-export streaming SSR utilities
14
29
  export {
@@ -69,6 +84,57 @@ export interface SSROptions {
69
84
  * manifest entry — prod builds never emit the preamble at all.
70
85
  */
71
86
  cspNonce?: string | boolean;
87
+ /**
88
+ * Issue #192 — emit `<style>@view-transition{navigation:auto}</style>`
89
+ * into `<head>`. Supported browsers (Chrome/Edge ≥ 111, Safari 18.2+)
90
+ * show a default crossfade between cross-document navigations; others
91
+ * ignore the at-rule (no regression).
92
+ *
93
+ * Default: `true`. Pass `false` to suppress the injection — typically
94
+ * wired from `ManduConfig.transitions`.
95
+ */
96
+ transitions?: boolean;
97
+ /**
98
+ * Issue #192 — emit the ~500-byte hover prefetch helper (`<script>`)
99
+ * into `<head>`. Listens for `mouseover` on same-origin `<a href="/...">`
100
+ * anchors and issues `<link rel="prefetch" as="document">` once per
101
+ * unique target. Individual links can opt out via `data-no-prefetch`.
102
+ *
103
+ * Default: `true`. Pass `false` to suppress — typically wired from
104
+ * `ManduConfig.prefetch`.
105
+ */
106
+ prefetch?: boolean;
107
+ /**
108
+ * Issue #193 — control opt-out SPA navigation. When `true` (default)
109
+ * the client-side router intercepts every internal same-origin `<a>`
110
+ * click; when `false` the router reverts to the legacy opt-in
111
+ * behavior (only `<a data-mandu-link>` is intercepted).
112
+ *
113
+ * Wired from `ManduConfig.spa`. Emitted to the client as
114
+ * `window.__MANDU_SPA__ = false` ONLY when explicitly `false` — the
115
+ * default case emits nothing so the typical response payload is
116
+ * unchanged.
117
+ */
118
+ spa?: boolean;
119
+ /**
120
+ * Issue #191 — control dev-mode injection of the `_devtools.js` bundle
121
+ * (~1.15 MB React dev runtime + Mandu Kitchen panel).
122
+ *
123
+ * Three states:
124
+ * - `true` → force inject regardless of islands (explicit opt-in —
125
+ * use this for SSR-only projects that still want the
126
+ * Kitchen panel for local debugging).
127
+ * - `false` → force skip regardless of islands (explicit opt-out —
128
+ * disables Kitchen even for island projects).
129
+ * - unset → default. Inject iff the page renders at least one
130
+ * hydratable island. Pure-SSR pages (no islands) download
131
+ * zero devtools bytes.
132
+ *
133
+ * Wired from `ManduConfig.dev.devtools`. Only takes effect in dev mode
134
+ * (production builds omit the `_devtools.js` output entirely, so this
135
+ * flag is a no-op in prod regardless of value).
136
+ */
137
+ devtools?: boolean;
72
138
  }
73
139
 
74
140
  let projectRenderToString: ((element: ReactElement) => string) | null | undefined;
@@ -378,6 +444,99 @@ export function _testOnly_getAttachedCspNonce(options: SSROptions): string | und
378
444
  return OPTIONS_TO_NONCE.get(options as object);
379
445
  }
380
446
 
447
+ /**
448
+ * Issue #198 — Pre-resolve async server components before handing the
449
+ * element tree to React's synchronous `renderToString`.
450
+ *
451
+ * React 19 supports async components natively in `renderToReadableStream`
452
+ * but NOT in `renderToString`. When a user writes:
453
+ *
454
+ * export default async function Page() {
455
+ * const data = await fetch(...).then(r => r.json());
456
+ * return <h1>{data.title}</h1>;
457
+ * }
458
+ *
459
+ * `React.createElement(Page)` returns an element whose `type` is an async
460
+ * function. Passing it to `renderToString` yields the opaque error
461
+ * "async/await is not yet supported in Client Components, only Server
462
+ * Components" (or silently renders the Promise as `[object Promise]`
463
+ * on older React builds). This helper walks the element tree, invokes
464
+ * each async component, awaits the resolved React tree, and recursively
465
+ * resolves nested async components — producing a fully-synchronous tree
466
+ * that `renderToString` can handle.
467
+ *
468
+ * Design notes:
469
+ * - Only `typeof type === "function"` elements whose constructor is
470
+ * `AsyncFunction` are invoked. Regular sync function components pass
471
+ * through unchanged so React's normal render lifecycle (hooks,
472
+ * Suspense, etc.) stays intact during `renderToString`.
473
+ * - We recurse into `children` AND invoke async components whose
474
+ * returned tree itself contains more async components — common in
475
+ * `async Layout → async Page` nesting.
476
+ * - Arrays, fragments, portals, and forward-refs are handled by the
477
+ * same recursion (fragments and arrays expose children via props;
478
+ * forwardRef/memo wrappers surface the underlying component via
479
+ * `type.render` / `type.type`, which we do NOT unwrap — those are
480
+ * opaque to us and sync by construction).
481
+ * - If an async component throws, the rejection propagates up — the
482
+ * caller (renderSSR / renderStreamingResponse) wraps it with the
483
+ * existing `createSSRErrorResponse` 500 path. No new error surface.
484
+ *
485
+ * The helper returns a `ReactNode` (not strictly `ReactElement`) because
486
+ * async components may legitimately return primitives, arrays, null, or
487
+ * fragments.
488
+ */
489
+ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
490
+ // null | undefined | boolean | string | number — pass through. React
491
+ // treats these as leaf content.
492
+ if (node == null || typeof node !== "object") return node;
493
+
494
+ // Arrays (iterables of children) — resolve each entry in parallel.
495
+ // `Promise.all` is safe because order is preserved and async components
496
+ // are independent within an array.
497
+ if (Array.isArray(node)) {
498
+ return Promise.all(node.map((child) => resolveAsyncElement(child))) as Promise<ReactNode>;
499
+ }
500
+
501
+ // Non-element objects (Promises, iterables, etc.). React will handle
502
+ // Promises itself in streaming SSR, but for the sync path we forbid
503
+ // them — return as-is and let React error loudly.
504
+ if (!React.isValidElement(node)) return node;
505
+
506
+ const element = node as ReactElement;
507
+ const type = element.type;
508
+ const props = element.props as Record<string, unknown> | null | undefined;
509
+
510
+ // Async function component: invoke with props, await, recurse.
511
+ // `type.constructor.name === "AsyncFunction"` is the standard detection
512
+ // used throughout Mandu (see filling/filling.ts, runtime/compose.ts).
513
+ // We intentionally do NOT attempt to resolve generators or
514
+ // AsyncGeneratorFunctions — React has no semantics for those as
515
+ // components.
516
+ if (typeof type === "function" && (type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction") {
517
+ const resolved = await (type as (p: unknown) => Promise<ReactNode>)(props ?? {});
518
+ // Recurse — the resolved tree may itself contain more async components
519
+ // (e.g. async layout returning async page content).
520
+ return resolveAsyncElement(resolved);
521
+ }
522
+
523
+ // Sync element — recurse into children only. React handles sync
524
+ // function/class components itself during renderToString, so we
525
+ // do NOT invoke them here (that would disable their hooks, Context,
526
+ // Suspense boundaries, etc.).
527
+ if (!props) return element;
528
+ const rawChildren = props.children as ReactNode | undefined;
529
+ if (rawChildren === undefined) return element;
530
+
531
+ const resolvedChildren = await resolveAsyncElement(rawChildren);
532
+ if (resolvedChildren === rawChildren) return element;
533
+
534
+ // Clone with the resolved children. React.cloneElement preserves the
535
+ // element's key, ref, and internal `$$typeof` markers — a plain spread
536
+ // does not.
537
+ return React.cloneElement(element, undefined, resolvedChildren);
538
+ }
539
+
381
540
  export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
382
541
  const {
383
542
  title = "Mandu App",
@@ -394,6 +553,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
394
553
  routePattern,
395
554
  cssPath,
396
555
  islandPreWrapped,
556
+ transitions = true,
557
+ prefetch = true,
558
+ spa,
559
+ devtools,
397
560
  } = options;
398
561
 
399
562
  // CSS 링크 태그 생성
@@ -403,6 +566,20 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
403
566
  ? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
404
567
  : "";
405
568
 
569
+ // Issue #192 — Smooth navigation primitives.
570
+ // `transitions`: CSS `@view-transition { navigation: auto }` — inert in
571
+ // non-supporting browsers (Firefox, older Safari), crossfade in
572
+ // Chrome/Edge ≥ 111 and Safari 18.2+. Zero layout impact, ~70 bytes.
573
+ // `prefetch`: ~500-byte IIFE that listens for `mouseover` on internal
574
+ // `<a href="/...">` anchors and issues `<link rel="prefetch">`. Honors
575
+ // per-link `data-no-prefetch` opt-out.
576
+ // Position: immediately after `cssLinkTag` so that (a) the at-rule
577
+ // parses alongside the user stylesheet, and (b) both blocks precede
578
+ // user-owned `headTags` / `collectedHeadTags`, letting users override
579
+ // or cancel with a later inline style. False disables each independently.
580
+ const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
581
+ const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
582
+
406
583
  // useHead/useSeoMeta SSR 수집
407
584
  let collectedHeadTags = "";
408
585
  let headReset: (() => void) | undefined;
@@ -492,12 +669,26 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
492
669
  ? generateFastRefreshPreambleTag(isDev, bundleManifest, resolvedCspNonce)
493
670
  : "";
494
671
 
495
- // DevTools 번들 로드 (개발 모드)
672
+ // Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
673
+ // - 기본: island 이 하나라도 있을 때만 주입. Pure-SSR 페이지는 0 bytes 다운로드.
674
+ // - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen panel 원할 때)
675
+ // - `devtools === false` → 강제 스킵 (island 프로젝트에서도 Kitchen 비활성화)
676
+ // Cache-bust 은 `manifest.buildTime` 우선, 없으면 `Date.now()`.
496
677
  let devtoolsScript = "";
497
- if (isDev) {
498
- devtoolsScript = generateDevtoolsScript();
678
+ if (isDev && shouldInjectDevtools(devtools, bundleManifest)) {
679
+ devtoolsScript = generateDevtoolsScript(bundleManifest);
499
680
  }
500
681
 
682
+ // Issue #193 — surface `spa: false` to the client.
683
+ // We emit the global ONLY when explicitly `false` because `true` is
684
+ // the router's default, and a missing global is indistinguishable
685
+ // from `true` in the router's `handleLinkClick` check. This keeps
686
+ // the typical response payload (where `spa` is unset or `true`)
687
+ // byte-identical to pre-#193 output.
688
+ const spaFlagScript = spa === false
689
+ ? `<script>window.__MANDU_SPA__=false;</script>`
690
+ : "";
691
+
501
692
  // #179: body 내 <link> 태그를 <head>로 호이스팅
502
693
  // React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
503
694
  // 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
@@ -516,6 +707,8 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
516
707
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
517
708
  <title>${escapeHtmlText(title)}</title>
518
709
  ${cssLinkTag}
710
+ ${viewTransitionTag}
711
+ ${prefetchScriptTag}
519
712
  ${hoistedLinkTags}
520
713
  ${headTags}
521
714
  ${collectedHeadTags}
@@ -527,6 +720,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
527
720
  ${routeScript}
528
721
  ${hydrationScripts}
529
722
  ${needsHydration ? REACT_INTERNALS_SHIM_SCRIPT : ""}
723
+ ${spaFlagScript}
530
724
  ${routerScript}
531
725
  ${hmrScript}
532
726
  ${devtoolsScript}
@@ -729,13 +923,75 @@ window.__MANDU_HMR_PORT__ = ${hmrPort};
729
923
  }
730
924
 
731
925
  /**
732
- * DevTools 번들 로드 스크립트 생성 (개발 모드 전용)
733
- * _devtools.js 번들이 자체적으로 initManduKitchen() 호출
926
+ * Issue #191 Determine whether the dev-only `_devtools.js` bundle
927
+ * (~1.15 MB React dev runtime + Kitchen panel) should be injected
928
+ * into the HTML response.
929
+ *
930
+ * Decision table (`devtools` option × manifest shape):
931
+ *
932
+ * | `devtools` | hasIslands | inject? | rationale |
933
+ * |-------------|------------|---------|-----------------------------|
934
+ * | `true` | any | YES | explicit opt-in |
935
+ * | `false` | any | NO | explicit opt-out |
936
+ * | `undefined` | true | YES | default, hydration runtime |
937
+ * | `undefined` | false | NO | pure-SSR — save 1.15 MB |
938
+ * | `undefined` | no manifest| NO | nothing to hydrate anyway |
939
+ *
940
+ * `hasIslands` is derived from the existing manifest shape rather than
941
+ * a new field, so no bundler-side change is required:
942
+ * - `manifest.islands` is populated only when per-island code
943
+ * splitting produced at least one bundle (build.ts:1654).
944
+ * - `manifest.bundles` entries exist only for routes where
945
+ * `needsHydration()` is true (build.ts:70 filter).
946
+ * Either non-empty ⇒ some route on this server hydrates ⇒ devtools useful.
947
+ *
948
+ * @internal Exported via `_testOnly_shouldInjectDevtools` below so
949
+ * `tests/runtime/devtools-inject.test.ts` can table-test the matrix
950
+ * without mounting React.
951
+ */
952
+ function shouldInjectDevtools(
953
+ devtools: boolean | undefined,
954
+ manifest: BundleManifest | undefined,
955
+ ): boolean {
956
+ // Explicit overrides take absolute precedence.
957
+ if (devtools === true) return true;
958
+ if (devtools === false) return false;
959
+
960
+ // Default behavior: inject only when there is at least one island.
961
+ if (!manifest) return false;
962
+ const hasIslandsMap =
963
+ manifest.islands && Object.keys(manifest.islands).length > 0;
964
+ const hasBundles =
965
+ manifest.bundles && Object.keys(manifest.bundles).length > 0;
966
+ return Boolean(hasIslandsMap || hasBundles);
967
+ }
968
+
969
+ /**
970
+ * Issue #191 — DevTools 번들 로드 스크립트 생성 (개발 모드 전용).
971
+ *
972
+ * `_devtools.js` 번들이 자체적으로 `initManduKitchen()` 을 호출한다.
973
+ *
974
+ * Cache-bust: `?v=${manifest.buildTime}` 을 우선 사용하고, manifest 가 없으면
975
+ * `?t=${Date.now()}` 로 fallback. `buildTime` 은 빌드별로 고정이라 브라우저
976
+ * 캐시 효율이 좋지만, 동일 빌드 안에서 HMR 이 발생하더라도 devtools 번들은
977
+ * dev 서버의 static 응답이 `Cache-Control: no-cache, no-store, must-revalidate`
978
+ * (server.ts:1104) 를 보내므로 stale 위험이 없다.
979
+ *
980
+ * @internal Exported via `_testOnly_generateDevtoolsScript` below so tests
981
+ * can verify the URL shape without needing a full render.
734
982
  */
735
- function generateDevtoolsScript(): string {
736
- return `<script type="module" src="/.mandu/client/_devtools.js"></script>`;
983
+ function generateDevtoolsScript(manifest?: BundleManifest): string {
984
+ const cacheBust = manifest?.buildTime
985
+ ? `?v=${encodeURIComponent(manifest.buildTime)}`
986
+ : `?t=${Date.now()}`;
987
+ return `<script type="module" src="/.mandu/client/_devtools.js${cacheBust}"></script>`;
737
988
  }
738
989
 
990
+ /** @internal test helper — exposed only so unit tests can inspect the decision. */
991
+ export const _testOnly_shouldInjectDevtools = shouldInjectDevtools;
992
+ /** @internal test helper — exposed only so unit tests can inspect the script tag. */
993
+ export const _testOnly_generateDevtoolsScript = generateDevtoolsScript;
994
+
739
995
  export function createHTMLResponse(
740
996
  html: string,
741
997
  status: number = 200,
@@ -24,6 +24,16 @@ import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
24
24
  import { getRenderToString } from "./react-renderer";
25
25
  import { mark, measure } from "../perf";
26
26
  import { generateFastRefreshPreamble } from "../bundler/dev";
27
+ import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
28
+
29
+ /**
30
+ * Issue #192 — `@view-transition` at-rule, mirror of the constant in
31
+ * `./ssr.ts`. Duplicated here to keep the streaming path self-contained
32
+ * without a cross-module runtime import cycle (ssr.ts already re-exports
33
+ * streaming-ssr.ts). Both constants MUST stay byte-identical.
34
+ */
35
+ const VIEW_TRANSITION_STYLE_TAG =
36
+ "<style>@view-transition{navigation:auto}</style>";
27
37
 
28
38
  // ========== Types ==========
29
39
 
@@ -154,8 +164,67 @@ export interface StreamingSSROptions {
154
164
  * Dev + hydration + populated `shared.fastRefresh` only.
155
165
  */
156
166
  cspNonce?: string | boolean;
167
+ /**
168
+ * Issue #192 — emit `<style>@view-transition{navigation:auto}</style>`
169
+ * into the streaming shell `<head>`. Mirrors `SSROptions.transitions`.
170
+ * Default: `true`.
171
+ */
172
+ transitions?: boolean;
173
+ /**
174
+ * Issue #192 — emit the ~500-byte hover prefetch helper `<script>`
175
+ * into the streaming shell `<head>`. Mirrors `SSROptions.prefetch`.
176
+ * Default: `true`.
177
+ */
178
+ prefetch?: boolean;
179
+ /**
180
+ * Issue #191 — control dev-mode injection of the `_devtools.js`
181
+ * bundle. Mirrors `SSROptions.devtools`:
182
+ * - `true` → force inject (explicit opt-in)
183
+ * - `false` → force skip (explicit opt-out)
184
+ * - `undefined` → default; inject iff the manifest has islands.
185
+ */
186
+ devtools?: boolean;
157
187
  }
158
188
 
189
+ /**
190
+ * Issue #191 — Streaming-SSR mirror of `ssr.ts:shouldInjectDevtools`.
191
+ * Kept in sync manually (tiny pure function, not worth a cross-module
192
+ * runtime import — the ssr.ts → streaming-ssr.ts re-export direction
193
+ * means a circular import here would force a refactor of the whole
194
+ * module graph). The unit test suite table-tests both implementations
195
+ * against the same matrix so drift is caught at CI time.
196
+ */
197
+ function shouldInjectDevtoolsStreaming(
198
+ devtools: boolean | undefined,
199
+ manifest: BundleManifest | undefined,
200
+ ): boolean {
201
+ if (devtools === true) return true;
202
+ if (devtools === false) return false;
203
+ if (!manifest) return false;
204
+ const hasIslandsMap =
205
+ manifest.islands && Object.keys(manifest.islands).length > 0;
206
+ const hasBundles =
207
+ manifest.bundles && Object.keys(manifest.bundles).length > 0;
208
+ return Boolean(hasIslandsMap || hasBundles);
209
+ }
210
+
211
+ /**
212
+ * Issue #191 — Streaming-SSR mirror of `ssr.ts:generateDevtoolsScript`.
213
+ * Emits `<script type="module" src="/.mandu/client/_devtools.js?v=BUILD">`
214
+ * with a `buildTime`-keyed cache-bust (or `Date.now()` fallback).
215
+ */
216
+ function generateStreamingDevtoolsScript(manifest: BundleManifest | undefined): string {
217
+ const cacheBust = manifest?.buildTime
218
+ ? `?v=${encodeURIComponent(manifest.buildTime)}`
219
+ : `?t=${Date.now()}`;
220
+ return `<script type="module" src="/.mandu/client/_devtools.js${cacheBust}"></script>`;
221
+ }
222
+
223
+ /** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */
224
+ export const _testOnly_shouldInjectDevtoolsStreaming = shouldInjectDevtoolsStreaming;
225
+ /** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */
226
+ export const _testOnly_generateStreamingDevtoolsScript = generateStreamingDevtoolsScript;
227
+
159
228
  export interface StreamingLoaderResult<T = unknown> {
160
229
  /** 즉시 로드할 Critical 데이터 */
161
230
  critical?: T;
@@ -462,6 +531,8 @@ function generateHTMLShell(options: StreamingSSROptions): string {
462
531
  hydration,
463
532
  cssPath,
464
533
  isDev = false,
534
+ transitions = true,
535
+ prefetch = true,
465
536
  } = options;
466
537
 
467
538
  // CSS 링크 태그 생성
@@ -471,6 +542,14 @@ function generateHTMLShell(options: StreamingSSROptions): string {
471
542
  ? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
472
543
  : "";
473
544
 
545
+ // Issue #192 — Smooth navigation primitives. Mirror of the block in
546
+ // `ssr.ts::renderToHTML`; see that call-site for the full rationale.
547
+ // Positioned right after the user stylesheet so the at-rule parses
548
+ // alongside it, and before user `headTags` so users can override with
549
+ // an inline style later in the document order.
550
+ const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
551
+ const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
552
+
474
553
  // Island wrapper (hydration이 필요한 경우)
475
554
  const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
476
555
 
@@ -549,6 +628,8 @@ function generateHTMLShell(options: StreamingSSROptions): string {
549
628
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
550
629
  <title>${escapeHtmlText(title)}</title>
551
630
  ${cssLinkTag}
631
+ ${viewTransitionTag}
632
+ ${prefetchScriptTag}
552
633
  ${loadingStyles}
553
634
  ${importMapScript}
554
635
  ${headTags}
@@ -572,6 +653,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
572
653
  hmrPort,
573
654
  enableClientRouter = false,
574
655
  hydration,
656
+ devtools,
575
657
  } = options;
576
658
 
577
659
  const scripts: string[] = [];
@@ -658,9 +740,13 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
658
740
  scripts.push(generateHMRScript(hmrPort));
659
741
  }
660
742
 
661
- // 11. DevTools 번들 로드 (개발 모드)
662
- if (isDev) {
663
- scripts.push(`<script type="module" src="/.mandu/client/_devtools.js"></script>`);
743
+ // 11. Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
744
+ // - 기본: manifest 에 island/bundle 이 있을 때만 주입 (pure-SSR 페이지는 스킵).
745
+ // - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen 원할 때).
746
+ // - `devtools === false` → 강제 스킵.
747
+ // - Cache-bust (`?v=buildTime`) 로 HMR 후 stale 방지.
748
+ if (isDev && shouldInjectDevtoolsStreaming(devtools, bundleManifest)) {
749
+ scripts.push(generateStreamingDevtoolsScript(bundleManifest));
664
750
  }
665
751
 
666
752
  // Island wrapper 닫기 (hydration이 필요한 경우)
@@ -926,7 +1012,17 @@ export async function renderToStream(
926
1012
  let shellSent = false;
927
1013
  let timedOut = false;
928
1014
 
929
- // React renderToReadableStream 호출
1015
+ // Issue #198 — `renderToReadableStream` natively supports async server
1016
+ // components in React 19. If `export default async function Page()`
1017
+ // lands here directly (without going through `server.ts`'s
1018
+ // `resolveAsyncElement` pre-pass), React's streaming pipeline
1019
+ // suspends on the awaiting component and flushes a fallback until
1020
+ // the promise resolves. `collectStreamingHeadTags` above uses the
1021
+ // sync `renderToString` and will throw on async trees — its
1022
+ // try/catch safely returns an empty string in that case, and any
1023
+ // `useHead`-pushed tags from async components are instead picked
1024
+ // up by `buildHtmlTail` on the way out. No additional wiring is
1025
+ // needed on this code path.
930
1026
  // 실패 시 throw → renderStreamingResponse에서 500 처리
931
1027
  const renderToReadableStream = getRenderToReadableStream();
932
1028
  const reactStream = await renderToReadableStream(element, {