@pyreon/runtime-server 0.15.0 → 0.18.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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +43 -8
- package/package.json +3 -3
- package/src/index.ts +84 -9
- package/src/tests/ssr.test.ts +145 -1
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"2a32a0fd-1"}]}],"isRoot":true},"nodeParts":{"2a32a0fd-1":{"renderedLength":16920,"gzipLength":5559,"brotliLength":0,"metaUid":"2a32a0fd-0"}},"nodeMetas":{"2a32a0fd-0":{"id":"/src/index.ts","moduleParts":{"index.js":"2a32a0fd-1"},"imported":[{"uid":"2a32a0fd-2"},{"uid":"2a32a0fd-3"}],"importedBy":[],"isEntry":true},"2a32a0fd-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a32a0fd-0"}]},"2a32a0fd-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a32a0fd-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import { ForSymbol, Fragment, Suspense, cx, makeReactiveProps, normalizeStyleValue, runWithHooks, setContextStackProvider } from "@pyreon/core";
|
|
2
|
+
import { ForSymbol, Fragment, Suspense, captureContextStack, cx, makeReactiveProps, normalizeStyleValue, popContext, runWithHooks, setContextStackProvider } from "@pyreon/core";
|
|
3
3
|
|
|
4
4
|
//#region src/index.ts
|
|
5
5
|
/**
|
|
@@ -116,6 +116,7 @@ async function streamComponentNode(vnode, enqueue) {
|
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.component");
|
|
119
|
+
const stackLenBefore = captureContextStack().length;
|
|
119
120
|
try {
|
|
120
121
|
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
121
122
|
const resolved = output instanceof Promise ? await output : output;
|
|
@@ -128,6 +129,8 @@ async function streamComponentNode(vnode, enqueue) {
|
|
|
128
129
|
const ctx = _streamCtxAls.getStore();
|
|
129
130
|
if (ctx && ctx.suspenseDepth > 0) throw err;
|
|
130
131
|
enqueue("<!--pyreon-error-->");
|
|
132
|
+
} finally {
|
|
133
|
+
trimContextStack(stackLenBefore);
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
async function streamElementNode(vnode, enqueue) {
|
|
@@ -182,11 +185,18 @@ async function streamSuspenseBoundary(vnode, enqueue) {
|
|
|
182
185
|
if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.suspense.boundary");
|
|
183
186
|
const ctx = _streamCtxAls.getStore();
|
|
184
187
|
const { fallback, children } = vnode.props;
|
|
188
|
+
/* c8 ignore start */
|
|
185
189
|
if (!ctx) {
|
|
190
|
+
const stackLenBefore = captureContextStack().length;
|
|
186
191
|
const { vnode: output } = runWithHooks(Suspense, vnode.props);
|
|
187
|
-
|
|
192
|
+
try {
|
|
193
|
+
if (output !== null) await streamNode(output, enqueue);
|
|
194
|
+
} finally {
|
|
195
|
+
trimContextStack(stackLenBefore);
|
|
196
|
+
}
|
|
188
197
|
return;
|
|
189
198
|
}
|
|
199
|
+
/* c8 ignore stop */
|
|
190
200
|
const id = ctx.nextId();
|
|
191
201
|
const { mainEnqueue } = ctx;
|
|
192
202
|
if (id === 0) mainEnqueue(SUSPENSE_SWAP_FN);
|
|
@@ -250,14 +260,39 @@ async function renderChildren(children) {
|
|
|
250
260
|
}
|
|
251
261
|
async function renderComponent(vnode) {
|
|
252
262
|
if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.component");
|
|
263
|
+
const stackLenBefore = captureContextStack().length;
|
|
253
264
|
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
265
|
+
let html;
|
|
266
|
+
try {
|
|
267
|
+
if (output instanceof Promise) {
|
|
268
|
+
const resolved = await output;
|
|
269
|
+
html = resolved === null ? "" : await renderNode(resolved);
|
|
270
|
+
} else if (output === null) html = "";
|
|
271
|
+
else html = await renderNode(output);
|
|
272
|
+
} finally {
|
|
273
|
+
trimContextStack(stackLenBefore);
|
|
274
|
+
}
|
|
275
|
+
return html;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Pop context frames pushed during a component's render. Trims the global
|
|
279
|
+
* context stack back to the snapshot length captured before the component
|
|
280
|
+
* ran. Each iteration calls `popContext` once — symmetric with `provide()`'s
|
|
281
|
+
* `pushContext()` so frames pop in LIFO order even when a single component
|
|
282
|
+
* called `provide()` multiple times.
|
|
283
|
+
*
|
|
284
|
+
* Why not run user unmount hooks here? See `renderComponent` for the full
|
|
285
|
+
* architectural rationale — TL;DR: SSR has no unmount phase, `useHead` uses
|
|
286
|
+
* `onUnmount` to clear head entries that the post-render extraction still
|
|
287
|
+
* needs, etc. The structural fix is to clean up the ONE SSR-visible side
|
|
288
|
+
* effect of `provide()` (its context frame) without firing other hooks.
|
|
289
|
+
*/
|
|
290
|
+
function trimContextStack(targetLen) {
|
|
291
|
+
let current = captureContextStack().length;
|
|
292
|
+
while (current > targetLen) {
|
|
293
|
+
popContext();
|
|
294
|
+
current--;
|
|
258
295
|
}
|
|
259
|
-
if (output === null) return "";
|
|
260
|
-
return renderNode(output);
|
|
261
296
|
}
|
|
262
297
|
async function renderElement(vnode) {
|
|
263
298
|
const tag = vnode.type;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "SSR/SSG renderer for Pyreon — streaming HTML + static generation",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-server#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"prepublishOnly": "bun run build"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@pyreon/core": "^0.
|
|
47
|
-
"@pyreon/reactivity": "^0.
|
|
46
|
+
"@pyreon/core": "^0.18.0",
|
|
47
|
+
"@pyreon/reactivity": "^0.18.0"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
17
17
|
import type { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from '@pyreon/core'
|
|
18
18
|
import {
|
|
19
|
+
captureContextStack,
|
|
19
20
|
cx,
|
|
20
21
|
ForSymbol,
|
|
21
22
|
Fragment,
|
|
22
23
|
makeReactiveProps,
|
|
23
24
|
normalizeStyleValue,
|
|
25
|
+
popContext,
|
|
24
26
|
runWithHooks,
|
|
25
27
|
Suspense,
|
|
26
28
|
setContextStackProvider,
|
|
@@ -187,8 +189,19 @@ async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void):
|
|
|
187
189
|
return
|
|
188
190
|
}
|
|
189
191
|
if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.component')
|
|
192
|
+
// Snapshot the context stack BEFORE the component renders so we can pop
|
|
193
|
+
// any frames pushed via `provide()` after children stream. We do NOT run
|
|
194
|
+
// user-registered unmount hooks during SSR — that would clear state still
|
|
195
|
+
// needed by post-render extraction (e.g. `useHead` uses `onUnmount` to
|
|
196
|
+
// remove its registered tags from the head store; running it during SSR
|
|
197
|
+
// wipes the entries before `renderWithHead` reads them). See `renderComponent`
|
|
198
|
+
// for the full architectural rationale.
|
|
199
|
+
const stackLenBefore = captureContextStack().length
|
|
190
200
|
try {
|
|
191
|
-
const { vnode: output } = runWithHooks(
|
|
201
|
+
const { vnode: output } = runWithHooks(
|
|
202
|
+
vnode.type as ComponentFn,
|
|
203
|
+
mergeChildrenIntoProps(vnode),
|
|
204
|
+
)
|
|
192
205
|
const resolved = output instanceof Promise ? await output : output
|
|
193
206
|
if (resolved !== null) await streamNode(resolved, enqueue)
|
|
194
207
|
} catch (err) {
|
|
@@ -202,6 +215,8 @@ async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void):
|
|
|
202
215
|
const ctx = _streamCtxAls.getStore()
|
|
203
216
|
if (ctx && ctx.suspenseDepth > 0) throw err
|
|
204
217
|
enqueue('<!--pyreon-error-->')
|
|
218
|
+
} finally {
|
|
219
|
+
trimContextStack(stackLenBefore)
|
|
205
220
|
}
|
|
206
221
|
}
|
|
207
222
|
|
|
@@ -282,12 +297,28 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
|
|
|
282
297
|
const ctx = _streamCtxAls.getStore()
|
|
283
298
|
const { fallback, children } = vnode.props as { fallback: VNodeChild; children?: VNodeChild }
|
|
284
299
|
|
|
285
|
-
//
|
|
300
|
+
// Defensive: the streaming pipeline only enters this function via
|
|
301
|
+
// `_streamCtxAls.run(ctx, ...)` (set up in `renderToStream`), so `ctx`
|
|
302
|
+
// is always defined when `streamSuspenseBoundary` runs. Kept as a safety
|
|
303
|
+
// net in case a future entry point bypasses the streaming context — the
|
|
304
|
+
// block performs the same context-stack hygiene as `renderComponent` /
|
|
305
|
+
// `streamComponentNode` so a Suspense `provide()` wouldn't leak into
|
|
306
|
+
// siblings if it ever fires. Excluded from coverage because no public-API
|
|
307
|
+
// path reaches it; including a unit test would either require exporting
|
|
308
|
+
// `streamSuspenseBoundary` (leaks an internal) or stubbing the ALS (false
|
|
309
|
+
// signal).
|
|
310
|
+
/* c8 ignore start */
|
|
286
311
|
if (!ctx) {
|
|
312
|
+
const stackLenBefore = captureContextStack().length
|
|
287
313
|
const { vnode: output } = runWithHooks(Suspense as ComponentFn, vnode.props)
|
|
288
|
-
|
|
314
|
+
try {
|
|
315
|
+
if (output !== null) await streamNode(output, enqueue)
|
|
316
|
+
} finally {
|
|
317
|
+
trimContextStack(stackLenBefore)
|
|
318
|
+
}
|
|
289
319
|
return
|
|
290
320
|
}
|
|
321
|
+
/* c8 ignore stop */
|
|
291
322
|
|
|
292
323
|
const id = ctx.nextId()
|
|
293
324
|
const { mainEnqueue } = ctx
|
|
@@ -408,17 +439,61 @@ async function renderChildren(children: VNodeChild[]): Promise<string> {
|
|
|
408
439
|
|
|
409
440
|
async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<string> {
|
|
410
441
|
if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.component')
|
|
442
|
+
// Snapshot the context stack length BEFORE the component renders. After
|
|
443
|
+
// children render, trim back to this length — that pops every frame the
|
|
444
|
+
// component pushed via `provide(ctx, value)` (which calls `pushContext` +
|
|
445
|
+
// registers `onUnmount(popContext)`). Without this, every provider during
|
|
446
|
+
// SSR leaks its frame onto the global stack and subsequent siblings see
|
|
447
|
+
// the wrong context value (Bug 4 — bokisch.com `<PyreonUI inversed>`
|
|
448
|
+
// inside `<Intro>` flipped every later section to dark).
|
|
449
|
+
//
|
|
450
|
+
// We trim the stack DIRECTLY instead of running the component's unmount
|
|
451
|
+
// hooks because users register `onUnmount` for things still load-bearing
|
|
452
|
+
// at post-render time during SSR — `useHead({ title })` uses `onUnmount`
|
|
453
|
+
// to remove its head entries, and running it here wipes the head store
|
|
454
|
+
// before `renderWithHead` extracts it. SSR has no real "unmount" phase
|
|
455
|
+
// (the response ships, the process moves on); user-registered cleanup
|
|
456
|
+
// is for the CSR lifecycle. `provide()`'s frame cleanup is the only
|
|
457
|
+
// SSR-visible side effect and we handle it structurally below.
|
|
458
|
+
const stackLenBefore = captureContextStack().length
|
|
411
459
|
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode))
|
|
412
460
|
|
|
413
461
|
// Async component function (async function Component()) — await the promise
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (
|
|
417
|
-
|
|
462
|
+
let html: string
|
|
463
|
+
try {
|
|
464
|
+
if (output instanceof Promise) {
|
|
465
|
+
const resolved = await output
|
|
466
|
+
html = resolved === null ? '' : await renderNode(resolved)
|
|
467
|
+
} else if (output === null) {
|
|
468
|
+
html = ''
|
|
469
|
+
} else {
|
|
470
|
+
html = await renderNode(output)
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
trimContextStack(stackLenBefore)
|
|
418
474
|
}
|
|
475
|
+
return html
|
|
476
|
+
}
|
|
419
477
|
|
|
420
|
-
|
|
421
|
-
|
|
478
|
+
/**
|
|
479
|
+
* Pop context frames pushed during a component's render. Trims the global
|
|
480
|
+
* context stack back to the snapshot length captured before the component
|
|
481
|
+
* ran. Each iteration calls `popContext` once — symmetric with `provide()`'s
|
|
482
|
+
* `pushContext()` so frames pop in LIFO order even when a single component
|
|
483
|
+
* called `provide()` multiple times.
|
|
484
|
+
*
|
|
485
|
+
* Why not run user unmount hooks here? See `renderComponent` for the full
|
|
486
|
+
* architectural rationale — TL;DR: SSR has no unmount phase, `useHead` uses
|
|
487
|
+
* `onUnmount` to clear head entries that the post-render extraction still
|
|
488
|
+
* needs, etc. The structural fix is to clean up the ONE SSR-visible side
|
|
489
|
+
* effect of `provide()` (its context frame) without firing other hooks.
|
|
490
|
+
*/
|
|
491
|
+
function trimContextStack(targetLen: number): void {
|
|
492
|
+
let current = captureContextStack().length
|
|
493
|
+
while (current > targetLen) {
|
|
494
|
+
popContext()
|
|
495
|
+
current--
|
|
496
|
+
}
|
|
422
497
|
}
|
|
423
498
|
|
|
424
499
|
async function renderElement(vnode: VNode): Promise<string> {
|
package/src/tests/ssr.test.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { ComponentFn, VNode } from '@pyreon/core'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
For,
|
|
5
|
+
Fragment,
|
|
6
|
+
h,
|
|
7
|
+
onUnmount,
|
|
8
|
+
provide,
|
|
9
|
+
pushContext,
|
|
10
|
+
Suspense,
|
|
11
|
+
useContext,
|
|
12
|
+
} from '@pyreon/core'
|
|
3
13
|
import { signal } from '@pyreon/reactivity'
|
|
4
14
|
import {
|
|
5
15
|
configureStoreIsolation,
|
|
@@ -1106,6 +1116,140 @@ describe('renderToString — For key markers', () => {
|
|
|
1106
1116
|
|
|
1107
1117
|
// ─── For SSR — key markers in stream ─────────────────────────────────────────
|
|
1108
1118
|
|
|
1119
|
+
// ─── Bug 4: SSR provide() context cleanup across siblings ───────────────────
|
|
1120
|
+
//
|
|
1121
|
+
// Regression: pre-fix, `renderComponent` invoked `runWithHooks(...)` to render
|
|
1122
|
+
// each component but DESTRUCTURED only the vnode — never invoked the
|
|
1123
|
+
// component's unmount hooks. `provide(context, value)` registers
|
|
1124
|
+
// `onUnmount(popContext)` to clean up its pushed context frame on unmount.
|
|
1125
|
+
// Without unmount-hook invocation during SSR, every `provide()` call left
|
|
1126
|
+
// its context frame on the global stack permanently. Subsequent siblings
|
|
1127
|
+
// saw the leaked context value instead of the outer provider's value.
|
|
1128
|
+
//
|
|
1129
|
+
// Real-world manifestation (bokisch.com): a `<PyreonUI inversed>` inside an
|
|
1130
|
+
// `<Intro>` section flipped mode to dark and pushed it as context. After
|
|
1131
|
+
// Intro rendered, every subsequent section (`<Quote>`, `<Companies>`, etc.)
|
|
1132
|
+
// saw the inverted dark mode → all sections rendered in dark even though
|
|
1133
|
+
// the page was in light mode → wrong colors everywhere.
|
|
1134
|
+
|
|
1135
|
+
describe('SSR — provide() context cleanup across siblings (Bug 4)', () => {
|
|
1136
|
+
test('sibling AFTER a provide() call sees the OUTER context value, not the leak', async () => {
|
|
1137
|
+
const Ctx = createContext('outer')
|
|
1138
|
+
|
|
1139
|
+
// Component that pushes a NEW context value via `provide()`.
|
|
1140
|
+
const Inner: ComponentFn = () => {
|
|
1141
|
+
provide(Ctx, 'inner')
|
|
1142
|
+
return h('span', { 'data-testid': 'inner' }, () => useContext(Ctx))
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Sibling rendered AFTER Inner — should see 'outer', not 'inner'.
|
|
1146
|
+
const Sibling: ComponentFn = () => {
|
|
1147
|
+
return h('span', { 'data-testid': 'sibling' }, () => useContext(Ctx))
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Outer wrapper: <div><Inner /><Sibling /></div>. Pre-fix, Sibling
|
|
1151
|
+
// sees 'inner' because Inner's provide() was never popped.
|
|
1152
|
+
const App: ComponentFn = () => {
|
|
1153
|
+
return h(Fragment, null, h(Inner, null), h(Sibling, null))
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const html = await renderToString(h(App, null))
|
|
1157
|
+
|
|
1158
|
+
expect(html).toContain('data-testid="inner">inner<')
|
|
1159
|
+
expect(html).toContain('data-testid="sibling">outer<') // not "inner"
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
test('multiple sequential provide() calls each clean up their own frame', async () => {
|
|
1163
|
+
const Ctx = createContext('default')
|
|
1164
|
+
|
|
1165
|
+
const First: ComponentFn = () => {
|
|
1166
|
+
provide(Ctx, 'first')
|
|
1167
|
+
return h('span', { 'data-testid': 'first' }, () => useContext(Ctx))
|
|
1168
|
+
}
|
|
1169
|
+
const Second: ComponentFn = () => {
|
|
1170
|
+
provide(Ctx, 'second')
|
|
1171
|
+
return h('span', { 'data-testid': 'second' }, () => useContext(Ctx))
|
|
1172
|
+
}
|
|
1173
|
+
const Third: ComponentFn = () => {
|
|
1174
|
+
return h('span', { 'data-testid': 'third' }, () => useContext(Ctx))
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const html = await renderToString(
|
|
1178
|
+
h(Fragment, null, h(First, null), h(Second, null), h(Third, null)),
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
expect(html).toContain('data-testid="first">first<')
|
|
1182
|
+
expect(html).toContain('data-testid="second">second<')
|
|
1183
|
+
// Third sees 'default' — no leakage from First or Second.
|
|
1184
|
+
expect(html).toContain('data-testid="third">default<')
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
test('nested provide() — child sees parent provide, sibling outside sees outer', async () => {
|
|
1188
|
+
const Ctx = createContext('outer')
|
|
1189
|
+
|
|
1190
|
+
const InnerChild: ComponentFn = () =>
|
|
1191
|
+
h('span', { 'data-testid': 'inner-child' }, () => useContext(Ctx))
|
|
1192
|
+
|
|
1193
|
+
const Provider: ComponentFn = () => {
|
|
1194
|
+
provide(Ctx, 'provider-value')
|
|
1195
|
+
return h('div', null, h(InnerChild, null))
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const Sibling: ComponentFn = () =>
|
|
1199
|
+
h('span', { 'data-testid': 'sibling' }, () => useContext(Ctx))
|
|
1200
|
+
|
|
1201
|
+
const html = await renderToString(
|
|
1202
|
+
h(Fragment, null, h(Provider, null), h(Sibling, null)),
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
// Inner child sees the provider's value (correct).
|
|
1206
|
+
expect(html).toContain('data-testid="inner-child">provider-value<')
|
|
1207
|
+
// Sibling outside the provider sees outer (must NOT see leaked
|
|
1208
|
+
// provider-value).
|
|
1209
|
+
expect(html).toContain('data-testid="sibling">outer<')
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
// Bug 4 follow-up: the FIRST attempt at the fix (running runUnmountHooks
|
|
1213
|
+
// during SSR) overshot — it fired ALL user-registered onUnmount hooks,
|
|
1214
|
+
// not just `provide()`'s `popContext`. That broke @pyreon/head, where
|
|
1215
|
+
// `useHead({ title })` registers `onUnmount(() => removeFromHeadStore())`
|
|
1216
|
+
// to clean up on CSR unmount; running it during SSR cleared the head
|
|
1217
|
+
// store BEFORE `renderWithHead` extracted it → 38 head tests failed,
|
|
1218
|
+
// every SSR'd page lost its <title>/<meta>/<link> tags.
|
|
1219
|
+
//
|
|
1220
|
+
// Architectural rule: SSR has no real unmount phase (the response
|
|
1221
|
+
// ships, the process moves on). User-registered `onUnmount` hooks are
|
|
1222
|
+
// for the CSR lifecycle. The ONE SSR-visible side effect of `provide()`
|
|
1223
|
+
// is its context frame, and we clean that up structurally (snapshot +
|
|
1224
|
+
// trim the stack) without firing user hooks.
|
|
1225
|
+
test('user-registered onUnmount hooks DO NOT fire during SSR (head store contract)', async () => {
|
|
1226
|
+
// Simulate `useHead({ title })`-style registration: register an entry
|
|
1227
|
+
// in a per-render store, register `onUnmount` to clean it up. Real
|
|
1228
|
+
// `@pyreon/head` does this via a context'd HeadStore; we use a
|
|
1229
|
+
// module-local for the test.
|
|
1230
|
+
const store: { title?: string } = {}
|
|
1231
|
+
|
|
1232
|
+
const TitleRegister: ComponentFn = () => {
|
|
1233
|
+
store.title = 'Hello SSR'
|
|
1234
|
+
onUnmount(() => {
|
|
1235
|
+
delete store.title
|
|
1236
|
+
})
|
|
1237
|
+
return null
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const App: ComponentFn = () =>
|
|
1241
|
+
h('html', null, h('head', null, h(TitleRegister, null)), h('body', null))
|
|
1242
|
+
|
|
1243
|
+
await renderToString(h(App, null))
|
|
1244
|
+
|
|
1245
|
+
// After SSR completes, the head-style store entry MUST still be
|
|
1246
|
+
// present — `renderWithHead` and similar post-render extractors
|
|
1247
|
+
// need it. If `runUnmountHooks` were called here, `store.title`
|
|
1248
|
+
// would be undefined.
|
|
1249
|
+
expect(store.title).toBe('Hello SSR')
|
|
1250
|
+
})
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1109
1253
|
describe('renderToStream — For key markers', () => {
|
|
1110
1254
|
test('emits key markers for each item in stream', async () => {
|
|
1111
1255
|
const items = [
|