@pyreon/head 0.20.0 → 0.21.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","children":[{"uid":"c3ae39c2-1","name":"context.ts"},{"uid":"c3ae39c2-3","name":"provider.ts"},{"uid":"c3ae39c2-5","name":"dom.ts"},{"uid":"c3ae39c2-7","name":"use-head.ts"},{"uid":"c3ae39c2-9","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"c3ae39c2-1":{"renderedLength":1373,"gzipLength":509,"brotliLength":0,"metaUid":"c3ae39c2-0"},"c3ae39c2-3":{"renderedLength":681,"gzipLength":408,"brotliLength":0,"metaUid":"c3ae39c2-2"},"c3ae39c2-5":{"renderedLength":3447,"gzipLength":1292,"brotliLength":0,"metaUid":"c3ae39c2-4"},"c3ae39c2-7":{"renderedLength":2634,"gzipLength":1093,"brotliLength":0,"metaUid":"c3ae39c2-6"},"c3ae39c2-9":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"c3ae39c2-8"}},"nodeMetas":{"c3ae39c2-0":{"id":"/src/context.ts","moduleParts":{"index.js":"c3ae39c2-1"},"imported":[{"uid":"c3ae39c2-10"}],"importedBy":[{"uid":"c3ae39c2-8"},{"uid":"c3ae39c2-2"},{"uid":"c3ae39c2-6"}]},"c3ae39c2-2":{"id":"/src/provider.ts","moduleParts":{"index.js":"c3ae39c2-3"},"imported":[{"uid":"c3ae39c2-10"},{"uid":"c3ae39c2-0"}],"importedBy":[{"uid":"c3ae39c2-8"}]},"c3ae39c2-4":{"id":"/src/dom.ts","moduleParts":{"index.js":"c3ae39c2-5"},"imported":[],"importedBy":[{"uid":"c3ae39c2-6"}]},"c3ae39c2-6":{"id":"/src/use-head.ts","moduleParts":{"index.js":"c3ae39c2-7"},"imported":[{"uid":"c3ae39c2-10"},{"uid":"c3ae39c2-11"},{"uid":"c3ae39c2-0"},{"uid":"c3ae39c2-4"}],"importedBy":[{"uid":"c3ae39c2-8"}]},"c3ae39c2-8":{"id":"/src/index.ts","moduleParts":{"index.js":"c3ae39c2-9"},"imported":[{"uid":"c3ae39c2-0"},{"uid":"c3ae39c2-2"},{"uid":"c3ae39c2-6"}],"importedBy":[],"isEntry":true},"c3ae39c2-10":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"c3ae39c2-0"},{"uid":"c3ae39c2-2"},{"uid":"c3ae39c2-6"}]},"c3ae39c2-11":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"c3ae39c2-6"}]}},"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","children":[{"uid":"4825d939-1","name":"context.ts"},{"uid":"4825d939-3","name":"provider.ts"},{"uid":"4825d939-5","name":"dom.ts"},{"uid":"4825d939-7","name":"use-head.ts"},{"uid":"4825d939-9","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"4825d939-1":{"renderedLength":1373,"gzipLength":509,"brotliLength":0,"metaUid":"4825d939-0"},"4825d939-3":{"renderedLength":2121,"gzipLength":1074,"brotliLength":0,"metaUid":"4825d939-2"},"4825d939-5":{"renderedLength":3447,"gzipLength":1292,"brotliLength":0,"metaUid":"4825d939-4"},"4825d939-7":{"renderedLength":2634,"gzipLength":1093,"brotliLength":0,"metaUid":"4825d939-6"},"4825d939-9":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"4825d939-8"}},"nodeMetas":{"4825d939-0":{"id":"/src/context.ts","moduleParts":{"index.js":"4825d939-1"},"imported":[{"uid":"4825d939-10"}],"importedBy":[{"uid":"4825d939-8"},{"uid":"4825d939-2"},{"uid":"4825d939-6"}]},"4825d939-2":{"id":"/src/provider.ts","moduleParts":{"index.js":"4825d939-3"},"imported":[{"uid":"4825d939-10"},{"uid":"4825d939-0"}],"importedBy":[{"uid":"4825d939-8"}]},"4825d939-4":{"id":"/src/dom.ts","moduleParts":{"index.js":"4825d939-5"},"imported":[],"importedBy":[{"uid":"4825d939-6"}]},"4825d939-6":{"id":"/src/use-head.ts","moduleParts":{"index.js":"4825d939-7"},"imported":[{"uid":"4825d939-10"},{"uid":"4825d939-11"},{"uid":"4825d939-0"},{"uid":"4825d939-4"}],"importedBy":[{"uid":"4825d939-8"}]},"4825d939-8":{"id":"/src/index.ts","moduleParts":{"index.js":"4825d939-9"},"imported":[{"uid":"4825d939-0"},{"uid":"4825d939-2"},{"uid":"4825d939-6"}],"importedBy":[],"isEntry":true},"4825d939-10":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"4825d939-0"},{"uid":"4825d939-2"},{"uid":"4825d939-6"}]},"4825d939-11":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"4825d939-6"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
@@ -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":"provider.js","children":[{"name":"src","children":[{"uid":"65ae058d-1","name":"context.ts"},{"uid":"65ae058d-3","name":"provider.ts"}]}]}],"isRoot":true},"nodeParts":{"65ae058d-1":{"renderedLength":1373,"gzipLength":509,"brotliLength":0,"metaUid":"65ae058d-0"},"65ae058d-3":{"renderedLength":681,"gzipLength":408,"brotliLength":0,"metaUid":"65ae058d-2"}},"nodeMetas":{"65ae058d-0":{"id":"/src/context.ts","moduleParts":{"provider.js":"65ae058d-1"},"imported":[{"uid":"65ae058d-4"}],"importedBy":[{"uid":"65ae058d-2"}]},"65ae058d-2":{"id":"/src/provider.ts","moduleParts":{"provider.js":"65ae058d-3"},"imported":[{"uid":"65ae058d-4"},{"uid":"65ae058d-0"}],"importedBy":[],"isEntry":true},"65ae058d-4":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"65ae058d-2"},{"uid":"65ae058d-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"provider.js","children":[{"name":"src","children":[{"uid":"11781eb7-1","name":"context.ts"},{"uid":"11781eb7-3","name":"provider.ts"}]}]}],"isRoot":true},"nodeParts":{"11781eb7-1":{"renderedLength":1373,"gzipLength":509,"brotliLength":0,"metaUid":"11781eb7-0"},"11781eb7-3":{"renderedLength":2121,"gzipLength":1074,"brotliLength":0,"metaUid":"11781eb7-2"}},"nodeMetas":{"11781eb7-0":{"id":"/src/context.ts","moduleParts":{"provider.js":"11781eb7-1"},"imported":[{"uid":"11781eb7-4"}],"importedBy":[{"uid":"11781eb7-2"}]},"11781eb7-2":{"id":"/src/provider.ts","moduleParts":{"provider.js":"11781eb7-3"},"imported":[{"uid":"11781eb7-4"},{"uid":"11781eb7-0"}],"importedBy":[],"isEntry":true},"11781eb7-4":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"11781eb7-2"},{"uid":"11781eb7-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
@@ -64,18 +64,42 @@ const HeadContext = createContext(null);
64
64
  * Provides a HeadContextValue to all descendant components.
65
65
  * Wrap your app root with this to enable useHead() throughout the tree.
66
66
  *
67
- * If no `context` prop is passed, a new HeadContext is created automatically.
67
+ * Resolution order (first non-null wins):
68
+ * 1. `props.context` — explicit context (documented SSR pattern).
69
+ * 2. An outer `HeadContext` already in scope — inherited transparently.
70
+ * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
71
+ * work without manual context plumbing: `renderWithHead` pushes its
72
+ * own `HeadContext` onto the per-request stack, and a nested
73
+ * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
74
+ * inherits it instead of silently shadowing it with a fresh,
75
+ * write-only registry.
76
+ * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
77
+ *
78
+ * The inheritance step is load-bearing for any consumer wrapping
79
+ * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
80
+ * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
81
+ * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
82
+ * `context` prop. Without inheritance, all `useHead()` calls in the
83
+ * subtree wrote tags into the inner ctx while `renderWithHead` resolved
84
+ * the outer ctx — producing an empty `<head>` for the whole app.
85
+ *
86
+ * Apps that genuinely need an isolated registry (e.g. iframe / micro-
87
+ * frontend boundaries) can still opt out by passing
88
+ * `context={createHeadContext()}` explicitly — `props.context` always wins.
68
89
  *
69
90
  * @example
70
- * // Auto-create context:
91
+ * // Auto-create context (root of a CSR app):
71
92
  * <HeadProvider><App /></HeadProvider>
72
93
  *
73
94
  * // Explicit context (e.g. for SSR):
74
95
  * const headCtx = createHeadContext()
75
96
  * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
97
+ *
98
+ * // Composes with `renderWithHead` out of the box — no plumbing needed:
99
+ * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
76
100
  */
77
101
  const HeadProvider = (props) => {
78
- provide(HeadContext, props.context ?? createHeadContext());
102
+ provide(HeadContext, props.context ?? useContext(HeadContext) ?? createHeadContext());
79
103
  const ch = props.children;
80
104
  return typeof ch === "function" ? ch() : ch;
81
105
  };
package/lib/provider.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, nativeCompat, provide } from "@pyreon/core";
1
+ import { createContext, nativeCompat, provide, useContext } from "@pyreon/core";
2
2
 
3
3
  //#region src/context.ts
4
4
  function createHeadContext() {
@@ -63,18 +63,42 @@ const HeadContext = createContext(null);
63
63
  * Provides a HeadContextValue to all descendant components.
64
64
  * Wrap your app root with this to enable useHead() throughout the tree.
65
65
  *
66
- * If no `context` prop is passed, a new HeadContext is created automatically.
66
+ * Resolution order (first non-null wins):
67
+ * 1. `props.context` — explicit context (documented SSR pattern).
68
+ * 2. An outer `HeadContext` already in scope — inherited transparently.
69
+ * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
70
+ * work without manual context plumbing: `renderWithHead` pushes its
71
+ * own `HeadContext` onto the per-request stack, and a nested
72
+ * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
73
+ * inherits it instead of silently shadowing it with a fresh,
74
+ * write-only registry.
75
+ * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
76
+ *
77
+ * The inheritance step is load-bearing for any consumer wrapping
78
+ * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
79
+ * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
80
+ * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
81
+ * `context` prop. Without inheritance, all `useHead()` calls in the
82
+ * subtree wrote tags into the inner ctx while `renderWithHead` resolved
83
+ * the outer ctx — producing an empty `<head>` for the whole app.
84
+ *
85
+ * Apps that genuinely need an isolated registry (e.g. iframe / micro-
86
+ * frontend boundaries) can still opt out by passing
87
+ * `context={createHeadContext()}` explicitly — `props.context` always wins.
67
88
  *
68
89
  * @example
69
- * // Auto-create context:
90
+ * // Auto-create context (root of a CSR app):
70
91
  * <HeadProvider><App /></HeadProvider>
71
92
  *
72
93
  * // Explicit context (e.g. for SSR):
73
94
  * const headCtx = createHeadContext()
74
95
  * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
96
+ *
97
+ * // Composes with `renderWithHead` out of the box — no plumbing needed:
98
+ * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
75
99
  */
76
100
  const HeadProvider = (props) => {
77
- provide(HeadContext, props.context ?? createHeadContext());
101
+ provide(HeadContext, props.context ?? useContext(HeadContext) ?? createHeadContext());
78
102
  const ch = props.children;
79
103
  return typeof ch === "function" ? ch() : ch;
80
104
  };
@@ -212,15 +212,39 @@ interface HeadProviderProps extends Props {
212
212
  * Provides a HeadContextValue to all descendant components.
213
213
  * Wrap your app root with this to enable useHead() throughout the tree.
214
214
  *
215
- * If no `context` prop is passed, a new HeadContext is created automatically.
215
+ * Resolution order (first non-null wins):
216
+ * 1. `props.context` — explicit context (documented SSR pattern).
217
+ * 2. An outer `HeadContext` already in scope — inherited transparently.
218
+ * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
219
+ * work without manual context plumbing: `renderWithHead` pushes its
220
+ * own `HeadContext` onto the per-request stack, and a nested
221
+ * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
222
+ * inherits it instead of silently shadowing it with a fresh,
223
+ * write-only registry.
224
+ * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
225
+ *
226
+ * The inheritance step is load-bearing for any consumer wrapping
227
+ * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
228
+ * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
229
+ * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
230
+ * `context` prop. Without inheritance, all `useHead()` calls in the
231
+ * subtree wrote tags into the inner ctx while `renderWithHead` resolved
232
+ * the outer ctx — producing an empty `<head>` for the whole app.
233
+ *
234
+ * Apps that genuinely need an isolated registry (e.g. iframe / micro-
235
+ * frontend boundaries) can still opt out by passing
236
+ * `context={createHeadContext()}` explicitly — `props.context` always wins.
216
237
  *
217
238
  * @example
218
- * // Auto-create context:
239
+ * // Auto-create context (root of a CSR app):
219
240
  * <HeadProvider><App /></HeadProvider>
220
241
  *
221
242
  * // Explicit context (e.g. for SSR):
222
243
  * const headCtx = createHeadContext()
223
244
  * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
245
+ *
246
+ * // Composes with `renderWithHead` out of the box — no plumbing needed:
247
+ * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
224
248
  */
225
249
  declare const HeadProvider: ComponentFn<HeadProviderProps>;
226
250
  //#endregion
@@ -43,15 +43,39 @@ interface HeadProviderProps extends Props {
43
43
  * Provides a HeadContextValue to all descendant components.
44
44
  * Wrap your app root with this to enable useHead() throughout the tree.
45
45
  *
46
- * If no `context` prop is passed, a new HeadContext is created automatically.
46
+ * Resolution order (first non-null wins):
47
+ * 1. `props.context` — explicit context (documented SSR pattern).
48
+ * 2. An outer `HeadContext` already in scope — inherited transparently.
49
+ * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
50
+ * work without manual context plumbing: `renderWithHead` pushes its
51
+ * own `HeadContext` onto the per-request stack, and a nested
52
+ * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
53
+ * inherits it instead of silently shadowing it with a fresh,
54
+ * write-only registry.
55
+ * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
56
+ *
57
+ * The inheritance step is load-bearing for any consumer wrapping
58
+ * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
59
+ * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
60
+ * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
61
+ * `context` prop. Without inheritance, all `useHead()` calls in the
62
+ * subtree wrote tags into the inner ctx while `renderWithHead` resolved
63
+ * the outer ctx — producing an empty `<head>` for the whole app.
64
+ *
65
+ * Apps that genuinely need an isolated registry (e.g. iframe / micro-
66
+ * frontend boundaries) can still opt out by passing
67
+ * `context={createHeadContext()}` explicitly — `props.context` always wins.
47
68
  *
48
69
  * @example
49
- * // Auto-create context:
70
+ * // Auto-create context (root of a CSR app):
50
71
  * <HeadProvider><App /></HeadProvider>
51
72
  *
52
73
  * // Explicit context (e.g. for SSR):
53
74
  * const headCtx = createHeadContext()
54
75
  * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
76
+ *
77
+ * // Composes with `renderWithHead` out of the box — no plumbing needed:
78
+ * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
55
79
  */
56
80
  declare const HeadProvider: ComponentFn<HeadProviderProps>;
57
81
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/head",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Head tag management for Pyreon — works in SSR and CSR",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/head#readme",
6
6
  "bugs": {
@@ -59,16 +59,16 @@
59
59
  "prepublishOnly": "bun run build"
60
60
  },
61
61
  "dependencies": {
62
- "@pyreon/core": "^0.20.0",
63
- "@pyreon/reactivity": "^0.20.0",
64
- "@pyreon/runtime-server": "^0.20.0"
62
+ "@pyreon/core": "^0.21.0",
63
+ "@pyreon/reactivity": "^0.21.0",
64
+ "@pyreon/runtime-server": "^0.21.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@happy-dom/global-registrator": "^20.8.9",
68
68
  "@pyreon/manifest": "0.13.1",
69
- "@pyreon/runtime-dom": "^0.20.0",
70
- "@pyreon/runtime-server": "^0.20.0",
71
- "@pyreon/test-utils": "^0.13.7",
69
+ "@pyreon/runtime-dom": "^0.21.0",
70
+ "@pyreon/runtime-server": "^0.21.0",
71
+ "@pyreon/test-utils": "^0.13.8",
72
72
  "@vitest/browser-playwright": "^4.1.4"
73
73
  },
74
74
  "peerDependenciesMeta": {
package/src/manifest.ts CHANGED
@@ -81,19 +81,28 @@ useHead(() => ({
81
81
  kind: 'component',
82
82
  signature: '(props: HeadProviderProps) => VNodeChild',
83
83
  summary:
84
- 'Client-side context provider that collects every `useHead()` call from descendants and syncs the resolved tags into the live `document.head` element. Mount once near the application root. Auto-creates a `HeadContextValue` when no `context` prop is passed; nested providers each own an independent context.',
84
+ 'Context provider that collects every `useHead()` call from descendants. Resolves its context as `props.context ?? outer HeadContext in scope ?? a fresh one`, so a `HeadProvider` mounted INSIDE `renderWithHead()` (or inside another `HeadProvider`) transparently inherits the outer registry instead of shadowing it with a write-only one. On the client it also syncs the resolved tags into the live `document.head`. Mount once near the application root for the canonical CSR shape; the inheritance step makes nested mounts and the SSR-wrapped shape work without manual context plumbing.',
85
85
  example: `<HeadProvider>{children}</HeadProvider>
86
86
 
87
- // Client-side setup:
87
+ // CSR root — auto-creates a fresh context:
88
88
  mount(
89
89
  <HeadProvider>
90
90
  <App />
91
91
  </HeadProvider>,
92
92
  document.getElementById("app")!
93
- )`,
93
+ )
94
+
95
+ // SSR — composes with renderWithHead out of the box (no context prop needed):
96
+ const { html, head } = await renderWithHead(
97
+ <HeadProvider><App /></HeadProvider>
98
+ )
99
+
100
+ // Explicit isolation (iframe / micro-frontend boundary):
101
+ <HeadProvider context={createHeadContext()}><App /></HeadProvider>`,
94
102
  mistakes: [
95
- 'Mounting two `HeadProvider` instances at sibling roots — each owns an independent context, so a `useHead()` deeper in tree A is invisible to tree B',
96
- 'Forgetting to mount `HeadProvider` and expecting `useHead()` to still update `document.head` — silent no-op outside a provider',
103
+ 'Mounting two `HeadProvider` instances at SIBLING roots — each owns an independent context, so a `useHead()` deeper in tree A is invisible to tree B (use a shared `context` prop or merge under a common parent provider)',
104
+ 'Forgetting to mount `HeadProvider` (or `renderWithHead`) and expecting `useHead()` to still update `document.head` — silent no-op outside any provider',
105
+ 'Assuming a NESTED `HeadProvider` isolates its subtree by default — it does the opposite, inheriting the outer context. Pass `context={createHeadContext()}` explicitly when you genuinely want isolation',
97
106
  ],
98
107
  seeAlso: ['useHead', 'renderWithHead', 'createHeadContext'],
99
108
  },
package/src/provider.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
- import { nativeCompat, provide } from '@pyreon/core'
2
+ import { nativeCompat, provide, useContext } from '@pyreon/core'
3
3
  import type { HeadContextValue } from './context'
4
4
  import { createHeadContext, HeadContext } from './context'
5
5
 
@@ -12,18 +12,47 @@ export interface HeadProviderProps extends Props {
12
12
  * Provides a HeadContextValue to all descendant components.
13
13
  * Wrap your app root with this to enable useHead() throughout the tree.
14
14
  *
15
- * If no `context` prop is passed, a new HeadContext is created automatically.
15
+ * Resolution order (first non-null wins):
16
+ * 1. `props.context` — explicit context (documented SSR pattern).
17
+ * 2. An outer `HeadContext` already in scope — inherited transparently.
18
+ * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
19
+ * work without manual context plumbing: `renderWithHead` pushes its
20
+ * own `HeadContext` onto the per-request stack, and a nested
21
+ * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
22
+ * inherits it instead of silently shadowing it with a fresh,
23
+ * write-only registry.
24
+ * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
25
+ *
26
+ * The inheritance step is load-bearing for any consumer wrapping
27
+ * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
28
+ * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
29
+ * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
30
+ * `context` prop. Without inheritance, all `useHead()` calls in the
31
+ * subtree wrote tags into the inner ctx while `renderWithHead` resolved
32
+ * the outer ctx — producing an empty `<head>` for the whole app.
33
+ *
34
+ * Apps that genuinely need an isolated registry (e.g. iframe / micro-
35
+ * frontend boundaries) can still opt out by passing
36
+ * `context={createHeadContext()}` explicitly — `props.context` always wins.
16
37
  *
17
38
  * @example
18
- * // Auto-create context:
39
+ * // Auto-create context (root of a CSR app):
19
40
  * <HeadProvider><App /></HeadProvider>
20
41
  *
21
42
  * // Explicit context (e.g. for SSR):
22
43
  * const headCtx = createHeadContext()
23
44
  * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
45
+ *
46
+ * // Composes with `renderWithHead` out of the box — no plumbing needed:
47
+ * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
24
48
  */
25
49
  export const HeadProvider: ComponentFn<HeadProviderProps> = (props) => {
26
- const ctx = props.context ?? createHeadContext()
50
+ // `useContext(HeadContext)` returns `null` when no outer provider exists
51
+ // (the context's defaultValue). The `??` chain therefore resolves to:
52
+ // explicit prop → inherited outer ctx → fresh ctx
53
+ // and `provide()` re-pushes the same ctx for the subtree (harmless: the
54
+ // descendant `useContext` walk finds it identically via either frame).
55
+ const ctx = props.context ?? useContext(HeadContext) ?? createHeadContext()
27
56
  provide(HeadContext, ctx)
28
57
 
29
58
  const ch = props.children
@@ -0,0 +1,131 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import { h } from '@pyreon/core'
3
+ import { mount } from '@pyreon/runtime-dom'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { createHeadContext, HeadProvider, useHead } from '../index'
6
+ import { renderWithHead } from '../ssr'
7
+
8
+ /**
9
+ * `HeadProvider` resolves its HeadContext as `props.context ?? outer ?? fresh`.
10
+ * That inheritance step is load-bearing for the documented composition
11
+ * `renderWithHead(h(HeadProvider, null, h(App)))` AND for the
12
+ * `@pyreon/zero` SSR/SSG pipeline (whose `createApp` mounts
13
+ * `h(HeadProvider, null, …)` unconditionally with no `context` prop).
14
+ *
15
+ * Pre-fix `HeadProvider` ALWAYS auto-created a fresh ctx and `provide()`d
16
+ * it — silently SHADOWING the ctx that `renderWithHead` had pushed onto
17
+ * the per-request context stack. Every `useHead({...})` call in the
18
+ * subtree wrote tags to the inner ctx (HeadProvider's), but
19
+ * `renderWithHead` resolved the outer ctx (its own, still empty) and
20
+ * produced an empty `<head>` string. Static SSG / SSR output shipped
21
+ * with NO `<title>` / `<meta>` / JSON-LD / OG tags — social scrapers and
22
+ * non-JS crawlers saw nothing. Fixed by adding `useContext(HeadContext)`
23
+ * to the resolution chain so an outer ctx is inherited transparently.
24
+ */
25
+ describe('HeadProvider — inherits an outer HeadContext (composability contract)', () => {
26
+ it('REGRESSION: `renderWithHead(h(HeadProvider, null, h(App)))` carries useHead tags into <head>', async () => {
27
+ // This is the EXACT shape `@pyreon/zero`'s `createApp` mounts:
28
+ // h(App, null) → h(HeadProvider, null, h(RouterProvider, …, h(RouterView, null)))
29
+ // — i.e. the inner `HeadProvider` has no `context` prop. Pre-fix this
30
+ // produced an empty `head` string; the rendered HTML was perfectly fine.
31
+ const App: ComponentFn = () => {
32
+ useHead({
33
+ title: 'Page Title',
34
+ meta: [{ name: 'description', content: 'page desc' }],
35
+ })
36
+ return h('div', null, 'app body')
37
+ }
38
+
39
+ const wrapped = h(HeadProvider as ComponentFn, null, h(App, null))
40
+ const { html, head } = await renderWithHead(wrapped)
41
+
42
+ expect(html).toContain('app body')
43
+ expect(head).toContain('<title>Page Title</title>')
44
+ expect(head).toContain('name="description"')
45
+ expect(head).toContain('content="page desc"')
46
+ })
47
+
48
+ it('direct `h(App)` (no inner HeadProvider) still works — baseline parity', async () => {
49
+ const App: ComponentFn = () => {
50
+ useHead({ title: 'Baseline' })
51
+ return h('div', null)
52
+ }
53
+ const { head } = await renderWithHead(h(App, null))
54
+ expect(head).toContain('<title>Baseline</title>')
55
+ })
56
+
57
+ it('explicit `context` prop on the inner HeadProvider still wins (opt-out for isolation)', async () => {
58
+ // Apps that genuinely want an isolated head registry (iframe / micro-
59
+ // frontend) can pass their own ctx; the explicit prop overrides
60
+ // inheritance. The outer ctx that `renderWithHead` resolves remains
61
+ // empty in this case BY DESIGN — verifying the opt-out works.
62
+ const isolatedCtx = createHeadContext()
63
+ const App: ComponentFn = () => {
64
+ useHead({ title: 'Isolated' })
65
+ return h('div', null)
66
+ }
67
+ const wrapped = h(
68
+ HeadProvider as ComponentFn,
69
+ { context: isolatedCtx },
70
+ h(App, null),
71
+ )
72
+ const { head } = await renderWithHead(wrapped)
73
+ // Tags landed in the isolated ctx, NOT in renderWithHead's outer ctx
74
+ expect(head).toBe('')
75
+ // Confirm the tags really did go into the isolated ctx
76
+ const isolatedTags = isolatedCtx.resolve()
77
+ expect(isolatedTags.find((t) => t.tag === 'title')?.children).toBe('Isolated')
78
+ })
79
+
80
+ it('nested HeadProvider — inner inherits outer ctx, no shadow (registry stays single)', async () => {
81
+ // Two HeadProviders in the same tree should write into ONE registry,
82
+ // not two disjoint ones. Pre-fix the inner one created a fresh ctx,
83
+ // so the outer registry (which renderWithHead resolves) lost the
84
+ // inner subtree's tags. Post-fix the inner inherits the outer ctx
85
+ // and tags from both subtrees land in the same resolved <head>.
86
+ const Inner: ComponentFn = () => {
87
+ useHead({ meta: [{ name: 'inner', content: 'inner-value' }] })
88
+ return h('span', null, 'inner')
89
+ }
90
+ const Outer: ComponentFn = () => {
91
+ useHead({ title: 'Outer Title' })
92
+ return h(
93
+ 'div',
94
+ null,
95
+ h(HeadProvider as ComponentFn, null, h(Inner, null)),
96
+ )
97
+ }
98
+ const { head } = await renderWithHead(h(Outer, null))
99
+ expect(head).toContain('<title>Outer Title</title>')
100
+ expect(head).toContain('name="inner"')
101
+ expect(head).toContain('content="inner-value"')
102
+ })
103
+
104
+ describe('CSR root — fresh-ctx fallback preserved (regression guard for the fix)', () => {
105
+ let container: HTMLElement
106
+ beforeEach(() => {
107
+ container = document.createElement('div')
108
+ document.body.appendChild(container)
109
+ for (const el of document.head.querySelectorAll('[data-pyreon-head]'))
110
+ el.remove()
111
+ document.title = ''
112
+ })
113
+ afterEach(() => {
114
+ container.remove()
115
+ })
116
+
117
+ it('mounts at CSR root with NO `context` prop + NO outer provider → auto-creates fresh ctx, useHead works', () => {
118
+ // When neither `props.context` nor an outer `HeadContext` is in
119
+ // scope, HeadProvider must STILL auto-create a fresh ctx so pure
120
+ // CSR roots work. If the fix accidentally regressed this path
121
+ // (e.g. requiring an outer ctx), `useHead` would no-op silently
122
+ // and `document.title` would stay empty.
123
+ const App: ComponentFn = () => {
124
+ useHead({ title: 'CSR Root' })
125
+ return h('div', null)
126
+ }
127
+ mount(h(HeadProvider as ComponentFn, null, h(App, null)), container)
128
+ expect(document.title).toBe('CSR Root')
129
+ })
130
+ })
131
+ })