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' }, + ]); + }); +});