@praxisjs/head 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/head-stack.d.ts.map +1 -1
- package/dist/head-stack.js +74 -62
- package/dist/head-stack.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +15 -6
- package/src/head-stack.ts +70 -62
- package/dist/__tests__/head.test.d.ts +0 -2
- package/dist/__tests__/head.test.d.ts.map +0 -1
- package/dist/__tests__/head.test.js +0 -544
- package/dist/__tests__/head.test.js.map +0 -1
- package/dist/__tests__/internal.test.d.ts +0 -2
- package/dist/__tests__/internal.test.d.ts.map +0 -1
- package/dist/__tests__/internal.test.js +0 -32
- package/dist/__tests__/internal.test.js.map +0 -1
- package/src/__tests__/head.test.ts +0 -454
- package/src/__tests__/internal.test.ts +0 -38
- package/tsconfig.json +0 -8
|
@@ -1,454 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
3
|
-
import { signal } from "@praxisjs/core/internal";
|
|
4
|
-
import { StatefulComponent } from "@praxisjs/core";
|
|
5
|
-
|
|
6
|
-
import { Head } from "../index";
|
|
7
|
-
import { pushHead, removeHead, resetHeadState, headVersion } from "../head-stack";
|
|
8
|
-
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
document.head.innerHTML = "";
|
|
11
|
-
resetHeadState();
|
|
12
|
-
document.title = "initial";
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
// ─── head-stack unit tests ────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
describe("pushHead / removeHead", () => {
|
|
18
|
-
it("sets document.title", () => {
|
|
19
|
-
const id = Symbol();
|
|
20
|
-
pushHead(id, { title: "Hello" });
|
|
21
|
-
expect(document.title).toBe("Hello");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("adds meta name=description", () => {
|
|
25
|
-
const id = Symbol();
|
|
26
|
-
pushHead(id, { description: "My desc" });
|
|
27
|
-
expect(document.querySelector<HTMLMetaElement>('meta[name="description"]')?.content).toBe("My desc");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("adds og: meta properties", () => {
|
|
31
|
-
const id = Symbol();
|
|
32
|
-
pushHead(id, { og: { title: "OG Title", image: "https://example.com/img.jpg" } });
|
|
33
|
-
expect(document.querySelector('[property="og:title"]')?.getAttribute("content")).toBe("OG Title");
|
|
34
|
-
expect(document.querySelector('[property="og:image"]')?.getAttribute("content")).toBe("https://example.com/img.jpg");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("adds twitter: meta tags", () => {
|
|
38
|
-
const id = Symbol();
|
|
39
|
-
pushHead(id, { twitter: { card: "summary_large_image", title: "TW" } });
|
|
40
|
-
expect(document.querySelector('[name="twitter:card"]')?.getAttribute("content")).toBe("summary_large_image");
|
|
41
|
-
expect(document.querySelector('[name="twitter:title"]')?.getAttribute("content")).toBe("TW");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("adds canonical link", () => {
|
|
45
|
-
const id = Symbol();
|
|
46
|
-
pushHead(id, { canonical: "https://example.com/page" });
|
|
47
|
-
expect(document.querySelector('link[rel="canonical"]')?.getAttribute("href")).toBe("https://example.com/page");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("adds arbitrary meta[] by name and property", () => {
|
|
51
|
-
const id = Symbol();
|
|
52
|
-
pushHead(id, {
|
|
53
|
-
meta: [
|
|
54
|
-
{ name: "robots", content: "noindex" },
|
|
55
|
-
{ property: "fb:app_id", content: "12345" },
|
|
56
|
-
],
|
|
57
|
-
});
|
|
58
|
-
expect(document.querySelector('[name="robots"]')?.getAttribute("content")).toBe("noindex");
|
|
59
|
-
expect(document.querySelector('[property="fb:app_id"]')?.getAttribute("content")).toBe("12345");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("updates an existing entry (no duplicate push)", () => {
|
|
63
|
-
const id = Symbol();
|
|
64
|
-
pushHead(id, { title: "v1" });
|
|
65
|
-
pushHead(id, { title: "v2" });
|
|
66
|
-
expect(document.title).toBe("v2");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("restores initial title and removes managed metas on removeHead", () => {
|
|
70
|
-
const id = Symbol();
|
|
71
|
-
pushHead(id, { title: "Page", description: "Desc" });
|
|
72
|
-
removeHead(id);
|
|
73
|
-
expect(document.title).toBe("initial");
|
|
74
|
-
expect(document.querySelector('meta[name="description"]')).toBeNull();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("stack: last entry wins; removing it restores the previous one", () => {
|
|
78
|
-
const a = Symbol();
|
|
79
|
-
const b = Symbol();
|
|
80
|
-
pushHead(a, { title: "A", description: "desc-a" });
|
|
81
|
-
pushHead(b, { title: "B", description: "desc-b" });
|
|
82
|
-
expect(document.title).toBe("B");
|
|
83
|
-
removeHead(b);
|
|
84
|
-
expect(document.title).toBe("A");
|
|
85
|
-
expect(document.querySelector<HTMLMetaElement>('meta[name="description"]')?.content).toBe("desc-a");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("removes all managed metas when stack is empty", () => {
|
|
89
|
-
const id = Symbol();
|
|
90
|
-
pushHead(id, { description: "x", og: { title: "y" } });
|
|
91
|
-
removeHead(id);
|
|
92
|
-
expect(document.querySelectorAll("[data-praxis-head]").length).toBe(0);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("removeHead with a non-existent id is a no-op (idx < 0 branch)", () => {
|
|
96
|
-
const id = Symbol("never-pushed");
|
|
97
|
-
expect(() => removeHead(id)).not.toThrow();
|
|
98
|
-
expect(document.querySelectorAll("[data-praxis-head]").length).toBe(0);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("_apply restores title to '' when _initialTitle is undefined and stack is empty", () => {
|
|
102
|
-
// resetHeadState leaves _initialTitle = undefined; calling removeHead on a missing id
|
|
103
|
-
// triggers _apply with an empty stack and undefined _initialTitle → title = "" fallback.
|
|
104
|
-
const id = Symbol("missing");
|
|
105
|
-
document.title = "should-be-cleared";
|
|
106
|
-
removeHead(id);
|
|
107
|
-
expect(document.title).toBe("");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("adds preload link with href and as", () => {
|
|
111
|
-
const id = Symbol();
|
|
112
|
-
pushHead(id, { preload: [{ href: "/fonts/inter.woff2", as: "font" }] });
|
|
113
|
-
const el = document.querySelector<HTMLLinkElement>('link[rel="preload"]');
|
|
114
|
-
expect(el?.href).toContain("/fonts/inter.woff2");
|
|
115
|
-
expect(el?.getAttribute("as")).toBe("font");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("adds preload link with type and crossOrigin", () => {
|
|
119
|
-
const id = Symbol();
|
|
120
|
-
pushHead(id, {
|
|
121
|
-
preload: [{ href: "/fonts/inter.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" }],
|
|
122
|
-
});
|
|
123
|
-
const el = document.querySelector<HTMLLinkElement>('link[rel="preload"]');
|
|
124
|
-
expect(el?.type).toBe("font/woff2");
|
|
125
|
-
expect(el?.crossOrigin).toBe("anonymous");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("adds multiple preload links", () => {
|
|
129
|
-
const id = Symbol();
|
|
130
|
-
pushHead(id, {
|
|
131
|
-
preload: [
|
|
132
|
-
{ href: "/style.css", as: "style" },
|
|
133
|
-
{ href: "/app.js", as: "script" },
|
|
134
|
-
],
|
|
135
|
-
});
|
|
136
|
-
const els = document.querySelectorAll('link[rel="preload"]');
|
|
137
|
-
expect(els.length).toBe(2);
|
|
138
|
-
expect(els[0].getAttribute("as")).toBe("style");
|
|
139
|
-
expect(els[1].getAttribute("as")).toBe("script");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("adds prefetch link with href", () => {
|
|
143
|
-
const id = Symbol();
|
|
144
|
-
pushHead(id, { prefetch: [{ href: "/about" }] });
|
|
145
|
-
const el = document.querySelector<HTMLLinkElement>('link[rel="prefetch"]');
|
|
146
|
-
expect(el?.href).toContain("/about");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("adds prefetch link with optional as", () => {
|
|
150
|
-
const id = Symbol();
|
|
151
|
-
pushHead(id, { prefetch: [{ href: "/chunk.js", as: "script" }] });
|
|
152
|
-
const el = document.querySelector<HTMLLinkElement>('link[rel="prefetch"]');
|
|
153
|
-
expect(el?.getAttribute("as")).toBe("script");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("adds multiple prefetch links", () => {
|
|
157
|
-
const id = Symbol();
|
|
158
|
-
pushHead(id, {
|
|
159
|
-
prefetch: [
|
|
160
|
-
{ href: "/about" },
|
|
161
|
-
{ href: "/contact" },
|
|
162
|
-
],
|
|
163
|
-
});
|
|
164
|
-
const els = document.querySelectorAll('link[rel="prefetch"]');
|
|
165
|
-
expect(els.length).toBe(2);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("cleans up preload and prefetch on removeHead", () => {
|
|
169
|
-
const id = Symbol();
|
|
170
|
-
pushHead(id, {
|
|
171
|
-
preload: [{ href: "/font.woff2", as: "font" }],
|
|
172
|
-
prefetch: [{ href: "/next-page" }],
|
|
173
|
-
});
|
|
174
|
-
removeHead(id);
|
|
175
|
-
expect(document.querySelector('link[rel="preload"]')).toBeNull();
|
|
176
|
-
expect(document.querySelector('link[rel="prefetch"]')).toBeNull();
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it("meta tag with neither name nor property is silently skipped", () => {
|
|
180
|
-
const id = Symbol();
|
|
181
|
-
pushHead(id, { meta: [{ content: "orphan-content" }] });
|
|
182
|
-
const all = document.querySelectorAll("[data-praxis-head]");
|
|
183
|
-
const hasOrphan = Array.from(all).some((el) =>
|
|
184
|
-
el.getAttribute("content") === "orphan-content",
|
|
185
|
-
);
|
|
186
|
-
expect(hasOrphan).toBe(false);
|
|
187
|
-
removeHead(id);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("omits undefined og fields", () => {
|
|
191
|
-
const id = Symbol();
|
|
192
|
-
pushHead(id, { og: { title: "T" } });
|
|
193
|
-
expect(document.querySelector('[property="og:image"]')).toBeNull();
|
|
194
|
-
expect(document.querySelector('[property="og:title"]')?.getAttribute("content")).toBe("T");
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
// ─── SSR guards ───────────────────────────────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
describe("SSR guards — no DOM operations when document is undefined", () => {
|
|
201
|
-
afterEach(() => {
|
|
202
|
-
vi.unstubAllGlobals();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("pushHead is a no-op when document is undefined", () => {
|
|
206
|
-
const versionBefore = headVersion();
|
|
207
|
-
vi.stubGlobal("document", undefined);
|
|
208
|
-
const id = Symbol();
|
|
209
|
-
expect(() => pushHead(id, { title: "SSR" })).not.toThrow();
|
|
210
|
-
// headVersion must NOT have changed (function returned early)
|
|
211
|
-
expect(headVersion()).toBe(versionBefore);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("removeHead is a no-op when document is undefined", () => {
|
|
215
|
-
vi.stubGlobal("document", undefined);
|
|
216
|
-
const id = Symbol();
|
|
217
|
-
expect(() => removeHead(id)).not.toThrow();
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("resetHeadState skips DOM cleanup when document is undefined", () => {
|
|
221
|
-
vi.stubGlobal("document", undefined);
|
|
222
|
-
expect(() => resetHeadState()).not.toThrow();
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe("resetHeadState — DOM cleanup", () => {
|
|
227
|
-
it("removes managed elements from document.head when called with elements present", () => {
|
|
228
|
-
const id = Symbol();
|
|
229
|
-
pushHead(id, { title: "Before Reset", description: "desc" });
|
|
230
|
-
expect(document.querySelectorAll("[data-praxis-head]").length).toBeGreaterThan(0);
|
|
231
|
-
|
|
232
|
-
// Call resetHeadState WITHOUT clearing head.innerHTML first — exercises the forEach
|
|
233
|
-
resetHeadState();
|
|
234
|
-
expect(document.querySelectorAll("[data-praxis-head]").length).toBe(0);
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// ─── @Head decorator ──────────────────────────────────────────────────────────
|
|
239
|
-
|
|
240
|
-
// All decorated classes at module scope for correct TypeScript inference.
|
|
241
|
-
|
|
242
|
-
@Head({ title: "Static Title", description: "Static desc" })
|
|
243
|
-
class StaticComp extends StatefulComponent {
|
|
244
|
-
render() { return null; }
|
|
245
|
-
}
|
|
246
|
-
Object.assign(StaticComp, { __isComponent: true as const, __isStateless: false });
|
|
247
|
-
|
|
248
|
-
@Head({ title: "Before" })
|
|
249
|
-
class BeforeComp extends StatefulComponent {
|
|
250
|
-
render() { return null; }
|
|
251
|
-
}
|
|
252
|
-
Object.assign(BeforeComp, { __isComponent: true as const, __isStateless: false });
|
|
253
|
-
|
|
254
|
-
describe("@Head decorator — static config", () => {
|
|
255
|
-
it("applies head config on onMount", () => {
|
|
256
|
-
const inst = new StaticComp({});
|
|
257
|
-
inst.onMount?.();
|
|
258
|
-
expect(document.title).toBe("Static Title");
|
|
259
|
-
expect(document.querySelector<HTMLMetaElement>('meta[name="description"]')?.content).toBe("Static desc");
|
|
260
|
-
inst.onUnmount?.();
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("removes head config on onUnmount", () => {
|
|
264
|
-
const inst = new BeforeComp({});
|
|
265
|
-
inst.onMount?.();
|
|
266
|
-
expect(document.title).toBe("Before");
|
|
267
|
-
inst.onUnmount?.();
|
|
268
|
-
expect(document.title).toBe("initial");
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
describe("@Head decorator — reactive config", () => {
|
|
273
|
-
it("updates document head when signal changes", async () => {
|
|
274
|
-
const count = signal(0);
|
|
275
|
-
|
|
276
|
-
@Head((self: ReactiveComp) => ({ title: `Count: ${self.n()}` }))
|
|
277
|
-
class ReactiveComp extends StatefulComponent {
|
|
278
|
-
n = count;
|
|
279
|
-
render() { return null; }
|
|
280
|
-
}
|
|
281
|
-
Object.assign(ReactiveComp, { __isComponent: true as const, __isStateless: false });
|
|
282
|
-
|
|
283
|
-
const inst = new ReactiveComp({});
|
|
284
|
-
inst.onMount?.();
|
|
285
|
-
expect(document.title).toBe("Count: 0");
|
|
286
|
-
count.set(5);
|
|
287
|
-
await Promise.resolve();
|
|
288
|
-
expect(document.title).toBe("Count: 5");
|
|
289
|
-
inst.onUnmount?.();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it("stops tracking after onUnmount", async () => {
|
|
293
|
-
const val = signal("a");
|
|
294
|
-
|
|
295
|
-
@Head((self: StopComp) => ({ title: self.v() }))
|
|
296
|
-
class StopComp extends StatefulComponent {
|
|
297
|
-
v = val;
|
|
298
|
-
render() { return null; }
|
|
299
|
-
}
|
|
300
|
-
Object.assign(StopComp, { __isComponent: true as const, __isStateless: false });
|
|
301
|
-
|
|
302
|
-
const inst = new StopComp({});
|
|
303
|
-
inst.onMount?.();
|
|
304
|
-
inst.onUnmount?.();
|
|
305
|
-
val.set("b");
|
|
306
|
-
await Promise.resolve();
|
|
307
|
-
expect(document.title).not.toBe("b");
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it("multiple instances — last mounted wins, cleanup restores previous", () => {
|
|
311
|
-
const t1 = signal("Page A");
|
|
312
|
-
const t2 = signal("Page B");
|
|
313
|
-
|
|
314
|
-
@Head((self: CompA) => ({ title: self.t() }))
|
|
315
|
-
class CompA extends StatefulComponent {
|
|
316
|
-
t = t1;
|
|
317
|
-
render() { return null; }
|
|
318
|
-
}
|
|
319
|
-
Object.assign(CompA, { __isComponent: true as const, __isStateless: false });
|
|
320
|
-
|
|
321
|
-
@Head((self: CompB) => ({ title: self.t() }))
|
|
322
|
-
class CompB extends StatefulComponent {
|
|
323
|
-
t = t2;
|
|
324
|
-
render() { return null; }
|
|
325
|
-
}
|
|
326
|
-
Object.assign(CompB, { __isComponent: true as const, __isStateless: false });
|
|
327
|
-
|
|
328
|
-
const a = new CompA({});
|
|
329
|
-
const b = new CompB({});
|
|
330
|
-
a.onMount?.();
|
|
331
|
-
expect(document.title).toBe("Page A");
|
|
332
|
-
b.onMount?.();
|
|
333
|
-
expect(document.title).toBe("Page B");
|
|
334
|
-
b.onUnmount?.();
|
|
335
|
-
expect(document.title).toBe("Page A");
|
|
336
|
-
a.onUnmount?.();
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it("full og: config is applied reactively", async () => {
|
|
340
|
-
const slug = signal("hello");
|
|
341
|
-
|
|
342
|
-
@Head((self: OGComp) => ({
|
|
343
|
-
og: { title: `Post: ${self.s()}`, url: `https://site.com/${self.s()}` },
|
|
344
|
-
}))
|
|
345
|
-
class OGComp extends StatefulComponent {
|
|
346
|
-
s = slug;
|
|
347
|
-
render() { return null; }
|
|
348
|
-
}
|
|
349
|
-
Object.assign(OGComp, { __isComponent: true as const, __isStateless: false });
|
|
350
|
-
|
|
351
|
-
const inst = new OGComp({});
|
|
352
|
-
inst.onMount?.();
|
|
353
|
-
expect(document.querySelector('[property="og:title"]')?.getAttribute("content")).toBe("Post: hello");
|
|
354
|
-
slug.set("world");
|
|
355
|
-
await Promise.resolve();
|
|
356
|
-
expect(document.querySelector('[property="og:title"]')?.getAttribute("content")).toBe("Post: world");
|
|
357
|
-
inst.onUnmount?.();
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it("getter is called only when deps change, not on every frame", async () => {
|
|
361
|
-
const name = signal("Alice");
|
|
362
|
-
const getter = vi.fn((self: SpyComp) => ({ title: self.n() }));
|
|
363
|
-
|
|
364
|
-
@Head(getter)
|
|
365
|
-
class SpyComp extends StatefulComponent {
|
|
366
|
-
n = name;
|
|
367
|
-
render() { return null; }
|
|
368
|
-
}
|
|
369
|
-
Object.assign(SpyComp, { __isComponent: true as const, __isStateless: false });
|
|
370
|
-
|
|
371
|
-
const inst = new SpyComp({});
|
|
372
|
-
inst.onMount?.();
|
|
373
|
-
expect(getter).toHaveBeenCalledTimes(1);
|
|
374
|
-
name.set("Bob");
|
|
375
|
-
await Promise.resolve();
|
|
376
|
-
expect(getter).toHaveBeenCalledTimes(2);
|
|
377
|
-
await Promise.resolve();
|
|
378
|
-
expect(getter).toHaveBeenCalledTimes(2); // no extra calls
|
|
379
|
-
inst.onUnmount?.();
|
|
380
|
-
});
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
describe("head tag diffing", () => {
|
|
384
|
-
const tags = () => [...document.head.querySelectorAll("[data-praxis-head]")];
|
|
385
|
-
const byName = (name: string) => document.head.querySelector(`meta[name="${name}"]`);
|
|
386
|
-
|
|
387
|
-
it("keeps tags that did not change and only replaces the ones that did", () => {
|
|
388
|
-
const id = Symbol();
|
|
389
|
-
pushHead(id, { description: "same", meta: [{ name: "robots", content: "index" }] });
|
|
390
|
-
const description = byName("description");
|
|
391
|
-
const robots = byName("robots");
|
|
392
|
-
|
|
393
|
-
pushHead(id, { description: "same", meta: [{ name: "robots", content: "noindex" }] });
|
|
394
|
-
|
|
395
|
-
expect(byName("description")).toBe(description);
|
|
396
|
-
expect(byName("robots")).not.toBe(robots);
|
|
397
|
-
expect(byName("robots")?.getAttribute("content")).toBe("noindex");
|
|
398
|
-
expect(tags()).toHaveLength(2);
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
it("removes tags that are no longer in the config", () => {
|
|
402
|
-
const id = Symbol();
|
|
403
|
-
pushHead(id, { description: "d", canonical: "https://example.com/" });
|
|
404
|
-
pushHead(id, { description: "d" });
|
|
405
|
-
expect(document.head.querySelector('link[rel="canonical"]')).toBeNull();
|
|
406
|
-
expect(byName("description")).not.toBeNull();
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
it("keeps identical repeated tags as separate elements", () => {
|
|
410
|
-
const id = Symbol();
|
|
411
|
-
pushHead(id, { meta: [{ name: "x", content: "1" }, { name: "x", content: "1" }] });
|
|
412
|
-
expect(document.head.querySelectorAll('meta[name="x"]')).toHaveLength(2);
|
|
413
|
-
pushHead(id, { meta: [{ name: "x", content: "1" }, { name: "x", content: "1" }] });
|
|
414
|
-
expect(document.head.querySelectorAll('meta[name="x"]')).toHaveLength(2);
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
it("recreates a managed tag that was removed from the document", () => {
|
|
418
|
-
const id = Symbol();
|
|
419
|
-
pushHead(id, { description: "d" });
|
|
420
|
-
byName("description")?.remove();
|
|
421
|
-
pushHead(id, { description: "d" });
|
|
422
|
-
expect(byName("description")?.getAttribute("content")).toBe("d");
|
|
423
|
-
expect(tags()).toHaveLength(1);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it("sweeps managed-looking tags a prerender left behind", () => {
|
|
427
|
-
const stale = document.createElement("meta");
|
|
428
|
-
stale.setAttribute("name", "old");
|
|
429
|
-
stale.setAttribute("data-praxis-head", "");
|
|
430
|
-
document.head.appendChild(stale);
|
|
431
|
-
pushHead(Symbol(), { description: "d" });
|
|
432
|
-
expect(byName("old")).toBeNull();
|
|
433
|
-
expect(byName("description")).not.toBeNull();
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it("restores the title and removes every tag once the last entry is gone", () => {
|
|
437
|
-
const id = Symbol();
|
|
438
|
-
pushHead(id, { title: "Page", description: "d" });
|
|
439
|
-
removeHead(id);
|
|
440
|
-
expect(document.title).toBe("initial");
|
|
441
|
-
expect(tags()).toHaveLength(0);
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
it("a nested entry's tags replace the outer ones and the outer ones come back on removal", () => {
|
|
445
|
-
const outer = Symbol();
|
|
446
|
-
const inner = Symbol();
|
|
447
|
-
pushHead(outer, { description: "outer" });
|
|
448
|
-
pushHead(inner, { description: "inner" });
|
|
449
|
-
expect(byName("description")?.getAttribute("content")).toBe("inner");
|
|
450
|
-
removeHead(inner);
|
|
451
|
-
expect(byName("description")?.getAttribute("content")).toBe("outer");
|
|
452
|
-
expect(tags()).toHaveLength(1);
|
|
453
|
-
});
|
|
454
|
-
});
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { pushHead, removeHead } from "../head-stack";
|
|
5
|
-
import { resetHeadState } from "../internal";
|
|
6
|
-
|
|
7
|
-
describe("@praxisjs/head/internal", () => {
|
|
8
|
-
it("exports resetHeadState, which removes managed head elements", () => {
|
|
9
|
-
document.head.innerHTML = "";
|
|
10
|
-
pushHead(Symbol("page"), { title: "pushed", description: "desc" });
|
|
11
|
-
expect(document.head.querySelectorAll("[data-praxis-head]").length).toBeGreaterThan(0);
|
|
12
|
-
|
|
13
|
-
resetHeadState();
|
|
14
|
-
|
|
15
|
-
expect(document.head.querySelectorAll("[data-praxis-head]")).toHaveLength(0);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("forgets the remembered initial title, so the next page's baseline is captured fresh", () => {
|
|
19
|
-
// Without resetHeadState between "pages", the initial title is only ever
|
|
20
|
-
// captured once (pushHead's `_initialTitle ??=`) — exactly the leak an
|
|
21
|
-
// @praxisjs/ssg prerender run (many render() calls, one Node process)
|
|
22
|
-
// needs this export to avoid between routes.
|
|
23
|
-
document.head.innerHTML = "";
|
|
24
|
-
document.title = "page A";
|
|
25
|
-
const idA = Symbol("a");
|
|
26
|
-
pushHead(idA, { title: "A" });
|
|
27
|
-
removeHead(idA);
|
|
28
|
-
expect(document.title).toBe("page A");
|
|
29
|
-
|
|
30
|
-
resetHeadState();
|
|
31
|
-
|
|
32
|
-
document.title = "page B";
|
|
33
|
-
const idB = Symbol("b");
|
|
34
|
-
pushHead(idB, { title: "B" });
|
|
35
|
-
removeHead(idB);
|
|
36
|
-
expect(document.title).toBe("page B");
|
|
37
|
-
});
|
|
38
|
-
});
|