Skip to content

Block Supports: Add an experimental rotate block support - #84505

Draft
draganescu wants to merge 23 commits into
trunkfrom
add/block-rotate-support
Draft

draganescu wants to merge 23 commits into
trunkfrom
add/block-rotate-support

Conversation

@draganescu

@draganescu draganescu commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

What?

Adds a rotate block support, behind a new "Block rotation" experiment. Blocks get an optional Rotation control in their Dimensions panel unless they opt out with supports.rotate: false. The rotation follows the selected viewport (Desktop, Tablet, Mobile). Split out of #84340, where rotation started as a grid-only value; the canvas rotate handle for blocks in a manual placement grid is #84512, at the top of the grid stack, which builds on this.

Why?

Blocks can't be rotated today. This is the general support that the manual grid's canvas rotate handle builds on: the handle reads and writes the same data through the helpers added here.

How?

  • The rotation is a number of degrees in (-180, 180] stored in style.rotate. Viewport overrides live in style['@tablet'].rotate and style['@mobile'].rotate, where 0 is stored and undoes the default rotation on that viewport. In the default state, 0 is not stored.
  • The control is an AnglePickerControl in an optional ToolsPanelItem in the Dimensions panel. It edits the selected viewport, and its reset and the panel's "Reset all" only clear that viewport's rotation. It's hidden for pseudo states such as :hover.
  • Blocks are rotated with the CSS rotate property, not transform, which the editor writes to animate block moves. Rules are added under a generated class, without !important, with the default rotation first and viewport overrides in the responsive media queries. This happens in the editor through a style override and on the front end through the block supports stylesheet (lib/block-supports/rotate.php). Saved markup doesn't change.
  • Stored values are treated as untrusted, the same way in JS and PHP: only numbers and decimal numeric strings are read (no 0x10), rounded to two decimals with halves away from zero, and wrapped into (-180, 180]. rotate is added to the safe CSS properties, because the style engine filters every declaration through safecss_filter_attr().
  • A block whose own style attribute is not an object is left alone.
  • The blocks that render no wrapper of their own opt out with supports.rotate: false, mirroring the blocks that opt out of customCSS and visibility: Classic, Custom HTML, Missing, More, Page Break, Pattern, Shortcode and synced patterns (core/block). Classic would lose the rotation on save, Custom HTML and synced patterns would rotate an editor wrapper that doesn't exist on the front end, and More and Page Break have no tag.
  • The support has its own hook and PHP file rather than going through the style engine and the block states pipeline (states.php), following block visibility and custom CSS. The style engine route would need changes to the public style engine package; it would serialise an inline style into the saved markup of blocks that have other style supports; its editor output only covers blocks with style supports; and states.php emits viewport rules with !important, which would stop a canvas handle from previewing an angle with an inline style.
  • Rotation is only applied while the experiment is on, in the editor and on the front end. Turning it off keeps stored rotations, since the style attribute stays registered for rotation in JS and PHP so saving doesn't drop them, but blocks are shown unrotated. In the editor the support's hasSupport is isRotateEnabled(), which gates the control and the rotation together. On the front end, lib/load.php removes the render_block filter while the experiment is off, so rotate.php stays as it would be backported. Without this, third-party blocks that store their own style.rotate would be rotated by Gutenberg with the experiment off.
  • getRotateValue(), getRotateForState() and getUpdatedRotateStyle() read and write a style state's rotation for other editor tools. They live in utils/rotation.ts with the angle helpers, not in the hook module, so tools that only need the data don't import the hook and its controls. To keep that module free of the hook utilities (which import the components and the store), cleanEmptyObject moved from hooks/utils.tsx to utils/object.ts; hooks/utils.tsx re-exports it, so its importers and the private API are unchanged.

Known limitations:

  • While a rotated block animates to a new position after a move, the move animation's transform translates along the rotated axes, so the block briefly travels at an angle. It settles in the right place.
  • Clearing the angle field in a viewport stores 0, which unrotates the block on that viewport, rather than removing the override. Use the item's reset or "Reset all" to remove it.

Still to do before this can leave draft:

  • A Core backport PR for lib/block-supports/rotate.php (as wp_render_rotate_support() and wp_register_rotate_support()) and the rotate entry in safe_style_css: Editor: Add the rotate block support wordpress-develop#14707 (draft), with backport-changelog/7.2/14707.md. The supports.rotate: false opt-outs in Core's block.json files arrive with the package update.
  • Once Core ships its version, have lib/block-supports/rotate.php remove Core's render_block filter before adding its own, as block-visibility.php and custom-css.php do.
  • Decide whether to document supports.rotate in schemas/json/block.json and the block supports reference while it is experimental.

Testing Instructions

  1. In Gutenberg > Experiments, turn on "Block rotation".
  2. Add a Paragraph and an Image. In the block settings, open the Dimensions panel's options menu and turn on Rotation.
  3. Set an angle and check the block rotates on the canvas. Save and check it rotates on the front end.
  4. Set the angle back to 0 and check that style.rotate is removed from the block's attributes (Code editor).
  5. Switch the preview to Mobile and set a different angle, then 0. Check the block is unrotated on mobile only, on the canvas and on the front end at a narrow width.
  6. In Mobile, use "Reset all" in the Dimensions panel and check that the desktop rotation is kept.
  7. Turn the experiment off and check the control is gone and rotated blocks are shown unrotated, on the canvas and on the front end. Edit and save the post, turn the experiment back on, and check the blocks are rotated again.
  8. Add a Custom HTML, Classic, Shortcode and More block, and a synced pattern, and check they have no Rotation control.

Testing Instructions for Keyboard

Tab to the Dimensions panel's options menu, turn on Rotation, then type an angle into the Rotation number field. Check that Tab moves through the panel and that "Reset all" works from the menu.

Screenshots or screencast

To add.

Use of AI Tools

Written with Claude Code (Claude Opus 5.5), split out of #84340. The design decisions (a general support stored in style.rotate, a separate experiment) were made in conversation with the PR author.

🤖 Generated with Claude Code

draganescu and others added 2 commits October 10, 2026 11:35
Every block that doesn't opt out with `supports.rotate: false` can now be
rotated, behind the new "Block rotation" experiment
(`gutenberg-block-rotation`, which sets
`window.__experimentalEnableBlockRotation`). The Dimensions panel gets an
optional Rotation item with an angle picker.

The rotation is a number of degrees in (-180, 180] stored in
`style.rotate`. It follows the selected viewport style state: overrides
live in `style['@tablet'].rotate` and `style['@mobile'].rotate`, where 0
is stored and undoes the default rotation on that viewport. In the
default state, 0 is not stored. "Reset all" only clears the selected
state's rotation.

Blocks are rotated with the CSS `rotate` property rather than
`transform`, which the editor writes to animate block moves. In the
editor, the rules are added under a generated class, with viewport
overrides in the responsive media queries. On the front end,
`lib/block-supports/rotate.php` adds the same rules to the block supports
stylesheet under a class generated from them, treating the stored value as
untrusted. Saved markup doesn't change, and stored rotation is shown
whether or not the experiment is on.

`getRotateForState()` and `getUpdatedRotateStyle()` read and write the
rotation of a style state, for tools such as a canvas rotate handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Classic, Custom HTML, Missing, More, Page Break, Pattern, Shortcode and
synced patterns opt out with `supports.rotate: false`, like they opt out
of custom CSS and visibility: they have no wrapper of their own to
rotate, or lose it on save or on the front end.

A block whose own `style` attribute is not an object doesn't support
rotation, and style values that are not objects are read as no
rotation.

JS reads stored values like PHP: only decimal numeric strings, and
halves rounded away from zero after removing floating point noise, so
-12.345 is -12.35 on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added [Package] Block library /packages/block-library [Package] Block editor /packages/block-editor labels Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

🏷️ 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: +798 B (+0.01%)

Total Size: 9.07 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 607 kB +767 B (+0.13%)
build/scripts/block-library/index.min.js 449 kB +31 B (+0.01%)

7f94fc3 Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric a2cdd69 trunk % Change
timeToFirstByte 58.9 ms +8.15% -6.28% 53.6 ms +8.3% -1.68% 9.89%
largestContentfulPaint 96 ms +6.25% -4.17% 88 ms +6.82% -4.55% 9.09%
lcpMinusTtfb 35.95 ms +20.58% -6.26% 32.85 ms +7.31% -5.33% 9.44%
wpBeforeTemplate 29.82 ms +20.25% -2.62% 28.57 ms +18.87% -1.82% 4.38%
wpTemplate 22.64 ms +5.74% -3.84% 21.23 ms +2.17% -3.86% 6.64%
wpTotal 54.9 ms +8.65% -6.19% 49.88 ms +8.86% -2.04% 10.06%
wpMemoryUsage 7.68 MB +0% -0% 7.64 MB +0% -0% 0.46%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric a2cdd69 trunk % Change
timeToFirstByte 47.8 ms +3.56% -0.63% 45.45 ms +3.52% -1.65% 5.17%
largestContentfulPaint 104 ms +3.85% -0% 100 ms +2% -4% 4%
lcpMinusTtfb 56.5 ms +3.01% -0.71% 54 ms +1.2% -3.98% 4.63%
wpBeforeTemplate 27.08 ms +4.17% -0.85% 26.04 ms +1.8% -1.23% 3.99%
wpTemplate 17.34 ms +3.98% -2.94% 16.02 ms +2.87% -2.06% 8.24%
wpTotal 44.61 ms +4.17% -0.99% 42.33 ms +4.02% -1.51% 5.39%
wpMemoryUsage 6.37 MB +0% -0% 6.33 MB +0% -0% 0.56%
wpDbQueries 13 +0% -0% 13 +0% -0% 0%

media-processing

Metric a2cdd69 trunk % Change
mediaProcessingJpeg 408.91 ms +2.8% -0.74% 397.22 ms +0.17% -0.18% 2.94%
mediaProcessingAvif 6147.55 ms +0.3% -0.38% 6060.97 ms +0.27% -0.24% 1.43%
mediaProcessingJpegToAvif 4223.81 ms +0.27% -0.43% 4179.83 ms +0.08% -0.14% 1.05%

media-upload

Metric a2cdd69 trunk % Change
jpegUploadProcessing 1469.22 ms +33.46% -1.47% 1434.46 ms +1.51% -0.9% 2.42%
pngUploadProcessing 208.67 ms +11.67% -13.41% 204.34 ms +11.25% -12.44% 2.12%
largeJpegUploadProcessing 1446.25 ms +32.89% -1% 1450.16 ms +1.32% -1.03% -0.27%
multipleImageUploadProcessing 2078.74 ms +3.3% -0.48% 2063.01 ms +0.7% -1.19% 0.76%

post-editor

Metric a2cdd69 trunk % Change
serverResponse 537.17 ms +2.89% -3.41% 532.5 ms +4.86% -5.62% 0.88%
firstPaint 251.06 ms +102.01% -5.8% 531.72 ms +3% -5.88% -52.78%
domContentLoaded 1257.79 ms +1.72% -0.44% 1261.84 ms +2.72% -1.46% -0.32%
loaded 1259.08 ms +1.69% -0.47% 1263.18 ms +2.71% -1.46% -0.32%
firstContentfulPaint 538.58 ms +1.39% -5.83% 541.41 ms +3.1% -7% -0.52%
firstBlock 3634.72 ms +1.73% -1.26% 3580.69 ms +2.38% -0.82% 1.51%
type 19 ms +5.37% -1.05% 21.33 ms +2.91% -8.49% -10.92%
typeWithoutInspector 19.25 ms +7.32% -3.06% 19.34 ms +1.55% -3.21% -0.47%
typeWithTopToolbar 25.13 ms +3.02% -2.67% 25.87 ms +1.58% -1.93% -2.86%
typeContainer 8.45 ms +8.99% -8.88% 8.99 ms +11.01% -3.11% -6.01%
focus 125.17 ms +5.38% -3.7% 124.13 ms +2.92% -2.91% 0.84%
firstFocus 286.08 ms +0% -0% 268.22 ms +0% -0% 6.66%
selectAll 567.85 ms +2.29% -2.66% 571.44 ms +0.27% -3.03% -0.63%
listViewOpen 82.89 ms +15.45% -2.76% 87.15 ms +5.63% -5.83% -4.89%
inserterOpen 45.04 ms +12.01% -8.53% 45.9 ms +9.78% -8.04% -1.87%
inserterHover 12.41 ms +7.09% -6.77% 13.19 ms +8.64% -14.86% -5.91%
inserterSearch 9.1 ms +5.27% -3.41% 8.7 ms +4.48% -4.02% 4.6%
loadPatterns 707.44 ms +4.29% -5.82% 700.49 ms +3.47% -2.98% 0.99%
wpTotal 527.65 ms +2.9% -3.4% 522.64 ms +5.04% -5.69% 0.96%
wpMemoryUsage 14.71 MB +0% -0% 14.68 MB +0% -0% 0.25%
wpDbQueries 54.5 +0.92% -0.92% 55 +0% -1.82% -0.91%

site-editor

Metric a2cdd69 trunk % Change
serverResponse 510.52 ms +4.16% -7.55% 519.32 ms +4.2% -2.63% -1.69%
firstPaint 302.22 ms +6.7% -17.84% 286.92 ms +7.16% -13.38% 5.33%
domContentLoaded 1339.11 ms +1.46% -1.74% 1351.45 ms +2.37% -0.34% -0.91%
loaded 1340.15 ms +1.46% -1.73% 1352.81 ms +2.36% -0.37% -0.94%
firstContentfulPaint 545.32 ms +4.8% -2.61% 555.01 ms +3.17% -4.41% -1.75%
firstBlock 4812.17 ms +1.56% -0.41% 4845.96 ms +0.51% -0.4% -0.7%
type 19.73 ms +4.41% -3.85% 20.13 ms +8.69% -2.83% -1.99%
navigate 125.3 ms +7% -2.92% 122.69 ms +12.16% -2.14% 2.13%
loadPatterns 1341.68 ms +4.55% -3.19% 1416.72 ms +15.51% -3.89% -5.3%
loadPages 1003.49 ms +4.42% -3.66% 1020.76 ms +3.21% -1.22% -1.69%
wpTotal 500.84 ms +4.23% -7.7% 509.72 ms +4.28% -2.89% -1.74%
wpMemoryUsage 12.73 MB +0% -0% 12.69 MB +0% -0% 0.32%
wpDbQueries 44 +2.27% -0% 44 +2.27% -0% 0%

7f94fc3 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.

Backspace in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  -  5
+ Received  + 11

@@ -1,25 +1,31 @@
  Array [
    Object {
      "innerBlocks": Array [
        Object {
          "attributes": Object {
-           "content": "1",
+           "content": "1‸a",
          },
-         "innerBlocks": Array [
+         "innerBlocks": Array [],
+         "name": "core/list-item",
+       },
        Object {
+         "attributes": Object {
+           "content": "2",
+         },
          "innerBlocks": Array [
            Object {
              "attributes": Object {
-                   "content": "a‸2",
+               "ordered": false,
+               "values": "",
              },
-                 "name": "core/list-item",
-               },
+             "innerBlocks": Array [
                Object {
                  "attributes": Object {
                    "content": "b",
                  },
+                 "innerBlocks": Array [],
                  "name": "core/list-item",
                },
              ],
              "name": "core/list",
            },

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:2041:42
Custom Completer: should allow newlines after multiple option completions in /test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js, passed after 1 failed attempt.
Error: expect(received).toBe(expected) // Object.is equality

- Expected  - 3
+ Received  + 3

@@ -1,15 +1,15 @@
  <!-- wp:paragraph -->
- <p>🫐 test</p>
+ <p>🍎 test</p>
  <!-- /wp:paragraph -->

  <!-- wp:paragraph -->
- <p>🫐 test</p>
+ <p>🍎 test</p>
  <!-- /wp:paragraph -->

  <!-- wp:paragraph -->
- <p>🫐 test</p>
+ <p>🍎 test</p>
  <!-- /wp:paragraph -->

  <!-- wp:paragraph -->
  <p>🫐 test</p>
  <!-- /wp:paragraph -->

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js:466:6
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
Undo restores the post selection when another synced entity is loaded (RTC WebSocket) in /test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 2
+ Received  + 2

  Object {
    "attributeKey": "content",
    "blockIndex": 0,
    "content": "abcdef",
-   "endOffset": 3,
-   "offset": 3,
+   "endOffset": 1,
+   "offset": 1,
  }

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts:363:5
can be converted to a quote in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toBe(expected) // Object.is equality

- Expected  - 1
+ Received  + 1

@@ -1,9 +1,9 @@
  <!-- wp:quote -->
  <blockquote class="wp-block-quote"><!-- wp:list -->
  <ul class="wp-block-list"><!-- wp:list-item -->
- <li>one</li>
+ <li>e</li>
  <!-- /wp:list-item -->

  <!-- wp:list-item -->
  <li>two</li>
  <!-- /wp:list-item --></ul>

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:436:52
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
should format text across blocks in /test/e2e/specs/editor/various/multi-block-selection.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 1

@@ -1,9 +1,9 @@
  Array [
    Object {
      "attributes": Object {
-       "content": "ab",
+       "content": "b",
      },
      "name": "core/paragraph",
    },
    Object {
      "attributes": Object {

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:2170:41
should gradually multi-select in /test/e2e/specs/editor/various/multi-block-selection.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 1

  Array [
    Object {
-     "name": "core/column",
+     "name": "core/paragraph",
    },
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:1145:5
should indent and outdent level 1 in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
TimeoutError: locator.click: Timeout 10000ms exceeded.
Call log:
  - waiting for locator('role=toolbar[name="Block tools"i]').locator('role=button[name="Indent"]')

    at Editor.clickBlockToolbarButton (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/editor/click-block-toolbar-button.ts:17:15)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:775:3
should insert a line break on shift+enter in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toBe(expected) // Object.is equality

- Expected  - 1
+ Received  + 1

  <!-- wp:list -->
  <ul class="wp-block-list"><!-- wp:list-item -->
- <li>a<br></li>
+ <li><br></li>
  <!-- /wp:list-item --></ul>
  <!-- /wp:list -->

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:1210:52
should keep a full selection when outdenting nested items with Shift+Tab in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
TimeoutError: locator.click: Timeout 10000ms exceeded.
Call log:
  - waiting for locator('role=toolbar[name="Block tools"i]').locator('role=button[name="Indent"]')

    at Editor.clickBlockToolbarButton (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/editor/click-block-toolbar-button.ts:17:15)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:1166:3
should merge a following sibling list into the outermost list with Delete from a nested item (#77245) in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 2
+ Received  + 2

@@ -8,11 +8,11 @@
          "innerBlocks": Array [
            Object {
              "innerBlocks": Array [
                Object {
                  "attributes": Object {
-                   "content": "b‸",
+                   "content": "b",
                  },
                  "name": "core/list-item",
                },
              ],
              "name": "core/list",
@@ -25,11 +25,11 @@
    },
    Object {
      "innerBlocks": Array [
        Object {
          "attributes": Object {
-           "content": "1",
+           "content": "‸1",
          },
          "innerBlocks": Array [
            Object {
              "innerBlocks": Array [
                Object {

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:2854:41
should only mutate text data on input in /test/e2e/specs/editor/various/rich-text.spec.js, passed after 1 failed attempt.
Error: locator.evaluate: Error: The selection change event listener was never called.
    at eval (eval at evaluate (:311:30), <anonymous>:6:15)
    at UtilityScript.evaluate (<anonymous>:313:16)
    at UtilityScript.<anonymous> (<anonymous>:1:44)
    at eval (eval at evaluate (:311:30), <anonymous>:6:15)
    at UtilityScript.evaluate (<anonymous>:313:16)
    at UtilityScript.<anonymous> (<anonymous>:1:44)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/rich-text.spec.js:407:42
should remove an anchored list when deleting its last item in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 1

@@ -5,11 +5,11 @@
      },
      "innerBlocks": Array [
        Object {
          "attributes": Object {
            "anchor": "item",
-           "content": "x",
+           "content": "",
          },
          "name": "core/list-item",
        },
      ],
      "name": "core/list",

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:2961:41
should replace selected non-breaking spaces from the toolbar in /test/e2e/specs/editor/various/rich-text.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 1

  Array [
    Object {
      "attributes": Object {
-       "content": "one two three",
+       "content": "e two three",
      },
      "name": "core/paragraph",
    },
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/rich-text.spec.js:1050:41
should split into two with paragraph and merge lists in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toBe(expected) // Object.is equality

- Expected  - 1
+ Received  + 1

@@ -1,8 +1,8 @@
  <!-- wp:list -->
  <ul class="wp-block-list"><!-- wp:list-item -->
- <li>one</li>
+ <li>ne</li>
  <!-- /wp:list-item -->

  <!-- wp:list-item -->
  <li></li>
  <!-- /wp:list-item -->

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:502:52
should update the URL from the last navigation if only varies in the URL fragment in /test/e2e/specs/interactivity/router-navigate.spec.ts, passed after 1 failed attempt.
Error: expect(locator).toHaveText(expected) failed

Locator:  getByTestId('title')
Expected: "Link 1"
Received: "Main"
Timeout:  5000ms

Call log:
  - Expect "toHaveText" getByTestId('title') with timeout 5000ms
  - waiting for getByTestId('title')
    14 × locator resolved to <h2 data-testid="title">Main</h2>
       - unexpected value "Main"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/interactivity/router-navigate.spec.ts:160:25

7f94fc3 Run

draganescu and others added 2 commits October 10, 2026 12:24
PHPCompatibility flags the '0x10' test value, which checks that
hexadecimal strings are rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP 7 rejects numeric strings with trailing whitespace, while PHP 8
accepts them, so the PHP 7.4 test run failed on ' 15 '.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@draganescu draganescu mentioned this pull request Oct 10, 2026
3 tasks

@draganescu draganescu left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A cursory code reading - didn't spot any glaring problems, mostly it copies other supports implementations.

draganescu and others added 4 commits October 14, 2026 14:48
# Conflicts:
#	packages/block-editor/CHANGELOG.md
#	packages/block-library/CHANGELOG.md
…port

# Conflicts:
#	packages/block-editor/tsconfig.json
Trunk types useSettings as returning unknown values, so the viewport
setting is cast to the record getRotateCSS expects, as other typed callers
of useSettings do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@draganescu draganescu added the No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core label Oct 21, 2026
draganescu and others added 3 commits October 21, 2026 14:52
The rotate hook registered its own filter adding the style attribute. When
the hook was imported early, such as by the grid's mobile stacking, the
filter ran before the other block supports and moved the style attribute
ahead of theirs, which changed serialized fixtures, and it added the
attribute in tests that only load another block support. The style hook's
filter now also adds it for rotation, so it stays in the same place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename the rotate block support and rotation utility tests to TypeScript so
they pass the `gutenberg/use-typescript` lint rule for new files. The tests
check the same things: block types are built with a typed helper, the block
rotation experiment flag is read through a typed window, and the cases that
pass a style attribute that is not an object use `@ts-expect-error`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…port

# Conflicts:
#	packages/block-editor/src/hooks/test/style.jsdom.test.tsx
The rotate render_block filter and the editor's useBlockProps ran with the
experiment off, so blocks storing style.rotate were rotated anyway. The
front-end filter is now removed in lib/load.php while the experiment is off,
which keeps rotate.php as it would be backported, and the editor support's
hasSupport is isRotateEnabled, which gates both the control and the
rotation. The style attribute is still registered for rotation on both
sides, so a stored rotation is kept rather than dropped on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tellthemachines

Copy link
Copy Markdown
Contributor

Quick notes from testing: this is generally working very well! I'd try and ship this as a stable rather than behind an experiment flag, provided we make it opt-in instead of opt-out. This is very important because making it opt-out as it currently is on this branch can really mess things up for third party blocks that aren't prepared to support this kind of behaviour.

Besides, this doesn't work super well for all types of blocks. I'd opt in for a select few where it works pretty well for now (Image, perhaps even Gallery, Button/Buttons, Video, Heading, Paragraph, maybe Quote, and Group so we can make fun shapes) and leave the rest out for gradual adoption. On some blocks e.g. Accordion things can get badly messy:

Screenshot 2026-10-22 at 3 14 41 pm

And for Spacer I don't think it makes sense at all.

In regards to the canvas experiment, this could mean rotation isn't available for all block types. I don't think this is necessarily a problem, especially with more niche blocks.

One thing to consider is editor UX once items are rotated, because they can overlap other block controls:

Screenshot 2026-10-22 at 3 03 12 pm

I'm not sure how this could best be solved, and we should be mindful of allowing folks to overlap blocks deliberately, so maybe it's something to not treat as a problem for now.

Regarding the placement of the control in the inspector, I don't think "dimensions" really makes sense conceptually. I'd put it under "position" instead (it can still be its own support because there's not much overlap with the position logic, but "position" is a more likely header to put "rotation" under IMO)

I'm happy to add changes to the PR myself if you agree with this direction!

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@draganescu draganescu removed the No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core label Oct 22, 2026
draganescu and others added 5 commits October 23, 2026 12:10
Remove core's wp_render_rotate_support filter when it exists, as the
custom CSS and block visibility supports do, so a WordPress version that
ships rotation doesn't output the rules twice.

The angle picker now rounds to two decimals, the same as stored
rotations, through a shared roundAngle helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
draganescu and others added 4 commits October 23, 2026 20:58
`getRotateValue`, `getRotateForState` and `getUpdatedRotateStyle` read and
write `style.rotate` without any React or UI code, but lived in the rotate
hook module, which imports the inspector controls. Grid code that only
needs the data helpers had to import that module. They now live in
`utils/rotation.ts` with the other angle helpers. The rotation utils test
runs in jsdom, since the block style state helpers need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block style state helpers only needed `hooks/utils` for
`cleanEmptyObject`, and that module imports the components and the store.
Through them, `utils/rotation.ts` couldn't load outside a browser
environment. `cleanEmptyObject` is a plain object helper, so it now lives
in `utils/object.ts`, and `hooks/utils` re-exports it for its existing
importers. The rotation utils test runs in node again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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] Block editor /packages/block-editor [Package] Block library /packages/block-library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants