@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.
@@ -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":"ffdc8719-1"}]}],"isRoot":true},"nodeParts":{"ffdc8719-1":{"renderedLength":15632,"gzipLength":5082,"brotliLength":0,"metaUid":"ffdc8719-0"}},"nodeMetas":{"ffdc8719-0":{"id":"/src/index.ts","moduleParts":{"index.js":"ffdc8719-1"},"imported":[{"uid":"ffdc8719-2"},{"uid":"ffdc8719-3"}],"importedBy":[],"isEntry":true},"ffdc8719-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"ffdc8719-0"}]},"ffdc8719-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"ffdc8719-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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
- if (output !== null) await streamNode(output, enqueue);
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
- if (output instanceof Promise) {
255
- const resolved = await output;
256
- if (resolved === null) return "";
257
- return renderNode(resolved);
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.15.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.15.0",
47
- "@pyreon/reactivity": "^0.15.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(vnode.type as ComponentFn, mergeChildrenIntoProps(vnode))
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
- // No streaming context (e.g. called from renderToString) — render children inline
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
- if (output !== null) await streamNode(output, enqueue)
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
- if (output instanceof Promise) {
415
- const resolved = await output
416
- if (resolved === null) return ''
417
- return renderNode(resolved)
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
- if (output === null) return ''
421
- return renderNode(output)
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> {
@@ -1,5 +1,15 @@
1
1
  import type { ComponentFn, VNode } from '@pyreon/core'
2
- import { createContext, For, Fragment, h, pushContext, Suspense, useContext } from '@pyreon/core'
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 = [