Skip to content

Commit 381a859

Browse files
committed
feat(router): plain head exports merging order
reverses head merging for plain objects but retains order for functions
1 parent c4752e6 commit 381a859

File tree

4 files changed

+200
-18
lines changed

4 files changed

+200
-18
lines changed

.changeset/some-emus-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/router': major
3+
---
4+
5+
Breaking: The order of head export merging has been slightly. Plain objects now override outer ones. Functions still are run inner-first.

packages/docs/src/routes/docs/(qwikrouter)/pages/index.mdx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export default component$(() => {
3636

3737
## `head` export
3838

39-
Every page can export a `head` property (or function) that returns a `DocumentHead` object. The `DocumentHead` object is used to resolve the title of the page, as well as the meta, links and styles.
39+
Every page can export a `head` property (or function) that returns a `DocumentHead` object. The `DocumentHead` object is used to resolve the `title` of the page, as well as the `meta`, `links`, `styles` and `scripts`.
4040

4141
This API allows you to set the title of the page, as well as the meta, open graph, twitter tags and links. This is useful for SEO and social sharing.
4242

@@ -72,20 +72,34 @@ export const head: DocumentHead = {
7272
href: 'https://example.com/about',
7373
},
7474
],
75+
styles: [
76+
{
77+
style: '.error { background-color: red; }',
78+
},
79+
],
80+
scripts: [
81+
{
82+
type: "application/ld+json",
83+
script: JSON.stringify({
84+
"@context": "https://schema.org",
85+
"@type": "ItemList",
86+
}),
87+
},
88+
],
7589
};
7690
```
7791

7892
The example above sets the title, as well as some [Open Graph](https://ogp.me/) meta and the [canonical link](https://developers.google.com/search/docs/crawling-indexing/canonicalization).
7993

8094
> HTML places the `<head>` tag as the first element within `<html>` (at the very top of the HTML content). The `<head>` section is not something that your route component renders directly because it would break the HTML streaming.
8195
82-
Look into `useDocumentHead()` to read and consume the `DocumentHead` object from within your component.
96+
Look into `useDocumentHead()` to read and consume the `DocumentHead` object from within your component; you can also use `<DocumentHeadTags />` to render the tags directly and correctly.
8397

8498
### Dynamic Head
8599

86-
You can also export a function that returns a `DocumentHead` object, allowing you to programmatically set the `<title>`, `<meta>` or `<link>` tags.
100+
You can also export a function that returns a `DocumentHead` object, allowing you to programmatically set the `<title>`, `<meta>`, `<link>`, `<style>`, and `<script>` tags.
87101

88-
This allows you to configure the `<head>`, including the title, meta or links using data from `routeLoader$()` or `routeAction$()`.
102+
This allows you to configure the `<head>` using data from `routeLoader$()` or `routeAction$()`.
89103

90104
We can use the `resolveValue` method to get the value of a `routeLoader$()` or `routeAction$()` within the `head` function.
91105

@@ -121,11 +135,31 @@ export const head: DocumentHead = ({resolveValue, params}) => {
121135
};
122136
```
123137

138+
#### A note on ordering and merging
139+
140+
The `head` exports are merged in an outward-in manner. This means that values from `index.tsx` will override the layout's `head` export, and that in turn will override the root layout's `head` export.
141+
142+
However, for dynamic `head()` exports (functions), the ordering is reversed. This allows to always add something to the title, for example, in a layout component.
143+
144+
```ts
145+
export const head: DocumentHead = ({ head }) => {
146+
return {
147+
title: `MySite - ${head.title}`,
148+
};
149+
};
150+
```
151+
152+
So first all plain object `head` exports are merged, and then the function `head` exports are called in reverse order.
153+
154+
Merging (both from objects or functions) is done by concatenating arrays, or overriding.
155+
If two values in arrays (like `meta` or `links`) have the same `key`, the last specified one wins. This allows you to override specific meta tags.
156+
Other than that, entries that don't have a `key`, or have a unique `key`, are always included.
157+
124158
### Server-injected Head
125159

126160
You can also pass `documentHead` to `createRenderer()` as part of the `serverData` option.
127161

128-
The values passed will be used as the default values for `useDocumentHead()`, before the `head` exports are resolved.
162+
The values passed will be used as the default values for `useDocumentHead()`, before the `head` exports are resolved. So layouts and pages can override the values set here.
129163

130164
```tsx title="src/entry.ssr.tsx" {10}
131165
import { createRenderer } from "@qwik.dev/router";

packages/qwik-router/src/runtime/src/head.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
Editable,
1212
ResolveSyncValue,
1313
ActionInternal,
14+
ContentModuleHead,
1415
} from './types';
1516
import { isPromise } from './utils';
1617

@@ -37,28 +38,36 @@ export const resolveHead = (
3738
}
3839
return data;
3940
}) as any as ResolveSyncValue;
40-
const headProps: DocumentHeadProps = {
41-
head,
42-
withLocale: (fn) => withLocale(locale, fn),
43-
resolveValue: getData,
44-
...routeLocation,
45-
};
4641

47-
for (let i = contentModules.length - 1; i >= 0; i--) {
48-
const contentModuleHead = contentModules[i] && contentModules[i].head;
42+
const fns: Extract<ContentModuleHead, Function>[] = [];
43+
for (const contentModule of contentModules) {
44+
const contentModuleHead = contentModule?.head;
4945
if (contentModuleHead) {
5046
if (typeof contentModuleHead === 'function') {
51-
resolveDocumentHead(
52-
head,
53-
withLocale(locale, () => contentModuleHead(headProps))
54-
);
47+
// Functions are executed inner before outer
48+
fns.unshift(contentModuleHead);
5549
} else if (typeof contentModuleHead === 'object') {
50+
// Objects are merged inner over outer
5651
resolveDocumentHead(head, contentModuleHead);
5752
}
5853
}
5954
}
55+
if (fns.length) {
56+
const headProps: DocumentHeadProps = {
57+
head,
58+
withLocale: (fn) => withLocale(locale, fn),
59+
resolveValue: getData,
60+
...routeLocation,
61+
};
62+
63+
withLocale(locale, () => {
64+
for (const fn of fns) {
65+
resolveDocumentHead(head, fn(headProps));
66+
}
67+
});
68+
}
6069

61-
return headProps.head;
70+
return head;
6271
};
6372

6473
const resolveDocumentHead = (
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { resolveHead } from './head';
3+
import type { ContentModuleHead } from './types';
4+
5+
const endpoint = {} as any;
6+
const routeLocation = {} as any;
7+
const locale = 'en';
8+
const defaults = {
9+
title: 'Default Title',
10+
meta: [{ key: 'desc', name: 'description', content: 'Default description' }],
11+
link: [{ key: 'css', rel: 'stylesheet', href: 'default.css' }],
12+
};
13+
const mergeHeads = (...modules: any[]) =>
14+
resolveHead(endpoint, routeLocation, modules.map((m) => ({ head: m })) as any, locale, defaults);
15+
16+
describe('resolveHead', () => {
17+
it('should merge contentModule properties correctly', () => {
18+
const baseModule: ContentModuleHead = {
19+
title: 'Base Title',
20+
meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
21+
links: [{ key: 'css', rel: 'stylesheet', href: 'base.css' }],
22+
};
23+
24+
const overrideModule: ContentModuleHead = {
25+
title: 'Override Title',
26+
meta: [{ key: 'keywords', content: 'override, test' }],
27+
links: [{ key: 'icon', rel: 'icon', href: 'favicon.ico' }],
28+
};
29+
30+
const result = mergeHeads(baseModule, overrideModule);
31+
32+
expect(result.title).toBe('Override Title');
33+
expect(result.meta).toEqual([
34+
{ key: 'desc', name: 'description', content: 'Base description' },
35+
{ key: 'keywords', content: 'override, test' },
36+
]);
37+
expect(result.links).toEqual([
38+
{ key: 'css', rel: 'stylesheet', href: 'base.css' },
39+
{ key: 'icon', rel: 'icon', href: 'favicon.ico' },
40+
]);
41+
});
42+
43+
it('should handle missing override properties', () => {
44+
const baseModule: ContentModuleHead = {
45+
title: 'Base Title',
46+
meta: [{ key: 'desc', content: 'Base description' }],
47+
};
48+
49+
const overrideModule: ContentModuleHead = {};
50+
51+
const result = mergeHeads(baseModule, overrideModule);
52+
53+
expect(result.title).toBe('Base Title');
54+
expect(result.meta).toEqual([{ key: 'desc', content: 'Base description' }]);
55+
});
56+
57+
it('should handle missing base properties', () => {
58+
const baseModule: ContentModuleHead = {};
59+
60+
const overrideModule: ContentModuleHead = {
61+
title: 'Override Title',
62+
meta: [{ key: 'keywords', content: 'override, test' }],
63+
};
64+
65+
const result = mergeHeads(baseModule, overrideModule);
66+
67+
expect(result.title).toBe('Override Title');
68+
expect(result.meta).toEqual([
69+
{ key: 'desc', name: 'description', content: 'Default description' },
70+
{ key: 'keywords', content: 'override, test' },
71+
]);
72+
});
73+
74+
it('should not mutate input objects', () => {
75+
const baseModule: ContentModuleHead = {
76+
title: 'Base Title',
77+
meta: [{ name: 'description', content: 'Base description' }],
78+
};
79+
80+
const overrideModule: ContentModuleHead = {
81+
title: 'Override Title',
82+
meta: [{ name: 'keywords', content: 'override, test' }],
83+
};
84+
85+
const baseCopy = JSON.parse(JSON.stringify(baseModule));
86+
const overrideCopy = JSON.parse(JSON.stringify(overrideModule));
87+
88+
mergeHeads(baseModule, overrideModule);
89+
90+
expect(baseModule).toEqual(baseCopy);
91+
expect(overrideModule).toEqual(overrideCopy);
92+
});
93+
});
94+
95+
describe('resolveHead with functions', () => {
96+
it('should execute head functions in correct order and merge results', () => {
97+
const baseModule: ContentModuleHead = (props) => ({
98+
title: props.head.title + ' - My Site',
99+
meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
100+
});
101+
102+
const overrideModule: ContentModuleHead = (props) => ({
103+
title: 'Override Title',
104+
meta: [{ key: 'desc', name: 'description', content: 'will be overridden' }],
105+
});
106+
107+
const result = mergeHeads(baseModule, overrideModule);
108+
109+
expect(result.title).toBe('Override Title - My Site');
110+
expect(result.meta).toEqual([
111+
{ key: 'desc', name: 'description', content: 'Base description' },
112+
]);
113+
});
114+
115+
it('should handle mix of object and function heads', () => {
116+
const objectModule: ContentModuleHead = {
117+
title: 'Object Title',
118+
meta: [{ key: 'desc', name: 'description', content: 'Object description' }],
119+
};
120+
121+
const functionModule: ContentModuleHead = (props) => ({
122+
title: props.head.title + ' - My Site',
123+
meta: [{ key: 'keywords', content: 'function, test' }],
124+
});
125+
126+
const result = mergeHeads(objectModule, functionModule);
127+
128+
expect(result.title).toBe('Object Title - My Site');
129+
expect(result.meta).toEqual([
130+
{ key: 'desc', name: 'description', content: 'Object description' },
131+
{ key: 'keywords', content: 'function, test' },
132+
]);
133+
});
134+
});

0 commit comments

Comments
 (0)