diff --git a/.changeset/some-emus-fly.md b/.changeset/some-emus-fly.md
new file mode 100644
index 00000000000..3b09b6ec65c
--- /dev/null
+++ b/.changeset/some-emus-fly.md
@@ -0,0 +1,5 @@
+---
+'@qwik.dev/router': major
+---
+
+Breaking: The order of head export merging has been slightly. Plain objects now override outer ones. Functions still are run inner-first.
diff --git a/packages/docs/src/routes/docs/(qwikrouter)/pages/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/pages/index.mdx
index 2fc5a7deee0..159676e0f2d 100644
--- a/packages/docs/src/routes/docs/(qwikrouter)/pages/index.mdx
+++ b/packages/docs/src/routes/docs/(qwikrouter)/pages/index.mdx
@@ -79,7 +79,7 @@ The example above sets the title, as well as some [Open Graph](https://ogp.me/)
> HTML places the `
` tag as the first element within `` (at the very top of the HTML content). The `` section is not something that your route component renders directly because it would break the HTML streaming.
-Look into `useDocumentHead()` to read and consume the `DocumentHead` object from within your component.
+Look into `useDocumentHead()` to read and consume the `DocumentHead` object from within your component; you can also use `` to render the tags directly and correctly.
### Dynamic Head
@@ -121,11 +121,30 @@ export const head: DocumentHead = ({resolveValue, params}) => {
};
```
+#### A note on ordering
+
+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.
+
+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.
+
+```ts
+export const head: DocumentHead = ({ head }) => {
+ return {
+ title: `MySite - ${head.title}`,
+ };
+};
+```
+
+So first all plain object `head` exports are merged, and then the function `head` exports are called in reverse order.
+
+Furthermore, if two values in arrays (like `meta` or `links`) have the same `key`, the last one wins. This allows you to override specific meta tags.
+Without a `key`, all entries are included.
+
### Server-injected Head
You can also pass `documentHead` to `createRenderer()` as part of the `serverData` option.
-The values passed will be used as the default values for `useDocumentHead()`, before the `head` exports are resolved.
+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.
```tsx title="src/entry.ssr.tsx" {10}
import { createRenderer } from "@qwik.dev/router";
diff --git a/packages/qwik-router/src/runtime/src/head.ts b/packages/qwik-router/src/runtime/src/head.ts
index 6e2136cd43f..fdb00342d15 100644
--- a/packages/qwik-router/src/runtime/src/head.ts
+++ b/packages/qwik-router/src/runtime/src/head.ts
@@ -11,6 +11,7 @@ import type {
Editable,
ResolveSyncValue,
ActionInternal,
+ ContentModuleHead,
} from './types';
import { isPromise } from './utils';
@@ -37,28 +38,36 @@ export const resolveHead = (
}
return data;
}) as any as ResolveSyncValue;
- const headProps: DocumentHeadProps = {
- head,
- withLocale: (fn) => withLocale(locale, fn),
- resolveValue: getData,
- ...routeLocation,
- };
- for (let i = contentModules.length - 1; i >= 0; i--) {
- const contentModuleHead = contentModules[i] && contentModules[i].head;
+ const fns: Extract[] = [];
+ for (const contentModule of contentModules) {
+ const contentModuleHead = contentModule?.head;
if (contentModuleHead) {
if (typeof contentModuleHead === 'function') {
- resolveDocumentHead(
- head,
- withLocale(locale, () => contentModuleHead(headProps))
- );
+ // Functions are executed inner before outer
+ fns.unshift(contentModuleHead);
} else if (typeof contentModuleHead === 'object') {
+ // Objects are merged inner over outer
resolveDocumentHead(head, contentModuleHead);
}
}
}
+ if (fns.length) {
+ const headProps: DocumentHeadProps = {
+ head,
+ withLocale: (fn) => withLocale(locale, fn),
+ resolveValue: getData,
+ ...routeLocation,
+ };
+
+ withLocale(locale, () => {
+ for (const fn of fns) {
+ resolveDocumentHead(head, fn(headProps));
+ }
+ });
+ }
- return headProps.head;
+ return head;
};
const resolveDocumentHead = (
diff --git a/packages/qwik-router/src/runtime/src/head.unit.ts b/packages/qwik-router/src/runtime/src/head.unit.ts
new file mode 100644
index 00000000000..03c80f89335
--- /dev/null
+++ b/packages/qwik-router/src/runtime/src/head.unit.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from 'vitest';
+import { resolveHead } from './head';
+import type { ContentModuleHead } from './types';
+
+const endpoint = {} as any;
+const routeLocation = {} as any;
+const locale = 'en';
+const defaults = {
+ title: 'Default Title',
+ meta: [{ key: 'desc', name: 'description', content: 'Default description' }],
+ link: [{ key: 'css', rel: 'stylesheet', href: 'default.css' }],
+};
+const mergeHeads = (...modules: any[]) =>
+ resolveHead(endpoint, routeLocation, modules.map((m) => ({ head: m })) as any, locale, defaults);
+
+describe('resolveHead', () => {
+ it('should merge contentModule properties correctly', () => {
+ const baseModule: ContentModuleHead = {
+ title: 'Base Title',
+ meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
+ links: [{ key: 'css', rel: 'stylesheet', href: 'base.css' }],
+ };
+
+ const overrideModule: ContentModuleHead = {
+ title: 'Override Title',
+ meta: [{ key: 'keywords', content: 'override, test' }],
+ links: [{ key: 'icon', rel: 'icon', href: 'favicon.ico' }],
+ };
+
+ const result = mergeHeads(baseModule, overrideModule);
+
+ expect(result.title).toBe('Override Title');
+ expect(result.meta).toEqual([
+ { key: 'desc', name: 'description', content: 'Base description' },
+ { key: 'keywords', content: 'override, test' },
+ ]);
+ expect(result.links).toEqual([
+ { key: 'css', rel: 'stylesheet', href: 'base.css' },
+ { key: 'icon', rel: 'icon', href: 'favicon.ico' },
+ ]);
+ });
+
+ it('should handle missing override properties', () => {
+ const baseModule: ContentModuleHead = {
+ title: 'Base Title',
+ meta: [{ key: 'desc', content: 'Base description' }],
+ };
+
+ const overrideModule: ContentModuleHead = {};
+
+ const result = mergeHeads(baseModule, overrideModule);
+
+ expect(result.title).toBe('Base Title');
+ expect(result.meta).toEqual([{ key: 'desc', content: 'Base description' }]);
+ });
+
+ it('should handle missing base properties', () => {
+ const baseModule: ContentModuleHead = {};
+
+ const overrideModule: ContentModuleHead = {
+ title: 'Override Title',
+ meta: [{ key: 'keywords', content: 'override, test' }],
+ };
+
+ const result = mergeHeads(baseModule, overrideModule);
+
+ expect(result.title).toBe('Override Title');
+ expect(result.meta).toEqual([
+ { key: 'desc', name: 'description', content: 'Default description' },
+ { key: 'keywords', content: 'override, test' },
+ ]);
+ });
+
+ it('should not mutate input objects', () => {
+ const baseModule: ContentModuleHead = {
+ title: 'Base Title',
+ meta: [{ name: 'description', content: 'Base description' }],
+ };
+
+ const overrideModule: ContentModuleHead = {
+ title: 'Override Title',
+ meta: [{ name: 'keywords', content: 'override, test' }],
+ };
+
+ const baseCopy = JSON.parse(JSON.stringify(baseModule));
+ const overrideCopy = JSON.parse(JSON.stringify(overrideModule));
+
+ mergeHeads(baseModule, overrideModule);
+
+ expect(baseModule).toEqual(baseCopy);
+ expect(overrideModule).toEqual(overrideCopy);
+ });
+});
+
+describe('resolveHead with functions', () => {
+ it('should execute head functions in correct order and merge results', () => {
+ const baseModule: ContentModuleHead = (props) => ({
+ title: props.head.title + ' - My Site',
+ meta: [{ key: 'desc', name: 'description', content: 'Base description' }],
+ });
+
+ const overrideModule: ContentModuleHead = (props) => ({
+ title: 'Override Title',
+ meta: [{ key: 'desc', name: 'description', content: 'will be overridden' }],
+ });
+
+ const result = mergeHeads(baseModule, overrideModule);
+
+ expect(result.title).toBe('Override Title - My Site');
+ expect(result.meta).toEqual([
+ { key: 'desc', name: 'description', content: 'Base description' },
+ ]);
+ });
+
+ it('should handle mix of object and function heads', () => {
+ const objectModule: ContentModuleHead = {
+ title: 'Object Title',
+ meta: [{ key: 'desc', name: 'description', content: 'Object description' }],
+ };
+
+ const functionModule: ContentModuleHead = (props) => ({
+ title: props.head.title + ' - My Site',
+ meta: [{ key: 'keywords', content: 'function, test' }],
+ });
+
+ const result = mergeHeads(objectModule, functionModule);
+
+ expect(result.title).toBe('Object Title - My Site');
+ expect(result.meta).toEqual([
+ { key: 'desc', name: 'description', content: 'Object description' },
+ { key: 'keywords', content: 'function, test' },
+ ]);
+ });
+});