@pyreon/head 0.20.0 → 0.22.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,3 +1,4 @@
1
+ import { HeadContext, createHeadContext } from "@pyreon/head/context";
1
2
  import { ComponentFn, Props, VNodeChild } from "@pyreon/core";
2
3
 
3
4
  //#region src/context.d.ts
@@ -200,8 +201,6 @@ interface HeadContextValue {
200
201
  /** Returns merged bodyAttrs (later entries override earlier) */
201
202
  resolveBodyAttrs(): Record<string, string>;
202
203
  }
203
- declare function createHeadContext(): HeadContextValue;
204
- declare const HeadContext: import("@pyreon/core").Context<HeadContextValue | null>;
205
204
  //#endregion
206
205
  //#region src/provider.d.ts
207
206
  interface HeadProviderProps extends Props {
@@ -212,15 +211,39 @@ interface HeadProviderProps extends Props {
212
211
  * Provides a HeadContextValue to all descendant components.
213
212
  * Wrap your app root with this to enable useHead() throughout the tree.
214
213
  *
215
- * If no `context` prop is passed, a new HeadContext is created automatically.
214
+ * Resolution order (first non-null wins):
215
+ * 1. `props.context` — explicit context (documented SSR pattern).
216
+ * 2. An outer `HeadContext` already in scope — inherited transparently.
217
+ * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
218
+ * work without manual context plumbing: `renderWithHead` pushes its
219
+ * own `HeadContext` onto the per-request stack, and a nested
220
+ * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
221
+ * inherits it instead of silently shadowing it with a fresh,
222
+ * write-only registry.
223
+ * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
224
+ *
225
+ * The inheritance step is load-bearing for any consumer wrapping
226
+ * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
227
+ * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
228
+ * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
229
+ * `context` prop. Without inheritance, all `useHead()` calls in the
230
+ * subtree wrote tags into the inner ctx while `renderWithHead` resolved
231
+ * the outer ctx — producing an empty `<head>` for the whole app.
232
+ *
233
+ * Apps that genuinely need an isolated registry (e.g. iframe / micro-
234
+ * frontend boundaries) can still opt out by passing
235
+ * `context={createHeadContext()}` explicitly — `props.context` always wins.
216
236
  *
217
237
  * @example
218
- * // Auto-create context:
238
+ * // Auto-create context (root of a CSR app):
219
239
  * <HeadProvider><App /></HeadProvider>
220
240
  *
221
241
  * // Explicit context (e.g. for SSR):
222
242
  * const headCtx = createHeadContext()
223
243
  * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
244
+ *
245
+ * // Composes with `renderWithHead` out of the box — no plumbing needed:
246
+ * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
224
247
  */
225
248
  declare const HeadProvider: ComponentFn<HeadProviderProps>;
226
249
  //#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/lib/use-head.js CHANGED
@@ -1,10 +1,7 @@
1
- import { createContext, onMount, onUnmount, useContext } from "@pyreon/core";
1
+ import { onMount, onUnmount, useContext } from "@pyreon/core";
2
2
  import { effect } from "@pyreon/reactivity";
3
+ import { HeadContext } from "@pyreon/head/context";
3
4
 
4
- //#region src/context.ts
5
- const HeadContext = createContext(null);
6
-
7
- //#endregion
8
5
  //#region src/dom.ts
9
6
  const ATTR = "data-pyreon-head";
10
7
  /** Tracks managed elements by key — avoids querySelectorAll on every sync */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/head",
3
- "version": "0.20.0",
3
+ "version": "0.22.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": {
@@ -30,6 +30,11 @@
30
30
  "import": "./lib/index.js",
31
31
  "types": "./lib/types/index.d.ts"
32
32
  },
33
+ "./context": {
34
+ "bun": "./src/context.ts",
35
+ "import": "./lib/context.js",
36
+ "types": "./lib/types/context.d.ts"
37
+ },
33
38
  "./provider": {
34
39
  "bun": "./src/provider.ts",
35
40
  "import": "./lib/provider.js",
@@ -59,16 +64,16 @@
59
64
  "prepublishOnly": "bun run build"
60
65
  },
61
66
  "dependencies": {
62
- "@pyreon/core": "^0.20.0",
63
- "@pyreon/reactivity": "^0.20.0",
64
- "@pyreon/runtime-server": "^0.20.0"
67
+ "@pyreon/core": "^0.22.0",
68
+ "@pyreon/reactivity": "^0.22.0",
69
+ "@pyreon/runtime-server": "^0.22.0"
65
70
  },
66
71
  "devDependencies": {
67
72
  "@happy-dom/global-registrator": "^20.8.9",
68
73
  "@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",
74
+ "@pyreon/runtime-dom": "^0.22.0",
75
+ "@pyreon/runtime-server": "^0.22.0",
76
+ "@pyreon/test-utils": "^0.13.9",
72
77
  "@vitest/browser-playwright": "^4.1.4"
73
78
  },
74
79
  "peerDependenciesMeta": {
package/src/index.ts CHANGED
@@ -12,7 +12,12 @@ export type {
12
12
  StyleTag,
13
13
  UseHeadInput,
14
14
  } from './context'
15
- export { createHeadContext, HeadContext } from './context'
15
+ // Runtime VALUE re-export via self-package path so the build externalizes
16
+ // the symbol — main entry + every sub-entry resolves to the same
17
+ // `lib/context.js` at runtime. The type re-exports above stay as `./context`
18
+ // (types erase, externalization doesn't apply). See `ssr.ts` for the full
19
+ // rationale + `tests/context-identity.test.ts` for the post-build contract.
20
+ export { createHeadContext, HeadContext } from '@pyreon/head/context'
16
21
  export type { HeadProviderProps } from './provider'
17
22
  export { HeadProvider } from './provider'
18
23
  export { useHead } from './use-head'
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,7 +1,10 @@
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
- import { createHeadContext, HeadContext } from './context'
4
+ // Runtime VALUE import via self-package path so the build externalizes
5
+ // the symbol — every sub-entry resolves to the same `lib/context.js` at
6
+ // runtime. See `ssr.ts` for the full rationale + `tests/context-identity.test.ts`.
7
+ import { createHeadContext, HeadContext } from '@pyreon/head/context'
5
8
 
6
9
  export interface HeadProviderProps extends Props {
7
10
  context?: HeadContextValue | undefined
@@ -12,18 +15,47 @@ export interface HeadProviderProps extends Props {
12
15
  * Provides a HeadContextValue to all descendant components.
13
16
  * Wrap your app root with this to enable useHead() throughout the tree.
14
17
  *
15
- * If no `context` prop is passed, a new HeadContext is created automatically.
18
+ * Resolution order (first non-null wins):
19
+ * 1. `props.context` — explicit context (documented SSR pattern).
20
+ * 2. An outer `HeadContext` already in scope — inherited transparently.
21
+ * This is what makes `renderWithHead(h(HeadProvider, null, h(App)))`
22
+ * work without manual context plumbing: `renderWithHead` pushes its
23
+ * own `HeadContext` onto the per-request stack, and a nested
24
+ * `HeadProvider` (e.g. one zero's `App` renders unconditionally)
25
+ * inherits it instead of silently shadowing it with a fresh,
26
+ * write-only registry.
27
+ * 3. A freshly-created `HeadContext` — root-level fallback (pure CSR).
28
+ *
29
+ * The inheritance step is load-bearing for any consumer wrapping
30
+ * `<HeadProvider>` inside `renderWithHead()` (the documented JSDoc
31
+ * pattern below) AND for the SSG / runtime-SSR pipeline in `@pyreon/zero`,
32
+ * whose `createApp` always mounts `h(HeadProvider, null, …)` with no
33
+ * `context` prop. Without inheritance, all `useHead()` calls in the
34
+ * subtree wrote tags into the inner ctx while `renderWithHead` resolved
35
+ * the outer ctx — producing an empty `<head>` for the whole app.
36
+ *
37
+ * Apps that genuinely need an isolated registry (e.g. iframe / micro-
38
+ * frontend boundaries) can still opt out by passing
39
+ * `context={createHeadContext()}` explicitly — `props.context` always wins.
16
40
  *
17
41
  * @example
18
- * // Auto-create context:
42
+ * // Auto-create context (root of a CSR app):
19
43
  * <HeadProvider><App /></HeadProvider>
20
44
  *
21
45
  * // Explicit context (e.g. for SSR):
22
46
  * const headCtx = createHeadContext()
23
47
  * mount(h(HeadProvider, { context: headCtx }, h(App, null)), root)
48
+ *
49
+ * // Composes with `renderWithHead` out of the box — no plumbing needed:
50
+ * const { html, head } = await renderWithHead(h(HeadProvider, null, h(App, null)))
24
51
  */
25
52
  export const HeadProvider: ComponentFn<HeadProviderProps> = (props) => {
26
- const ctx = props.context ?? createHeadContext()
53
+ // `useContext(HeadContext)` returns `null` when no outer provider exists
54
+ // (the context's defaultValue). The `??` chain therefore resolves to:
55
+ // explicit prop → inherited outer ctx → fresh ctx
56
+ // and `provide()` re-pushes the same ctx for the subtree (harmless: the
57
+ // descendant `useContext` walk finds it identically via either frame).
58
+ const ctx = props.context ?? useContext(HeadContext) ?? createHeadContext()
27
59
  provide(HeadContext, ctx)
28
60
 
29
61
  const ch = props.children
package/src/ssr.ts CHANGED
@@ -2,7 +2,16 @@ import type { ComponentFn, VNode } from '@pyreon/core'
2
2
  import { h, pushContext } from '@pyreon/core'
3
3
  import { renderToString } from '@pyreon/runtime-server'
4
4
  import type { HeadTag } from './context'
5
- import { createHeadContext, HeadContext } from './context'
5
+ // Runtime VALUE imports go through the self-package path so the build
6
+ // emits an external `import` instead of inlining `createContext(null)`
7
+ // into this sub-bundle. The bundler externalizes `@pyreon/head/context`
8
+ // per the package's `build.external` config — every sub-entry resolves to
9
+ // the SAME `lib/context.js` at runtime, so `HeadContext` is one Symbol
10
+ // across `lib/index.js` / `lib/ssr.js` / `lib/use-head.js` / `lib/provider.js`.
11
+ // See `tests/context-identity.test.ts` for the post-build identity contract.
12
+ // Type-only imports (`HeadTag` above) keep the relative path — types erase
13
+ // at build, no externalization needed.
14
+ import { createHeadContext, HeadContext } from '@pyreon/head/context'
6
15
 
7
16
  const VOID_TAGS = new Set(['meta', 'link', 'base'])
8
17
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Bundle-level regression: `HeadContext` is constructed in EXACTLY ONE
3
+ * place across the published `lib/` artifacts.
4
+ *
5
+ * The bug this test exists to catch: `@pyreon/head@0.21.0` shipped four
6
+ * sub-entries (`lib/index.js`, `lib/provider.js`, `lib/use-head.js`,
7
+ * `lib/ssr.js`) AND the shared `@vitus-labs/tools-rolldown` build invokes
8
+ * rolldown ONCE PER SUB-ENTRY (no cross-entry shared chunks). Result:
9
+ * every sub-bundle independently inlined `context.ts` and ran its own
10
+ * `createContext(null)` at module init — each call minted a unique
11
+ * `Symbol.for(...).id`, so a `useContext(HeadContext)` lookup in one
12
+ * bundle (e.g. the app's `useHead` from `lib/use-head.js`) silently
13
+ * MISSED a `provide(HeadContext)` from another (e.g. `renderWithHead`
14
+ * from `lib/ssr.js`). The bug was invisible in dev / source-mode tests
15
+ * because Vite's `bun` condition resolves to a single shared
16
+ * `src/context.ts` (ESM single-evaluation guarantee), but SSG output
17
+ * silently dropped every `useHead()`-registered tag — bad for SEO,
18
+ * social scrapers, accessibility, no-JS.
19
+ *
20
+ * The fix (`vl-tools.config.mjs` + self-package imports + the new
21
+ * `./context` sub-export): source uses `@pyreon/head/context` for the
22
+ * runtime VALUE; the build externalizes that specifier; every sub-bundle
23
+ * resolves to the SAME `lib/context.js` at runtime — one Symbol, one
24
+ * shared context.
25
+ *
26
+ * Structural assertions:
27
+ * 1. `lib/context.js` is the ONLY bundle that calls `createContext(`.
28
+ * 2. Every other published sub-bundle (`index`, `ssr`, `use-head`,
29
+ * `provider`) imports `HeadContext` from `@pyreon/head/context`
30
+ * (the external — the bundler's signal that the symbol comes from
31
+ * a shared runtime chunk, not from inlined source).
32
+ *
33
+ * Together these two invariants make the bug class structurally
34
+ * impossible to re-introduce silently — any future regression (e.g.
35
+ * removing the `vl-tools.config.mjs` external, or reverting a source
36
+ * file to a relative `./context` import for the runtime VALUE) flips
37
+ * one of the per-bundle counters and trips the assertion.
38
+ *
39
+ * Bisect-verified at the build artifact:
40
+ * - With the fix: lib/context.js has 1 createContext call site (+ 1
41
+ * import line = 2 occurrences); every other sub-bundle has 0.
42
+ * - Without the fix (revert `vl-tools.config.mjs`): every sub-bundle
43
+ * gets its own inlined `createContext(null)` (2 occurrences each) —
44
+ * this test fails on the first non-context bundle.
45
+ */
46
+
47
+ import { existsSync, readFileSync } from 'node:fs'
48
+ import { resolve } from 'node:path'
49
+ import { describe, expect, it } from 'vitest'
50
+
51
+ const PKG_ROOT = resolve(__dirname, '..', '..')
52
+ const LIB_DIR = resolve(PKG_ROOT, 'lib')
53
+ const libExists = existsSync(resolve(LIB_DIR, 'index.js'))
54
+
55
+ const read = (name: string) => readFileSync(resolve(LIB_DIR, name), 'utf8')
56
+
57
+ /**
58
+ * The bundle gate runs only when `lib/` has been built — `bun install`'s
59
+ * postinstall bootstrap rebuilds whenever sources are newer than lib, so
60
+ * in a normal dev session lib is always present. CI installs run the
61
+ * same bootstrap. The `skip` is a defensive escape so the suite doesn't
62
+ * false-fail in a partial worktree state where the user manually
63
+ * deleted lib/.
64
+ */
65
+ describe.skipIf(!libExists)(
66
+ '@pyreon/head bundle-level HeadContext identity (regression for the SSG-Meta-dropped bug)',
67
+ () => {
68
+ // ── Invariant 1: ONE createContext call across all bundles ───────
69
+ //
70
+ // `lib/context.js` is the canonical single chunk. Each other
71
+ // sub-bundle (`index`, `ssr`, `use-head`, `provider`) should
72
+ // import `HeadContext` from it externally, NOT inline a fresh
73
+ // `createContext(null)` call.
74
+
75
+ it('lib/context.js is the SINGLE bundle that calls createContext()', () => {
76
+ const src = read('context.js')
77
+ // 2 occurrences = the `import { createContext }` line + the actual
78
+ // `createContext(null)` call at module init. This is the SOURCE OF
79
+ // TRUTH for HeadContext's Symbol identity.
80
+ expect(src.match(/createContext/g)?.length ?? 0).toBe(2)
81
+ expect(src).toContain('createContext(null)')
82
+ })
83
+
84
+ it.each([
85
+ ['index.js'],
86
+ ['provider.js'],
87
+ ['use-head.js'],
88
+ ['ssr.js'],
89
+ ])('lib/%s does NOT inline createContext (must import HeadContext from @pyreon/head/context)', (name) => {
90
+ const src = read(name)
91
+ // Zero `createContext` references — anything > 0 means the bundle
92
+ // either imports `createContext` (= would call it at init) or
93
+ // declares its own `HeadContext = createContext(...)`. Both are
94
+ // the bug. With the fix, the sub-bundle's HeadContext arrives via
95
+ // an external `import { HeadContext } from "@pyreon/head/context"`.
96
+ expect(src.match(/createContext/g)?.length ?? 0).toBe(0)
97
+ })
98
+
99
+ // ── Invariant 2: external import to @pyreon/head/context ─────────
100
+ //
101
+ // Every non-`context` sub-bundle that USES HeadContext must import
102
+ // it from the externalized `@pyreon/head/context` specifier (the
103
+ // bundler's signal the symbol is shared with `lib/context.js`).
104
+
105
+ it.each([
106
+ ['index.js'],
107
+ ['provider.js'],
108
+ ['use-head.js'],
109
+ ['ssr.js'],
110
+ ])('lib/%s imports HeadContext via the externalized @pyreon/head/context specifier', (name) => {
111
+ const src = read(name)
112
+ // The bundler emits the import verbatim for externalized specifiers.
113
+ // Both ESM string literal styles (`"…"` and `'…'`) are matched
114
+ // because rolldown's output quoting is deterministic but not
115
+ // version-pinned by this test.
116
+ const hasExternalImport =
117
+ /from\s+["']@pyreon\/head\/context["']/.test(src)
118
+ expect(hasExternalImport).toBe(true)
119
+ })
120
+
121
+ // ── Invariant 3: the package.json wiring that enables it ─────────
122
+ //
123
+ // Two things must coexist for the externalization to be honored —
124
+ // the `./context` sub-export (so the import has a runtime target),
125
+ // and the `vl-tools.config.mjs` external rule (so the bundler keeps
126
+ // the specifier instead of inlining). Locking these here means a
127
+ // future revert of either side immediately fails the test.
128
+
129
+ it('package.json declares the ./context sub-export', () => {
130
+ const pkg = JSON.parse(read('../package.json')) as {
131
+ exports: Record<string, { bun?: string; import?: string; types?: string }>
132
+ }
133
+ expect(pkg.exports['./context']).toBeDefined()
134
+ expect(pkg.exports['./context']?.import).toBe('./lib/context.js')
135
+ expect(pkg.exports['./context']?.bun).toBe('./src/context.ts')
136
+ })
137
+
138
+ it('vl-tools.config.mjs externalizes @pyreon/head/context for every sub-entry build', () => {
139
+ // Plain text read instead of dynamic import — vitest serves test
140
+ // files via http:// URLs, and Node's default ESM loader rejects
141
+ // anything outside `file:` / `data:`. Text-grep is enough: this
142
+ // assertion is structural, not behavioural — the build pipeline
143
+ // already proved the contract by emitting external imports in
144
+ // every sub-bundle (invariants 1 and 2 above).
145
+ const cfg = readFileSync(resolve(PKG_ROOT, 'vl-tools.config.mjs'), 'utf8')
146
+ expect(cfg).toContain("'@pyreon/head/context'")
147
+ expect(cfg).toMatch(/external\s*:/)
148
+ })
149
+ },
150
+ )
@@ -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
+ })
package/src/use-head.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { onMount, onUnmount, useContext } from '@pyreon/core'
2
2
  import { effect } from '@pyreon/reactivity'
3
3
  import type { HeadEntry, HeadTag, UseHeadInput } from './context'
4
- import { HeadContext } from './context'
4
+ // Runtime VALUE import via self-package path so the build externalizes
5
+ // the symbol — every sub-entry resolves to the same `lib/context.js` at
6
+ // runtime. See `ssr.ts` for the full rationale + `tests/context-identity.test.ts`.
7
+ import { HeadContext } from '@pyreon/head/context'
5
8
  import { syncDom } from './dom'
6
9
 
7
10
  /** Cast a strict tag interface to the internal props format, stripping undefined values */