@pyreon/head 0.22.0 → 0.23.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.
@@ -3,48 +3,46 @@
3
3
  * place across the published `lib/` artifacts.
4
4
  *
5
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.
6
+ * sub-entries (`lib/{index,provider,use-head,ssr}.js`) and the shared
7
+ * `@vitus-labs/tools-rolldown` (< 2.4.0) invoked rolldown ONCE PER
8
+ * SUB-ENTRY (no cross-entry shared chunks). Result: every sub-bundle
9
+ * independently inlined `context.ts` and ran its own `createContext(null)`
10
+ * at module init — each call minted a unique `Symbol.for(...).id`, so a
11
+ * `useContext(HeadContext)` lookup in one bundle (e.g. the app's
12
+ * `useHead` from `lib/use-head.js`) silently MISSED a
13
+ * `provide(HeadContext)` from another (e.g. `renderWithHead` from
14
+ * `lib/ssr.js`). The bug was invisible in dev / source-mode tests because
15
+ * Vite's `bun` condition resolves to a single shared `src/context.ts`
16
+ * (ESM single-evaluation guarantee), but SSG output silently dropped
17
+ * every `useHead()`-registered tag — bad for SEO, social scrapers,
18
+ * accessibility, no-JS.
19
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.
20
+ * The durable fix lives upstream in `@vitus-labs/tools-rolldown >= 2.4.0`:
21
+ * the build tool now creates SHARED CHUNKS across sub-entries, so the
22
+ * shared `context.ts` gets hoisted into a single chunk (`lib/context.js`)
23
+ * that every other sub-entry imports via relative-path `./context.js`.
24
+ * `createContext(null)` runs exactly once at runtime; `HeadContext` is
25
+ * one Symbol across every sub-entry's bundle. No per-package
26
+ * externalization / self-package-import workaround needed.
25
27
  *
26
- * Structural assertions:
28
+ * Structural assertions (the BUG-CLASS-LOCK — same intent, cleaner shape):
27
29
  * 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 externalthe bundler's signal that the symbol comes from
31
- * a shared runtime chunk, not from inlined source).
30
+ * 2. EVERY other published JS file under `lib/` (including
31
+ * `lib/_chunks/*.js` shared chunks the tool emits) has ZERO
32
+ * `createContext` referencesthey all import `HeadContext` from
33
+ * `./context.js`, sharing the single Symbol identity.
32
34
  *
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.
35
+ * Together these invariants make the bug class structurally impossible
36
+ * to re-introduce silently — any future regression (e.g. downgrade of
37
+ * the build tool below 2.4.0, or a build-config change that re-enables
38
+ * per-entry inlining) flips one of the per-bundle counters and trips
39
+ * the assertion. Bisect-verified by reverting the
40
+ * `@vitus-labs/tools-rolldown` bump: every non-context sub-bundle gets
41
+ * its own inlined `createContext(null)` call (2 occurrences each), and
42
+ * the second assertion fails on the first non-context bundle.
45
43
  */
46
44
 
47
- import { existsSync, readFileSync } from 'node:fs'
45
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
48
46
  import { resolve } from 'node:path'
49
47
  import { describe, expect, it } from 'vitest'
50
48
 
@@ -52,7 +50,21 @@ const PKG_ROOT = resolve(__dirname, '..', '..')
52
50
  const LIB_DIR = resolve(PKG_ROOT, 'lib')
53
51
  const libExists = existsSync(resolve(LIB_DIR, 'index.js'))
54
52
 
55
- const read = (name: string) => readFileSync(resolve(LIB_DIR, name), 'utf8')
53
+ const read = (rel: string) => readFileSync(resolve(LIB_DIR, rel), 'utf8')
54
+
55
+ /** Every published JS file under lib/ (incl. _chunks/), excluding source maps. */
56
+ function publishedJsFiles(): string[] {
57
+ const out: string[] = []
58
+ for (const entry of readdirSync(LIB_DIR, { withFileTypes: true })) {
59
+ if (entry.isFile() && entry.name.endsWith('.js')) out.push(entry.name)
60
+ else if (entry.isDirectory() && entry.name === '_chunks') {
61
+ for (const sub of readdirSync(resolve(LIB_DIR, entry.name))) {
62
+ if (sub.endsWith('.js')) out.push(`_chunks/${sub}`)
63
+ }
64
+ }
65
+ }
66
+ return out
67
+ }
56
68
 
57
69
  /**
58
70
  * The bundle gate runs only when `lib/` has been built — `bun install`'s
@@ -67,10 +79,9 @@ describe.skipIf(!libExists)(
67
79
  () => {
68
80
  // ── Invariant 1: ONE createContext call across all bundles ───────
69
81
  //
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.
82
+ // `lib/context.js` is the canonical single chunk. Every other
83
+ // sub-bundle / shared chunk should import `HeadContext` from it,
84
+ // NOT inline a fresh `createContext(null)` call.
74
85
 
75
86
  it('lib/context.js is the SINGLE bundle that calls createContext()', () => {
76
87
  const src = read('context.js')
@@ -81,50 +92,27 @@ describe.skipIf(!libExists)(
81
92
  expect(src).toContain('createContext(null)')
82
93
  })
83
94
 
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 ─────────
95
+ // ── Invariant 2: ZERO createContext references in EVERY other JS ──
100
96
  //
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`).
97
+ // Covers both the top-level sub-entries AND the `_chunks/*.js` files
98
+ // the build tool now emits any file that contains `createContext`
99
+ // would be running it at module-init and minting its own Symbol.
104
100
 
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)
101
+ it('NO other lib/*.js (or lib/_chunks/*.js) calls createContext()', () => {
102
+ const offenders: Array<{ file: string; count: number }> = []
103
+ for (const rel of publishedJsFiles()) {
104
+ if (rel === 'context.js') continue
105
+ const count = read(rel).match(/createContext/g)?.length ?? 0
106
+ if (count > 0) offenders.push({ file: rel, count })
107
+ }
108
+ expect(offenders).toEqual([])
119
109
  })
120
110
 
121
111
  // ── Invariant 3: the package.json wiring that enables it ─────────
122
112
  //
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.
113
+ // The `./context` sub-export gives `HeadContext` a stable public
114
+ // address. Locking it here means a future revert immediately fails
115
+ // the test.
128
116
 
129
117
  it('package.json declares the ./context sub-export', () => {
130
118
  const pkg = JSON.parse(read('../package.json')) as {
@@ -134,17 +122,5 @@ describe.skipIf(!libExists)(
134
122
  expect(pkg.exports['./context']?.import).toBe('./lib/context.js')
135
123
  expect(pkg.exports['./context']?.bun).toBe('./src/context.ts')
136
124
  })
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
125
  },
150
126
  )
package/src/use-head.ts CHANGED
@@ -1,10 +1,7 @@
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
- // 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'
4
+ import { HeadContext } from './context'
8
5
  import { syncDom } from './dom'
9
6
 
10
7
  /** Cast a strict tag interface to the internal props format, stripping undefined values */