Partials
Overview
A partial is a part of a page served at its own path: the custom element only, without the page around it. The frontend loads it separately from the page, usually in the browser after the page has loaded.
The response of a partial is the element itself - no title, breadcrumbs, metatags or page layout. It is a response of its own, so it is cached apart from the pages showing it.
Typical uses:
- Random, rotating or short-lived content, like a random pick of articles or a photo of the day, on pages that are cached for long.
- Lazy loading of content below the fold, or of content shown on demand, like a tab's panel.
- An endpoint providing data to a frontend component, like items appended by a "load more" button.
Views: the "Custom Elements partial" display
The Lupus Decoupled Views submodule provides the Views display Custom Elements partial (custom_elements_partial). It works like the Custom Elements Page display, but serves the view as a partial:
- Add a
Custom Elements partialdisplay to a view. - Select
Custom elementsstyle as view format andContentas row style, as for a page display. - Set a path for the partial, e.g.
random-nodes.
The display has no menu settings, since a partial is no page to link to. Contextual filters take trailing path segments as usual, e.g. /random-nodes/article.
The view is served at /ce-api/random-nodes. For a view with the ID random_nodes, the response is the view element only:
{
"element": "drupal-view-random-nodes-partial",
"props": {
"title": "Random nodes",
"viewId": "random_nodes",
"displayId": "custom_elements_partial_1",
"pager": { "current": 0, "totalItems": 2 }
},
"slots": {
"rows": [
{ "element": "node-article-teaser", "props": { "title": "First article", "href": "/first-article" } },
{ "element": "node-article-teaser", "props": { "title": "Second article", "href": "/second-article" } }
]
}
}
The element name is drupal-view-{view_id}-partial, with underscores in the view ID turned into dashes, unless overridden in the display settings. The pager prop depends on the view's pager: with "Display a specified number of items", totalItems is the number of rows returned. As for any custom elements route, ?_content_format=markup returns the element as markup.
Custom routes
Any route can be served as a partial. Mark it with the _lupus_ce_renderer: content route option, provided by the Lupus Custom Elements Renderer:
MODULE.recommendations:
path: '/recommendations/{channel}'
defaults:
_controller: '\Drupal\MODULE\Controller\RecommendationsController::build'
requirements:
_permission: 'access content'
options:
_lupus_ce_renderer: content
The controller returns a custom element as for any route. As any custom elements route, it is fetched with the API prefix, e.g. /ce-api/recommendations/news. The response contains only that element, like the content of a page response. The _select query parameter has no effect on a partial route. To get the content of a regular page route without the page for a single request, use _select=content instead.
Frontend
A Nuxt frontend using nuxtjs-drupal-ce fetches a partial with the useCeApi() composable and renders it with renderCustomElements(). Set server: false, so the partial is fetched in the browser only and not during server-side rendering. The following component loads the partial once it is about to scroll into view:
<script setup>
const { useCeApi, renderCustomElements } = useDrupalCe()
const { data, execute } = useCeApi('/random-nodes', { server: false, immediate: false })
const container = useTemplateRef('container')
let observer
onMounted(() => {
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
observer.disconnect()
execute()
}
}, { rootMargin: '0px 0px 100%' })
observer.observe(container.value)
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<template>
<div ref="container" style="min-height: 24rem">
<component :is="renderCustomElements(data)" />
</div>
</template>
The view element renders like any listing: by the drupal-view--default component, or by a component drupal-view-random-nodes-partial.vue for a custom look. The rootMargin starts loading one viewport height ahead. To load the partial right after the page has loaded, drop immediate: false and the observer. The example uses plain <script setup>: useCeApi() is typed for page responses, so with lang="ts" passing its data to renderCustomElements() fails type checking unless cast.
Notes:
- Reserve the space of the content, e.g. with a minimum height or a placeholder, so the page does not shift when the partial arrives.
- With the server API proxy enabled, the browser fetches the partial from the frontend at
/api/drupal-ce/random-nodes. - Do not fetch partials holding random or short-lived content during server-side rendering. The content would be rendered into the page and cached with the page.
Caching
A partial is a response of its own, with its own cache entry and cache tags. When rendered into a page instead, the content shares the page's response:
- A random view rendered into a page is drawn once and stays in the cached page until the page is invalidated. Every visitor sees the same pick.
- As a partial, the page and the pick are cached apart. The page does not depend on the view's cache tags, and the partial is invalidated without invalidating the pages showing it.
By default, a partial gets these headers, as a page does:
Content-Type: application/json, ortext/plainwith_content_format=markup.Cache-Control: public, max-age=…, with the maximum age set at Configuration > Development > Performance.
Unlike a page, it has no X-Drupal-CE: page header. The response carries the cache tags of the view and its results only, e.g. for purging a reverse proxy. They show up as X-Drupal-Cache-Tags with cache debug headers enabled.
Drupal core does not apply the max-age of the rendered content to the response headers and the Internal Page Cache (see #2352009). For example, a view sorted randomly has a max-age of 0, yet its response is cached like any page. The Internal Page Cache keeps anonymous responses until a cache tag is invalidated, unless the response sets a future Expires header. To give a partial a shorter lifetime than the pages, either:
- Set the lifetime in the response: an event subscriber on the partial's route sets
Cache-Control: public, max-age=Nand a matchingExpiresheader. The Internal Page Cache drops its entry whenExpireshas passed, and a reverse proxy or CDN follows themax-age. Give the subscriber a negative priority, so it runs after Drupal core'sFinishResponseSubscriber, and only change responses already markedpublic. Otherwise it could make ano-cacheresponse cacheable, e.g. one for a logged-in user. Internal Page Cache copies keep theirmax-age, so a CDN fetching one just before it expires keeps it another full lifetime: up to about twice the lifetime. - Or set the lifetime for the partial's path in the reverse proxy or CDN, and keep the path out of the Internal Page Cache. Without the Internal Page Cache module, the partial keeps its
public, max-ageheader. A response policy denying the Internal Page Cache for the path makes Drupal sendCache-Control: no-cache, must-revalidate, private, so the CDN rule must override the origin'sCache-Control. For a custom route, the route optionno_cache: TRUEdenies the Internal Page Cache through a response policy built into Drupal core. With the server API proxy enabled, the CDN sees the partial at/api/drupal-ce/<path>on the frontend host, so the rule matches that path.
The server API proxy of nuxtjs-drupal-ce passes the Cache-Control and Expires headers of Drupal through unchanged.
Views caching of the view itself also holds the same pick, whatever the headers. With Tag based caching, the default, it keeps the pick until the content changes; with Time-based caching, for its lifetime. Set Caching to None, or use Time-based caching with a lifetime matching the partial.
The Cache Control Override module applies the max-age of the content to the Cache-Control header, but site-wide, not per partial. For a randomly sorted view this results in max-age=0, so the response of the partial is not cached at any layer. Any page whose content has a max-age of 0, e.g. one embedding a randomly sorted view, is not cached either.
See Caching for page-level caching in general.
Listings, Views
The Lupus Decoupled Views submodule provides support for Drupal's Views module. The Views module is Drupal's native way and very powerful way of creating listings of content. It provides a UI for configuring complex queries, searches and the rendering of results, including paging.
Searches