Skip to content

Templates: Let a template be rendered inside another template - #85402

Draft
scruffian wants to merge 6 commits into
trunkfrom
scruffian/yangon
Draft

scruffian wants to merge 6 commits into
trunkfrom
scruffian/yangon

Conversation

@scruffian

@scruffian scruffian commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What?

Lets a template be rendered inside another template, the way a page is rendered inside the template assigned to it.

A template names the template it is rendered inside, and the parent's Content block (core/post-content) is the slot: the child template's blocks are rendered in it. A 404 template can then carry only the "nothing here" message, while the header, the footer and whatever else surrounds it live in a single template.

A theme names it for its own templates in the new templates section of its theme.json, so a theme works that way on activation with nothing to set up. A site overrides what the theme names, including rendering a template on its own, from the Site Editor.

Behind the gutenberg-template-inheritance experiment, while the product questions this opens are settled: named slots, what a theme may declare, and how a chain meets the template hierarchy.

Why?

Every template repeats its own header and footer today. Changing the shell of a site means editing every template that has one, and a template that only differs in its main content still has to restate everything around it.

How?

A theme declares it:

"templates": [
	{ "name": "404", "template": "layout" },
	{ "name": "search", "template": "layout" }
]

A site's own choice, stored in the _wp_page_template post meta of the template's post — the same meta a page uses for the same purpose — stands for the one the theme names. The meta is default when the site renders the template on its own and absent when the site has made no choice, so reverting a template to the one of the theme drops the choice with it. The REST field is named template, like the one a post carries.

  • lib/experimental/block-template-parent.php holds the server side, loaded only with the experiment:
    • The slot. The first core/post-content block in document order, at whatever depth, takes the child's blocks as its own inner blocks rather than being replaced by them, so it still renders its wrapper: its layout, its alignment and entry-content apply to the template rendered inside it, and the canvas and the front end agree. It is marked with a templateSlot attribute in the markup, and a register_block_type_args filter makes the block render those blocks instead of the post of the request.
    • The resolution, on template_include once locate_block_template() has settled on a template. It walks the chain, stopping at a template that names none, at one already in the chain, and at one with no slot. The templates locate_block_template() already read are reused, so a site that uses none of this pays no extra query; they are forgotten when a template's post cache is cleaned.
    • Two REST fields on wp_template: template, the slug it is rendered inside, validated against the templates it may name, and has_content_slot, whether it has a slot of its own.
  • templates is a new top-level theme.json section: added to the schema, to WP_Theme_JSON_Gutenberg::VALID_TOP_LEVEL_KEYS, and read through a new get_templates() beside get_template_parts().
  • packages/core-fields/src/wp_template/template/ is the Template field of a template. The choices leave out the template itself, every template already rendered inside it, and every template with no slot. The field belongs to @wordpress/core-fields, which is core code with no experiment to read, so it is unregistered while the experiment is off and the template summary form leaves it out.
  • The editor renders the parent around the template under "Show template", the same control a page has. getTemplateId() answers the template a template is rendered inside, and the provider gives the parent's Content block the template being edited as its context, so the blocks in the slot edit the child rather than the parent.

A Content block inside a query loop is not the slot. It belongs to the post that loop is rendering, so putting the child there would repeat it once per post. The editor leaves those out of the post content blocks it makes editable for the same reason. This is why index.html as themes ship it cannot be a parent: its only Content block is inside the Query Loop.

Testing Instructions

  1. Enable the inherted templates experiment
  2. Use the version of TT5 in Twenty Twenty-Five: Render every template inside a shared index template
  3. Open the 404 template and see that Template is set to index.
  4. Open the preview menu (the screen icon) and tick Show template: the canvas shows Layout around the 404 content, and only the 404 content is editable. Type in it and confirm the 404 template, not Layout, becomes dirty.
  5. Save, then visit a URL that does not exist. The 404 content renders inside Layout, in the Content block's own wrapper.
  6. Open index and confirm its Template options leave out Page: 404 and index itself, so no loop can be made, and leave out Blog Home, All Archives and Search Results, whose only Content block is inside a Query Loop.
  7. Set the 404 template's Template back to None, save, and confirm the front end renders it on its own again.
  8. Turn the experiment off and confirm the Template row is gone from the template summary and the front end renders every template on its own.

To check what a theme declares, add a templates section to the theme.json of a block theme, pointing one of its templates at another, and confirm the front end and the summary both follow it with nothing set in the editor.

Screenshots or screencast

Use of AI Tools

Authored with Claude Code (Opus 5), directed and reviewed by me. The tests were written before the implementation and verified failing first. The front end and the editor flows were checked by hand in WordPress Playground; the end to end spec has not been run, as Docker would not start in that environment, so its selectors were pinned against the running editor rather than by executing it.

A template can now name the template it is rendered inside, the way a page
names the template it is rendered inside. The parent's Content block is the
slot: the child template's blocks are substituted for it, so a header and a
footer live in one template instead of being repeated in every one.

A Content block inside a query loop belongs to the post that loop renders, so
it is not the slot, and a template whose only one is there cannot render
another inside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added [Package] Core data /packages/core-data [Package] Editor /packages/editor [Package] Edit Site /packages/edit-site [Package] Fields /packages/fields labels Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

Preview in WordPress Playground

🎉 Props

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @Copilot.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: Copilot.

Co-authored-by: scruffian <scruffian@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

🏷️ Labels

This pull request needs exactly one label indicating its type, and has 0.

  • Required: any label starting with [Type].
  • Found: none.

Read more about Type labels in Gutenberg. If you cannot add labels yourself, a reviewer can do it for you.

📦 Bundle size

Size Change: +61.4 kB (+0.68%)

Total Size: 9.13 MB

📦 View Changed
Filename Size Change
build/modules/core-fields/wp_template.min.js 68.8 kB +61.2 kB (+804.22%) 🆘
build/modules/lazy-editor/index.min.js 15.3 kB +15 B (+0.1%)
build/scripts/core-data/index.min.js 79.5 kB +47 B (+0.06%)
build/scripts/edit-site/index.min.js 424 kB +20 B (0%)
build/scripts/editor/index.min.js 620 kB +107 B (+0.02%)

b583d00 Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric ef72cf0 trunk % Change
timeToFirstByte 53.5 ms +9.35% -1.78% 54.95 ms +2.73% -5.1% -2.64%
largestContentfulPaint 96 ms +0% -8.33% 92 ms +4.35% -4.35% 4.35%
lcpMinusTtfb 37.3 ms +16.09% -5.63% 36.35 ms +7.02% -6.88% 2.61%
wpBeforeTemplate 28.41 ms +12.6% -0.92% 28.4 ms +8.2% -2.18% 0.04%
wpTemplate 21.31 ms +4.6% -3.75% 20.51 ms +13.41% -2.58% 3.9%
wpTotal 49.64 ms +9.35% -1.81% 50.93 ms +2.81% -5.46% -2.53%
wpMemoryUsage 7.68 MB +0% -0% 7.64 MB +0% -0% 0.53%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric ef72cf0 trunk % Change
timeToFirstByte 45.5 ms +14.73% -1.98% 41.3 ms +8.11% -1.94% 10.17%
largestContentfulPaint 100 ms +10% -0% 92 ms +0% -4.35% 8.7%
lcpMinusTtfb 55.65 ms +2.34% -1.26% 47.5 ms +5.79% -0.42% 17.16%
wpBeforeTemplate 25.17 ms +17.16% -1.39% 24.94 ms +8.22% -1.32% 0.92%
wpTemplate 17.2 ms +8.02% -4.3% 12.89 ms +3.03% -2.33% 33.44%
wpTotal 42.24 ms +13.38% -2.04% 38.24 ms +8.03% -2.33% 10.46%
wpMemoryUsage 6.37 MB +0% -0% 6.22 MB +0% -0% 2.46%
wpDbQueries 13 +0% -0% 9 +0% -0% 44.44%

media-processing

Metric ef72cf0 trunk % Change
mediaProcessingJpeg 418.48 ms +1.25% -1.85% 419.22 ms +1.48% -1.7% -0.18%
mediaProcessingAvif 6204.41 ms +0.32% -0.02% 6213.5 ms +0.84% -0.12% -0.15%
mediaProcessingJpegToAvif 4323.12 ms +0.33% -0.27% 4344.13 ms +0.42% -0.35% -0.48%

media-upload

Metric ef72cf0 trunk % Change
jpegUploadProcessing 1699.96 ms +15.88% -15.72% 1443.22 ms +1.11% -1.51% 17.79%
pngUploadProcessing 192.23 ms +5.71% -9.87% 219.09 ms +3.31% -18.02% -12.26%
largeJpegUploadProcessing 1443.77 ms +0.27% -0.79% 1427.32 ms +1.26% -0.41% 1.15%
multipleImageUploadProcessing 1619.63 ms +27.48% -1.16% 1606.04 ms +28.41% -1.55% 0.85%

post-editor

Metric ef72cf0 trunk % Change
serverResponse 409.9 ms +6.33% -8.33% 419.83 ms +2.62% -8.91% -2.37%
firstPaint 484 ms +7.4% -30.5% 368.68 ms +40.74% -17.72% 31.28%
domContentLoaded 1193.35 ms +1.6% -2.45% 1160.25 ms +2.65% -1.07% 2.85%
loaded 1194.53 ms +1.58% -2.46% 1161.16 ms +2.65% -1.05% 2.87%
firstContentfulPaint 511.35 ms +1.75% -5.68% 420.62 ms +23.52% -14.87% 21.57%
firstBlock 3345.44 ms +3.39% -2.3% 3367.71 ms +2.02% -0.98% -0.66%
type 19.31 ms +4.87% -3.16% 18.89 ms +2.28% -4.61% 2.22%
typeWithoutInspector 19.24 ms +7.17% -4.47% 19.14 ms +1.57% -3.81% 0.52%
typeWithTopToolbar 24.8 ms +3.35% -8.51% 24.3 ms +1.69% -4.16% 2.06%
typeContainer 8.56 ms +7.13% -7.94% 8.84 ms +5.77% -9.05% -3.17%
focus 120.58 ms +4.06% -4.86% 117.61 ms +7.31% -5.09% 2.53%
firstFocus 236.73 ms +0% -0% 256.72 ms +0% -0% -7.79%
selectAll 538.3 ms +4.38% -0.8% 544.94 ms +2.61% -0.58% -1.22%
listViewOpen 77.8 ms +10.31% -3.07% 76.67 ms +10.67% -2.06% 1.47%
inserterOpen 42.41 ms +2.76% -9.03% 43.09 ms +1.86% -3.85% -1.58%
inserterHover 12.39 ms +6.54% -7.18% 12.87 ms +6.22% -10.8% -3.73%
inserterSearch 8.94 ms +4.92% -8.5% 8.82 ms +5.1% -12.59% 1.36%
loadPatterns 638.45 ms +2.57% -0.69% 655.98 ms +3.02% -4.34% -2.67%
wpTotal 398.94 ms +6.29% -8.51% 409.16 ms +2.21% -9.2% -2.5%
wpMemoryUsage 14.71 MB +0% -0% 14.67 MB +0% -0% 0.29%
wpDbQueries 55 +0% -1.82% 55 +0% -1.82% 0%

site-editor

Metric ef72cf0 trunk % Change
serverResponse 502.76 ms +0.9% -5.48% 496.43 ms +2.95% -5.29% 1.28%
firstPaint 269.22 ms +10.37% -9.72% 278.89 ms +8.69% -10.44% -3.47%
domContentLoaded 1264.25 ms +1.62% -1.82% 1256.89 ms +0.3% -1.1% 0.59%
loaded 1265.46 ms +1.61% -1.83% 1257.83 ms +0.3% -1.08% 0.61%
firstContentfulPaint 501.06 ms +5.35% -2.94% 505.76 ms +3.9% -2.61% -0.93%
firstBlock 4508.19 ms +0.59% -0.79% 4602.5 ms +0.8% -0.53% -2.05%
type 18.9 ms +1.85% -4.44% 18.9 ms +3.54% -1.96% 0%
navigate 115.9 ms +6.33% -2.6% 114.52 ms +13.53% -2.8% 1.21%
loadPatterns 1295.37 ms +7.36% -10.17% 1220.89 ms +2.13% -7.16% 6.1%
loadPages 960.48 ms +6.6% -2.09% 941.18 ms +5.99% -1.6% 2.05%
wpTotal 493.58 ms +0.83% -5.61% 487.36 ms +2.93% -5.44% 1.28%
wpMemoryUsage 12.73 MB +0% -0% 12.69 MB +0% -0% 0.37%
wpDbQueries 44 +2.27% -0% 44 +2.27% -0% 0%

b583d00 Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

Regression: updating a specific option will only change its value and will not corrupt others in /test/e2e/specs/editor/various/core-settings.spec.js, passed after 1 failed attempt.
Error: expect(received).toStrictEqual(expected) // deep equality

- Expected  - 1
+ Received  + 1

  Array [
    "rewrite_rules",
-   "SERIALIZED DATA",
+   "",
  ]
    at forEach (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/core-settings.spec.js:46:26)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/core-settings.spec.js:43:35
refuses the drop and uploads nothing in /test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js, passed after 1 failed attempt.
Error: expect(received).toHaveLength(expected)

Expected length: 0
Received length: 1
Received array:  [{"_links": {"about": [{"href": "http://localhost:8889/wp-json/wp/v2/types/attachment"}], "author": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/users/1"}], "collection": [{"href": "http://localhost:8889/wp-json/wp/v2/media"}], "curies": [{"href": "https://api.w.org/{rel}", "name": "wp", "templated": true}], "replies": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/comments?post=223"}], "self": [{"href": "http://localhost:8889/wp-json/wp/v2/media/223", "targetHints": {"allow": ["GET", "POST", "PUT", "PATCH", "DELETE"]}}], "wp:attached-to": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/posts/222", "id": 222, "post_type": "post"}]}, "alt_text": "", "author": 1, "caption": {"rendered": ""}, "class_list": ["post-223", "attachment", "type-attachment", "status-inherit", "hentry", "entry"], "comment_status": "open", "date": "2026-10-22T16:48:32", "date_gmt": "2026-10-22T16:48:32", "description": {"rendered": "<p class=\"attachment\"><a href='http://localhost:8889/wp-content/uploads/2026/10/f47521e8-87a3-4348-92c3-20653abbb298.png'><img loading=\"lazy\" decoding=\"async\" width=\"10\" height=\"10\" src=\"http://localhost:8889/wp-content/uploads/2026/10/f47521e8-87a3-4348-92c3-20653abbb298.png\" class=\"attachment-medium size-medium\" alt=\"\" style=\"width:100%;height:100%;max-width:10px;\" /></a></p>
"}, "featured_media": 0, "filename": "f47521e8-87a3-4348-92c3-20653abbb298.png", "filesize": 80, "guid": {"rendered": "http://localhost:8889/wp-content/uploads/2026/10/f47521e8-87a3-4348-92c3-20653abbb298.png"}, "id": 223, "link": "http://localhost:8889/?attachment_id=223", "media_details": {"file": "2026/10/f47521e8-87a3-4348-92c3-20653abbb298.png", "filesize": 80, "height": 10, "image_meta": {"alt": "", "aperture": "0", "camera": "", "caption": "", "copyright": "", "created_timestamp": "0", "credit": "", "focal_length": "0", "iso": "0", "keywords": [], "orientation": "0", "shutter_speed": "0", "title": ""}, "sizes": {}, "width": 10}, "media_type": "image", "meta": [], "mime_type": "image/png", "modified": "2026-10-22T16:48:32", "modified_gmt": "2026-10-22T16:48:32", "ping_status": "closed", "post": 222, "slug": "f47521e8-87a3-4348-92c3-20653abbb298", "source_url": "http://localhost:8889/wp-content/uploads/2026/10/f47521e8-87a3-4348-92c3-20653abbb298.png", "status": "inherit", "template": "", "title": {"rendered": "f47521e8-87a3-4348-92c3-20653abbb298"}, "type": "attachment"}]
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js:71:45
should cut and paste individual blocks with collapsed selection in /test/e2e/specs/editor/various/copy-cut-paste.spec.js, passed after 1 failed attempt.
Error: expect(string).toMatchSnapshot(expected) failed

  @@ -1,3 +1,3 @@
   <!-- wp:paragraph -->
  -<p>2Cut - collapsed selection</p>
  +<p>2</p>
   <!-- /wp:paragraph -->


    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/copy-cut-paste.spec.js:45:49

From 4b30781, not the current head. Run

@scruffian

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

# Conflicts:
#	phpunit/entity-fields-api-test.php

Co-authored-by: scruffian <275961+scruffian@users.noreply.github.com>

Copilot AI commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Merged trunk and resolved the conflict in phpunit/entity-fields-api-test.php in commit 3ac2c6aa.

scruffian and others added 2 commits October 23, 2026 12:09
…nside

A theme names the template each of its templates is rendered inside in the
`templates` section of its theme.json, so a theme works that way on activation
with nothing to set up. A site overrides what the theme names, including
rendering a template on its own, from the editor, and reverting a template to
the one of the theme drops that choice with it.

Adds the `template-inheritance` test theme, whose templates are all rendered
inside its layout template, and the end to end tests that drive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge of trunk converted the private selectors test to TypeScript, so the
registry the tests of `getTemplateId` set up needs typing too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the feature to `lib/experimental/block-template-parent.php`, loaded only
with the new `gutenberg-template-inheritance` experiment, while the product
questions it opens are settled: named slots, what a theme may declare, and how
a chain meets the template hierarchy. The `template` field of `wp_template` is
registered with `@wordpress/core-fields`, which is core code with no experiment
to read, so it is unregistered while the experiment is off, and the form of the
template summary leaves it out.

The slot now holds the blocks of the template rendered inside it rather than
being replaced by them, so the content block still renders its own wrapper: its
layout, its alignment and `entry-content` apply to the template inside it, and
the canvas and the front end agree.

Drops the `template-inheritance` test theme. The tests of what a theme declares
add the declaration to the data of the block theme of the test data through the
`wp_theme_json_data_theme` filter, and the end to end tests build their
templates over REST, so no theme exists only to carry them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@scruffian
scruffian marked this pull request as draft October 23, 2026 16:24

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Core data /packages/core-data [Package] Edit Site /packages/edit-site [Package] Editor /packages/editor [Package] Fields /packages/fields

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants