@mandujs/core 0.19.2 → 0.20.1

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.
@@ -15,12 +15,12 @@ import React, { Suspense } from "react";
15
15
  import type { BundleManifest } from "../bundler/types";
16
16
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
17
17
  import { serializeProps } from "../client/serialize";
18
- import type { Metadata, MetadataItem } from "../seo/types";
19
- import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
20
- import { PORTS, TIMEOUTS } from "../constants";
21
- import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript, escapeJsString } from "./escape";
22
- import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
23
- import { getRenderToString } from "./react-renderer";
18
+ import type { Metadata, MetadataItem } from "../seo/types";
19
+ import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
20
+ import { PORTS, TIMEOUTS } from "../constants";
21
+ import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript, escapeJsString } from "./escape";
22
+ import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
23
+ import { getRenderToString } from "./react-renderer";
24
24
 
25
25
  // ========== Types ==========
26
26
 
@@ -704,16 +704,35 @@ export async function renderToStream(
704
704
  streamingWarnings.markWarned();
705
705
  }
706
706
 
707
- const encoder = new TextEncoder();
708
- const collectedHeadTags = collectStreamingHeadTags(element);
709
- const resolvedOptions = collectedHeadTags
710
- ? { ...options, headTags: [options.headTags, collectedHeadTags].filter(Boolean).join("\n") }
711
- : options;
712
- const htmlShell = generateHTMLShell(resolvedOptions);
713
- // _skipHtmlClose가 true이면 </body></html> 생략 (deferred 스크립트 삽입용)
714
- const htmlTail = resolvedOptions._skipHtmlClose
715
- ? generateHTMLTailContent(resolvedOptions)
716
- : generateHTMLTail(resolvedOptions);
707
+ const encoder = new TextEncoder();
708
+ const collectedHeadTags = collectStreamingHeadTags(element);
709
+ const resolvedOptions = collectedHeadTags
710
+ ? { ...options, headTags: [options.headTags, collectedHeadTags].filter(Boolean).join("\n") }
711
+ : options;
712
+ const htmlShell = generateHTMLShell(resolvedOptions);
713
+
714
+ // Reset SSR head before real render so streaming components push fresh tags
715
+ try {
716
+ const headMod = require("../client/use-head") as { resetSSRHead?: () => void; getSSRHeadTags?: () => string };
717
+ headMod.resetSSRHead?.();
718
+ } catch {}
719
+
720
+ // Lazy tail: collect late head tags injected during streaming render
721
+ function buildHtmlTail(): string {
722
+ const baseTail = resolvedOptions._skipHtmlClose
723
+ ? generateHTMLTailContent(resolvedOptions)
724
+ : generateHTMLTail(resolvedOptions);
725
+ try {
726
+ const headMod = require("../client/use-head") as { getSSRHeadTags?: () => string };
727
+ const lateHeadTags = headMod.getSSRHeadTags?.() ?? "";
728
+ if (lateHeadTags) {
729
+ const escaped = escapeJsonForInlineScript(JSON.stringify(lateHeadTags));
730
+ const script = `<script>(function(){var t=${escaped},d=document.createElement('div');d.innerHTML=t;var h=document.head;while(d.firstChild)h.appendChild(d.firstChild);})()</script>`;
731
+ return script + baseTail;
732
+ }
733
+ } catch {}
734
+ return baseTail;
735
+ }
717
736
 
718
737
  let shellSent = false;
719
738
  let timedOut = false;
@@ -832,7 +851,7 @@ export async function renderToStream(
832
851
  controller.enqueue(encoder.encode(generateErrorScript(timeoutError, routeId)));
833
852
 
834
853
  if (!tailSent) {
835
- controller.enqueue(encoder.encode(htmlTail));
854
+ controller.enqueue(encoder.encode(buildHtmlTail()));
836
855
  tailSent = true;
837
856
  metrics.allReadyTime = Date.now() - metrics.startTime;
838
857
  onMetrics?.(metrics);
@@ -851,7 +870,7 @@ export async function renderToStream(
851
870
 
852
871
  if (done) {
853
872
  if (!tailSent) {
854
- controller.enqueue(encoder.encode(htmlTail));
873
+ controller.enqueue(encoder.encode(buildHtmlTail()));
855
874
  tailSent = true;
856
875
  // allReady가 아직 안 끝났을 수 있으므로 현재 시점으로 기록
857
876
  if (metrics.allReadyTime === 0) {
@@ -884,7 +903,7 @@ export async function renderToStream(
884
903
  controller.enqueue(encoder.encode(generateErrorScript(err, routeId)));
885
904
 
886
905
  if (!tailSent) {
887
- controller.enqueue(encoder.encode(htmlTail));
906
+ controller.enqueue(encoder.encode(buildHtmlTail()));
888
907
  tailSent = true;
889
908
  metrics.allReadyTime = Date.now() - metrics.startTime;
890
909
  onMetrics?.(metrics);
@@ -901,23 +920,23 @@ export async function renderToStream(
901
920
  }
902
921
  } catch {}
903
922
  },
904
- });
905
- }
906
-
907
- function collectStreamingHeadTags(element: ReactElement): string {
908
- try {
909
- const mod = require("../client/use-head") as {
910
- resetSSRHead?: () => void;
911
- getSSRHeadTags?: () => string;
912
- };
913
- mod.resetSSRHead?.();
914
- const renderToString = getRenderToString();
915
- renderToString(element);
916
- return mod.getSSRHeadTags?.() ?? "";
917
- } catch {
918
- return "";
919
- }
920
- }
923
+ });
924
+ }
925
+
926
+ function collectStreamingHeadTags(element: ReactElement): string {
927
+ try {
928
+ const mod = require("../client/use-head") as {
929
+ resetSSRHead?: () => void;
930
+ getSSRHeadTags?: () => string;
931
+ };
932
+ mod.resetSSRHead?.();
933
+ const renderToString = getRenderToString();
934
+ renderToString(element);
935
+ return mod.getSSRHeadTags?.() ?? "";
936
+ } catch {
937
+ return "";
938
+ }
939
+ }
921
940
 
922
941
  /**
923
942
  * Streaming SSR Response 생성
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { ManduContext } from "../filling/context";
7
7
  import type { ManduFilling } from "../filling/filling";
8
+ import type { RouteSpec, RoutesManifest } from "../spec/schema";
8
9
 
9
10
  // ========== Types ==========
10
11
 
@@ -142,3 +143,47 @@ export function createTestContext(
142
143
  const request = createTestRequest(path, options);
143
144
  return new ManduContext(request, options.params);
144
145
  }
146
+
147
+ // ========== Test Factories ==========
148
+
149
+ /**
150
+ * Create a RoutesManifest from partial route definitions.
151
+ * Fills in sensible defaults so tests only specify the fields they care about.
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * const manifest = createTestManifest([
156
+ * { id: "home", kind: "page", pattern: "/" },
157
+ * { id: "api-users", kind: "api", pattern: "/api/users" },
158
+ * ]);
159
+ * ```
160
+ */
161
+ export function createTestManifest(routes: Partial<RouteSpec>[]): RoutesManifest {
162
+ return {
163
+ version: 1,
164
+ routes: routes.map((r, i) => ({
165
+ id: r.id ?? `test-route-${i}`,
166
+ kind: r.kind ?? "page",
167
+ pattern: r.pattern ?? `/test-${i}`,
168
+ module: r.module ?? `app/test-${i}/page.tsx`,
169
+ componentModule:
170
+ (r.kind ?? "page") === "page"
171
+ ? (r.componentModule ?? r.module ?? `app/test-${i}/page.tsx`)
172
+ : undefined,
173
+ ...r,
174
+ })) as RouteSpec[],
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Create a minimal island descriptor for testing hydration logic.
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * const island = createTestIsland("counter", "interaction");
184
+ * expect(island.__hydrate).toBe("interaction");
185
+ * ```
186
+ */
187
+ export function createTestIsland(name: string, strategy: string = "visible") {
188
+ return { __island: true, __hydrate: strategy, __name: name };
189
+ }