From 568d21afc73297c94fee9509aae0c48087c72bca Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Fri, 16 Oct 2026 07:06:51 +0100
Subject: [PATCH 1/7] Post slug: Don't show `auto-draft` as the slug of a new
post
For a new post, the REST API builds `generated_slug` from the `Auto Draft`
placeholder title, and autosaves don't refresh it. Since #78135 made the
editor and the fields slug helper prefer `generated_slug` over the title,
the slug summary, the slug input and the permalink preview showed
`auto-draft` until the post was first saved manually.
Skip `generated_slug` while the post is still an auto-draft, so the slug
falls back to the one derived from the title.
Co-Authored-By: Claude Opus 5.5
---
.../reference-guides/data/data-core-editor.md | 2 +
packages/editor/CHANGELOG.md | 1 +
packages/editor/src/store/selectors.js | 10 +++-
.../src/store/test/selectors.jsdom.test.jsx | 19 ++++++++
packages/fields/CHANGELOG.md | 1 +
.../fields/src/fields/slug/test/utils.test.ts | 48 +++++++++++++++++++
packages/fields/src/fields/slug/utils.ts | 7 ++-
7 files changed, 86 insertions(+), 2 deletions(-)
create mode 100644 packages/fields/src/fields/slug/test/utils.test.ts
diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md
index baeb1cd57af8df..5f52825c7d1ad0 100644
--- a/docs/reference-guides/data/data-core-editor.md
+++ b/docs/reference-guides/data/data-core-editor.md
@@ -349,6 +349,8 @@ _Returns_
Returns the slug for the post being edited, preferring a manually edited value if one exists, then the server-generated slug from the post entity, then a JS approximation of the current post title, and finally the post ID.
+The server-generated slug is skipped until the post is first saved: for an auto-draft it comes from the `Auto Draft` placeholder title, and autosaves don't refresh it.
+
_Parameters_
- _state_ `Object`: Editor state.
diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index 43ea0a2e090914..10fe7fb0d9cac1 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -26,6 +26,7 @@
- Visual History: Render the inline removed-text diff (``) at the theme's body color over a red-tinted background instead of recoloring it with a translucent color-matrix filter, so it keeps the theme's WCAG AA contrast on any background ([#82258](https://github.com/WordPress/gutenberg/pull/82258)).
- Notes: Stop announcing the "Add note" block menu item as opening a dialog ([#84818](https://github.com/WordPress/gutenberg/pull/84818)).
- `EditorProvider`: Keep a preview canvas from restoring a block selection written to the entity by another editor, such as the site editor's navigation sidebar ([#84832](https://github.com/WordPress/gutenberg/pull/84832)).
+- Post slug: Show the slug derived from the title for a new post instead of `auto-draft` until it is first saved.
### Internal
diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js
index 37f07121349382..c5c73ad278ecb1 100644
--- a/packages/editor/src/store/selectors.js
+++ b/packages/editor/src/store/selectors.js
@@ -991,14 +991,22 @@ export function getPermalink( state ) {
* value if one exists, then the server-generated slug from the post entity,
* then a JS approximation of the current post title, and finally the post ID.
*
+ * The server-generated slug is skipped until the post is first saved: for an
+ * auto-draft it comes from the `Auto Draft` placeholder title, and autosaves
+ * don't refresh it.
+ *
* @param {Object} state Editor state.
*
* @return {string} The current slug to be displayed in the editor
*/
export function getEditedPostSlug( state ) {
+ const generatedSlug =
+ getCurrentPostAttribute( state, 'status' ) !== 'auto-draft'
+ ? getEditedPostAttribute( state, 'generated_slug' )
+ : undefined;
return (
getEditedPostAttribute( state, 'slug' ) ||
- getEditedPostAttribute( state, 'generated_slug' ) ||
+ generatedSlug ||
cleanForSlug( getEditedPostAttribute( state, 'title' ) ) ||
getCurrentPostId( state )
);
diff --git a/packages/editor/src/store/test/selectors.jsdom.test.jsx b/packages/editor/src/store/test/selectors.jsdom.test.jsx
index 65b9db91bf9f3c..5c57786b818e45 100644
--- a/packages/editor/src/store/test/selectors.jsdom.test.jsx
+++ b/packages/editor/src/store/test/selectors.jsdom.test.jsx
@@ -2739,6 +2739,25 @@ describe( 'selectors', () => {
);
} );
+ it( 'should ignore the server-generated slug of an auto-draft', () => {
+ const state = {
+ currentPost: {
+ status: 'auto-draft',
+ title: 'Auto Draft',
+ generated_slug: 'auto-draft',
+ },
+ editor: {
+ present: {
+ edits: {
+ title: 'Edited Title',
+ },
+ },
+ },
+ };
+
+ expect( getEditedPostSlug( state ) ).toBe( 'edited-title' );
+ } );
+
it( 'should return the cleaned title as slug if no saved, edited, or generated slug exists', () => {
const state = {
currentPost: {
diff --git a/packages/fields/CHANGELOG.md b/packages/fields/CHANGELOG.md
index af3bffef3141f5..99d1214b39fb56 100644
--- a/packages/fields/CHANGELOG.md
+++ b/packages/fields/CHANGELOG.md
@@ -17,6 +17,7 @@
- `PageTitleView`: Keep the page-type badge on a single line, so a list row with the `Privacy Policy Page` badge no longer grows and jumps when hovering reveals the primary action. ([#84368](https://github.com/WordPress/gutenberg/pull/84368))
- Status and slug fields: When the post's `_links` lack the `wp:action-publish` relation, disable the status field and show only the permalink in the slug control, so a user who can't publish gets what the classic sidebar shows. ([#84371](https://github.com/WordPress/gutenberg/pull/84371))
- `SlugEdit`: Keep the external link icon on the same line as the permalink when the slug can't be edited. ([#84371](https://github.com/WordPress/gutenberg/pull/84371))
+- Slug field: Show the slug derived from the title for a new post instead of `auto-draft` until it is first saved.
## 0.49.0 (2026-10-07)
diff --git a/packages/fields/src/fields/slug/test/utils.test.ts b/packages/fields/src/fields/slug/test/utils.test.ts
new file mode 100644
index 00000000000000..8c213208ff7830
--- /dev/null
+++ b/packages/fields/src/fields/slug/test/utils.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import type { BasePost } from '../../../types';
+import { getSlug } from '../utils';
+
+const createPost = ( overrides: Partial< BasePost > ): BasePost => ( {
+ id: 123,
+ type: 'post',
+ title: 'Sample Post',
+ content: '',
+ ...overrides,
+} );
+
+describe( 'getSlug', () => {
+ it( 'returns the slug if one exists', () => {
+ expect(
+ getSlug(
+ createPost( {
+ status: 'draft',
+ slug: 'custom-slug',
+ generated_slug: 'sample-post',
+ } )
+ )
+ ).toBe( 'custom-slug' );
+ } );
+
+ it( 'returns the server-generated slug if no slug exists', () => {
+ expect(
+ getSlug(
+ createPost( {
+ status: 'draft',
+ generated_slug: 'server-generated-slug',
+ } )
+ )
+ ).toBe( 'server-generated-slug' );
+ } );
+
+ it( 'ignores the server-generated slug of an auto-draft', () => {
+ expect(
+ getSlug(
+ createPost( {
+ status: 'auto-draft',
+ title: 'Hello Slug',
+ generated_slug: 'auto-draft',
+ } )
+ )
+ ).toBe( 'hello-slug' );
+ } );
+} );
diff --git a/packages/fields/src/fields/slug/utils.ts b/packages/fields/src/fields/slug/utils.ts
index df5c5d0f762182..f851dd437669eb 100644
--- a/packages/fields/src/fields/slug/utils.ts
+++ b/packages/fields/src/fields/slug/utils.ts
@@ -7,9 +7,14 @@ export const getSlug = ( item: BasePost ): string => {
return '';
}
+ // An auto-draft's generated slug comes from the `Auto Draft` placeholder
+ // title and isn't refreshed by autosaves, so fall back to the title.
+ const generatedSlug =
+ item.status !== 'auto-draft' ? item.generated_slug : undefined;
+
return (
item.slug ||
- item.generated_slug ||
+ generatedSlug ||
cleanForSlug( getItemTitle( item ) ) ||
item.id.toString()
);
From 96bd04cd28598cfe038bfe23b7021d2976db7f29 Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Fri, 16 Oct 2026 07:19:13 +0100
Subject: [PATCH 2/7] Post slug: Only use the server-generated slug while it
matches the title
The server generates `generated_slug` from the saved title, so it is stale
as soon as the title is edited, and for an auto-draft it comes from the
`Auto Draft` placeholder.
- `getEditedPostSlug` only uses `generated_slug` while the title is
unchanged (and never for an auto-draft), otherwise it falls back to the
slug of the current title.
- `getPermalinkParts` uses `getEditedPostSlug` for the post name, so the
slug, the permalink preview, the copied permalink and the view link
agree.
- When an autosave is saved to the post itself, core-data copies the new
title into the persisted record but kept the old `generated_slug`, which
the autosave response doesn't include. That left a new post showing
`auto-draft` after its first autosave. Drop it instead.
Co-Authored-By: Claude Opus 5.5
---
.../reference-guides/data/data-core-editor.md | 4 +-
packages/core-data/CHANGELOG.md | 1 +
packages/core-data/src/actions.js | 5 +-
.../core-data/src/test/actions.jsdom.test.js | 49 +++++++++++++++
packages/editor/CHANGELOG.md | 2 +-
packages/editor/src/store/selectors.js | 37 ++++++-----
.../src/store/test/selectors.jsdom.test.jsx | 63 ++++++++++++++++---
packages/fields/CHANGELOG.md | 2 +-
8 files changed, 132 insertions(+), 31 deletions(-)
diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md
index 5f52825c7d1ad0..5bf9219b2a252a 100644
--- a/docs/reference-guides/data/data-core-editor.md
+++ b/docs/reference-guides/data/data-core-editor.md
@@ -347,9 +347,9 @@ _Returns_
### getEditedPostSlug
-Returns the slug for the post being edited, preferring a manually edited value if one exists, then the server-generated slug from the post entity, then a JS approximation of the current post title, and finally the post ID.
+Returns the slug for the post being edited, preferring a manually edited or saved value if one exists, then the server-generated slug from the post entity, then a JS approximation of the current post title, and finally the post ID.
-The server-generated slug is skipped until the post is first saved: for an auto-draft it comes from the `Auto Draft` placeholder title, and autosaves don't refresh it.
+The server generates its slug from the saved title, so it is only used while the title is unchanged. It is never used for an auto-draft, whose saved title is the `Auto Draft` placeholder and which autosaves don't refresh.
_Parameters_
diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md
index 81533d877d375c..2b2c3a47ff91dd 100644
--- a/packages/core-data/CHANGELOG.md
+++ b/packages/core-data/CHANGELOG.md
@@ -13,6 +13,7 @@
### Internal
- Add the private `getFieldsConfig( kind, name )` selector, which returns the fields an entity registered on the server, as the `/wp/v2/entity-fields` REST API route returns them ([#84700](https://github.com/WordPress/gutenberg/pull/84700)).
+- `saveEntityRecord`: When an autosave is saved to the post itself, drop `generated_slug` from the persisted record instead of keeping the value generated from the previous title, which made a new post show `auto-draft` as its slug ([#84893](https://github.com/WordPress/gutenberg/pull/84893)).
## 8.2.0 (2026-10-07)
diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js
index c8cd75b6acfd53..a5f0053f27c019 100644
--- a/packages/core-data/src/actions.js
+++ b/packages/core-data/src/actions.js
@@ -699,8 +699,11 @@ export const saveEntityRecord =
newRecord.status === 'draft'
? newRecord.status
: persistedRecord.status;
- } else {
+ } else if ( key !== 'generated_slug' ) {
// These properties are not persisted in autosaves.
+ // `generated_slug` is dropped instead: the server
+ // derives it from the title this autosave just
+ // saved, and the response doesn't include it.
acc[ key ] = persistedRecord[ key ];
}
return acc;
diff --git a/packages/core-data/src/test/actions.jsdom.test.js b/packages/core-data/src/test/actions.jsdom.test.js
index 087cb5e0c6fab6..b0e5ed643d0d57 100644
--- a/packages/core-data/src/test/actions.jsdom.test.js
+++ b/packages/core-data/src/test/actions.jsdom.test.js
@@ -1401,6 +1401,55 @@ describe( 'saveEntityRecord', () => {
expect( dispatch.receiveAutosaves ).toHaveBeenCalled();
} );
} );
+
+ describe( 'autosaves saved to the post itself', () => {
+ it( 'drops the stale generated slug from the persisted record', async () => {
+ const persistedRecord = {
+ id: 10,
+ status: 'auto-draft',
+ title: '',
+ content: '',
+ generated_slug: 'auto-draft',
+ link: 'http://example.com/?p=10',
+ };
+ const select = {
+ getRawEntityRecord: () => persistedRecord,
+ };
+ const resolveSelect = {
+ getEntitiesConfig: vi.fn( () => [
+ {
+ name: 'post',
+ kind: 'postType',
+ baseURL: '/wp/v2/posts',
+ },
+ ] ),
+ };
+ // An author's autosave of a draft is saved to the post itself,
+ // so the response has the post's own ID.
+ apiFetch.mockImplementation( () => ( {
+ id: 10,
+ title: 'Hello Slug',
+ content: 'Some content',
+ } ) );
+
+ await saveEntityRecord(
+ 'postType',
+ 'post',
+ { id: 10, title: 'Hello Slug', content: 'Some content' },
+ { isAutosave: true }
+ )( { select, dispatch, resolveSelect } );
+
+ const [ , , receivedRecord ] =
+ dispatch.receiveEntityRecords.mock.calls[ 0 ];
+ expect( receivedRecord ).toEqual( {
+ id: 10,
+ status: 'draft',
+ title: 'Hello Slug',
+ content: 'Some content',
+ link: 'http://example.com/?p=10',
+ } );
+ } );
+ } );
} );
describe( 'receiveUserPermission', () => {
diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index 10fe7fb0d9cac1..a8be226bf8dace 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -26,7 +26,7 @@
- Visual History: Render the inline removed-text diff (``) at the theme's body color over a red-tinted background instead of recoloring it with a translucent color-matrix filter, so it keeps the theme's WCAG AA contrast on any background ([#82258](https://github.com/WordPress/gutenberg/pull/82258)).
- Notes: Stop announcing the "Add note" block menu item as opening a dialog ([#84818](https://github.com/WordPress/gutenberg/pull/84818)).
- `EditorProvider`: Keep a preview canvas from restoring a block selection written to the entity by another editor, such as the site editor's navigation sidebar ([#84832](https://github.com/WordPress/gutenberg/pull/84832)).
-- Post slug: Show the slug derived from the title for a new post instead of `auto-draft` until it is first saved.
+- Post slug: Show the slug derived from the current title instead of `auto-draft`, or the slug of the title before an unsaved edit. `getEditedPostSlug` only uses the server-generated slug while the title is unchanged, and `getPermalinkParts` now uses `getEditedPostSlug` for the post name, so the slug, the permalink preview and the copied permalink agree ([#84893](https://github.com/WordPress/gutenberg/pull/84893)).
### Internal
diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js
index c5c73ad278ecb1..9b65423b3fe65a 100644
--- a/packages/editor/src/store/selectors.js
+++ b/packages/editor/src/store/selectors.js
@@ -987,27 +987,34 @@ export function getPermalink( state ) {
}
/**
- * Returns the slug for the post being edited, preferring a manually edited
- * value if one exists, then the server-generated slug from the post entity,
- * then a JS approximation of the current post title, and finally the post ID.
+ * Returns the slug for the post being edited, preferring a manually edited or
+ * saved value if one exists, then the server-generated slug from the post
+ * entity, then a JS approximation of the current post title, and finally the
+ * post ID.
*
- * The server-generated slug is skipped until the post is first saved: for an
- * auto-draft it comes from the `Auto Draft` placeholder title, and autosaves
- * don't refresh it.
+ * The server generates its slug from the saved title, so it is only used while
+ * the title is unchanged. It is never used for an auto-draft, whose saved title
+ * is the `Auto Draft` placeholder and which autosaves don't refresh.
*
* @param {Object} state Editor state.
*
* @return {string} The current slug to be displayed in the editor
*/
export function getEditedPostSlug( state ) {
- const generatedSlug =
- getCurrentPostAttribute( state, 'status' ) !== 'auto-draft'
- ? getEditedPostAttribute( state, 'generated_slug' )
- : undefined;
+ const slug = getEditedPostAttribute( state, 'slug' );
+ if ( slug ) {
+ return slug;
+ }
+
+ const title = getEditedPostAttribute( state, 'title' );
+ const isGeneratedSlugCurrent =
+ getCurrentPostAttribute( state, 'status' ) !== 'auto-draft' &&
+ title === getCurrentPostAttribute( state, 'title' );
+
return (
- getEditedPostAttribute( state, 'slug' ) ||
- generatedSlug ||
- cleanForSlug( getEditedPostAttribute( state, 'title' ) ) ||
+ ( isGeneratedSlugCurrent &&
+ getEditedPostAttribute( state, 'generated_slug' ) ) ||
+ cleanForSlug( title ) ||
getCurrentPostId( state )
);
}
@@ -1030,9 +1037,7 @@ export function getPermalinkParts( state ) {
return null;
}
- const postName =
- getEditedPostAttribute( state, 'slug' ) ||
- getEditedPostAttribute( state, 'generated_slug' );
+ const postName = getEditedPostSlug( state );
const [ prefix, suffix ] = permalinkTemplate.split(
PERMALINK_POSTNAME_REGEX
diff --git a/packages/editor/src/store/test/selectors.jsdom.test.jsx b/packages/editor/src/store/test/selectors.jsdom.test.jsx
index 5c57786b818e45..337bb400a99e11 100644
--- a/packages/editor/src/store/test/selectors.jsdom.test.jsx
+++ b/packages/editor/src/store/test/selectors.jsdom.test.jsx
@@ -2652,6 +2652,30 @@ describe( 'selectors', () => {
expect( getPermalinkParts( state ) ).toEqual( parts );
} );
+ it( 'should use the edited post slug as the post name', () => {
+ const state = {
+ currentPost: {
+ permalink_template: 'http://foo.test/bar/%postname%/',
+ title: 'Sample Post',
+ generated_slug: 'sample-post',
+ },
+ editor: {
+ present: {
+ edits: {
+ title: 'Edited Title',
+ },
+ },
+ },
+ initialEdits: {},
+ };
+
+ expect( getPermalinkParts( state ) ).toEqual( {
+ prefix: 'http://foo.test/bar/',
+ postName: 'edited-title',
+ suffix: '/',
+ } );
+ } );
+
it( 'should return null if the post has no permalink template', () => {
const state = {
currentPost: {},
@@ -2719,11 +2743,11 @@ describe( 'selectors', () => {
);
} );
- it( 'should prefer the server-generated slug over the cleaned title', () => {
+ it( 'should ignore the server-generated slug once the title has been edited', () => {
const state = {
currentPost: {
title: 'Sample Post',
- generated_slug: 'server-generated-slug',
+ generated_slug: 'sample-post',
},
editor: {
present: {
@@ -2734,17 +2758,16 @@ describe( 'selectors', () => {
},
};
- expect( getEditedPostSlug( state ) ).toBe(
- 'server-generated-slug'
- );
+ expect( getEditedPostSlug( state ) ).toBe( 'edited-title' );
} );
- it( 'should ignore the server-generated slug of an auto-draft', () => {
+ it( 'should prefer a saved slug over the edited title', () => {
const state = {
currentPost: {
- status: 'auto-draft',
- title: 'Auto Draft',
- generated_slug: 'auto-draft',
+ status: 'publish',
+ slug: 'custom-slug',
+ title: 'Sample Post',
+ generated_slug: 'sample-post',
},
editor: {
present: {
@@ -2755,7 +2778,27 @@ describe( 'selectors', () => {
},
};
- expect( getEditedPostSlug( state ) ).toBe( 'edited-title' );
+ expect( getEditedPostSlug( state ) ).toBe( 'custom-slug' );
+ } );
+
+ it( 'should ignore the server-generated slug of an untitled auto-draft', () => {
+ // core-data clears an auto-draft's `Auto Draft` placeholder title,
+ // so the title looks unchanged while the slug still comes from it.
+ const state = {
+ postId: 123,
+ currentPost: {
+ status: 'auto-draft',
+ title: '',
+ generated_slug: 'auto-draft',
+ },
+ editor: {
+ present: {
+ edits: {},
+ },
+ },
+ };
+
+ expect( getEditedPostSlug( state ) ).toBe( 123 );
} );
it( 'should return the cleaned title as slug if no saved, edited, or generated slug exists', () => {
diff --git a/packages/fields/CHANGELOG.md b/packages/fields/CHANGELOG.md
index 99d1214b39fb56..2a3fdb3eb19dc5 100644
--- a/packages/fields/CHANGELOG.md
+++ b/packages/fields/CHANGELOG.md
@@ -17,7 +17,7 @@
- `PageTitleView`: Keep the page-type badge on a single line, so a list row with the `Privacy Policy Page` badge no longer grows and jumps when hovering reveals the primary action. ([#84368](https://github.com/WordPress/gutenberg/pull/84368))
- Status and slug fields: When the post's `_links` lack the `wp:action-publish` relation, disable the status field and show only the permalink in the slug control, so a user who can't publish gets what the classic sidebar shows. ([#84371](https://github.com/WordPress/gutenberg/pull/84371))
- `SlugEdit`: Keep the external link icon on the same line as the permalink when the slug can't be edited. ([#84371](https://github.com/WordPress/gutenberg/pull/84371))
-- Slug field: Show the slug derived from the title for a new post instead of `auto-draft` until it is first saved.
+- Slug field: Show the slug derived from the title for a new post instead of `auto-draft` until it is first saved ([#84893](https://github.com/WordPress/gutenberg/pull/84893)).
## 0.49.0 (2026-10-07)
From b35388b2fd8932d610c079991c29fae681f2f991 Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Fri, 16 Oct 2026 07:21:42 +0100
Subject: [PATCH 3/7] Core Data: Revert dropping `generated_slug` after an
autosave
core-data shouldn't special-case a post field in the autosave merge; it
keeps whatever the server last returned.
Co-Authored-By: Claude Opus 5.5
---
packages/core-data/CHANGELOG.md | 1 -
packages/core-data/src/actions.js | 5 +-
.../core-data/src/test/actions.jsdom.test.js | 49 -------------------
3 files changed, 1 insertion(+), 54 deletions(-)
diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md
index 2b2c3a47ff91dd..81533d877d375c 100644
--- a/packages/core-data/CHANGELOG.md
+++ b/packages/core-data/CHANGELOG.md
@@ -13,7 +13,6 @@
### Internal
- Add the private `getFieldsConfig( kind, name )` selector, which returns the fields an entity registered on the server, as the `/wp/v2/entity-fields` REST API route returns them ([#84700](https://github.com/WordPress/gutenberg/pull/84700)).
-- `saveEntityRecord`: When an autosave is saved to the post itself, drop `generated_slug` from the persisted record instead of keeping the value generated from the previous title, which made a new post show `auto-draft` as its slug ([#84893](https://github.com/WordPress/gutenberg/pull/84893)).
## 8.2.0 (2026-10-07)
diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js
index a5f0053f27c019..c8cd75b6acfd53 100644
--- a/packages/core-data/src/actions.js
+++ b/packages/core-data/src/actions.js
@@ -699,11 +699,8 @@ export const saveEntityRecord =
newRecord.status === 'draft'
? newRecord.status
: persistedRecord.status;
- } else if ( key !== 'generated_slug' ) {
+ } else {
// These properties are not persisted in autosaves.
- // `generated_slug` is dropped instead: the server
- // derives it from the title this autosave just
- // saved, and the response doesn't include it.
acc[ key ] = persistedRecord[ key ];
}
return acc;
diff --git a/packages/core-data/src/test/actions.jsdom.test.js b/packages/core-data/src/test/actions.jsdom.test.js
index b0e5ed643d0d57..087cb5e0c6fab6 100644
--- a/packages/core-data/src/test/actions.jsdom.test.js
+++ b/packages/core-data/src/test/actions.jsdom.test.js
@@ -1401,55 +1401,6 @@ describe( 'saveEntityRecord', () => {
expect( dispatch.receiveAutosaves ).toHaveBeenCalled();
} );
} );
-
- describe( 'autosaves saved to the post itself', () => {
- it( 'drops the stale generated slug from the persisted record', async () => {
- const persistedRecord = {
- id: 10,
- status: 'auto-draft',
- title: '',
- content: '',
- generated_slug: 'auto-draft',
- link: 'http://example.com/?p=10',
- };
- const select = {
- getRawEntityRecord: () => persistedRecord,
- };
- const resolveSelect = {
- getEntitiesConfig: vi.fn( () => [
- {
- name: 'post',
- kind: 'postType',
- baseURL: '/wp/v2/posts',
- },
- ] ),
- };
- // An author's autosave of a draft is saved to the post itself,
- // so the response has the post's own ID.
- apiFetch.mockImplementation( () => ( {
- id: 10,
- title: 'Hello Slug',
- content: 'Some content',
- } ) );
-
- await saveEntityRecord(
- 'postType',
- 'post',
- { id: 10, title: 'Hello Slug', content: 'Some content' },
- { isAutosave: true }
- )( { select, dispatch, resolveSelect } );
-
- const [ , , receivedRecord ] =
- dispatch.receiveEntityRecords.mock.calls[ 0 ];
- expect( receivedRecord ).toEqual( {
- id: 10,
- status: 'draft',
- title: 'Hello Slug',
- content: 'Some content',
- link: 'http://example.com/?p=10',
- } );
- } );
- } );
} );
describe( 'receiveUserPermission', () => {
From 5d60a85d00c258d29aa61fa5a3b9693ed7b5b1af Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Fri, 16 Oct 2026 07:48:18 +0100
Subject: [PATCH 4/7] Post slug: Derive the slug from the current title, not
the server
`generated_slug` comes from the saved title, so it lags behind title edits,
and for a new post it comes from the `Auto Draft` placeholder. An autosave
saves the title to the post but doesn't return a new `generated_slug`, so a
new post kept showing `auto-draft` after its first autosave. It only matters
for posts without a slug (drafts, pending posts and auto-drafts), which is
exactly when it's stale, so stop using it for the slug preview.
- `getEditedPostSlug` and the fields `getSlug` return the slug if one is
set, otherwise the slug of the current title, which is what WordPress
uses on publish. Without a slug or a title they return an empty string
instead of falling back to the post ID.
- `getPermalinkParts` uses `getEditedPostSlug` for the post name, and
`getPermalink` returns the post's plain `?p=ID` link when it's empty.
- The Slug summary row shows `None` for a post without a slug, and the
permalink previews in `PostURL` and the fields slug control show the plain
link.
- Drop the fields slug controls' `originalSlugRef` fallback: it never ran
while `getSlug` fell back to the post ID, and with an empty slug it would
pin an outdated slug on blur. This also removes their `react-hooks/refs`
lint suppressions.
Co-Authored-By: Claude Opus 5.5
---
.../reference-guides/data/data-core-editor.md | 4 +-
packages/editor/CHANGELOG.md | 2 +-
.../editor/src/components/post-url/index.jsx | 31 +++--
.../editor/src/components/post-url/panel.jsx | 3 +-
packages/editor/src/store/selectors.js | 35 +++---
.../src/store/test/selectors.jsdom.test.jsx | 113 ++++++++++--------
packages/fields/CHANGELOG.md | 2 +-
packages/fields/src/fields/slug/slug-edit.tsx | 48 ++++----
packages/fields/src/fields/slug/slug-view.tsx | 16 +--
.../fields/src/fields/slug/test/utils.test.ts | 17 +--
packages/fields/src/fields/slug/utils.ts | 24 ++--
.../editor/various/sidebar-permalink.spec.js | 40 +++++++
tools/eslint/suppressions.json | 8 --
13 files changed, 189 insertions(+), 154 deletions(-)
diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md
index 5bf9219b2a252a..52f4b4579325da 100644
--- a/docs/reference-guides/data/data-core-editor.md
+++ b/docs/reference-guides/data/data-core-editor.md
@@ -347,9 +347,9 @@ _Returns_
### getEditedPostSlug
-Returns the slug for the post being edited, preferring a manually edited or saved value if one exists, then the server-generated slug from the post entity, then a JS approximation of the current post title, and finally the post ID.
+Returns the slug for the post being edited: the edited or saved slug if one is set, otherwise the slug derived from the current title, which is what WordPress uses when the post is published. Returns an empty string for a post with neither.
-The server generates its slug from the saved title, so it is only used while the title is unchanged. It is never used for an auto-draft, whose saved title is the `Auto Draft` placeholder and which autosaves don't refresh.
+The server-generated slug isn't used: it comes from the saved title, so it lags behind title edits, and for a new post it comes from the `Auto Draft` placeholder.
_Parameters_
diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index a8be226bf8dace..338e5283433240 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -26,7 +26,7 @@
- Visual History: Render the inline removed-text diff (``) at the theme's body color over a red-tinted background instead of recoloring it with a translucent color-matrix filter, so it keeps the theme's WCAG AA contrast on any background ([#82258](https://github.com/WordPress/gutenberg/pull/82258)).
- Notes: Stop announcing the "Add note" block menu item as opening a dialog ([#84818](https://github.com/WordPress/gutenberg/pull/84818)).
- `EditorProvider`: Keep a preview canvas from restoring a block selection written to the entity by another editor, such as the site editor's navigation sidebar ([#84832](https://github.com/WordPress/gutenberg/pull/84832)).
-- Post slug: Show the slug derived from the current title instead of `auto-draft`, or the slug of the title before an unsaved edit. `getEditedPostSlug` only uses the server-generated slug while the title is unchanged, and `getPermalinkParts` now uses `getEditedPostSlug` for the post name, so the slug, the permalink preview and the copied permalink agree ([#84893](https://github.com/WordPress/gutenberg/pull/84893)).
+- Post slug: Show the slug of the current title instead of the server-generated slug, which could be `auto-draft` or the slug of an outdated title. A post without a slug or a title shows `None` and its plain `?p=ID` permalink: `getEditedPostSlug` now returns an empty string instead of the post ID, `getPermalinkParts` uses it as the post name, and `getPermalink` returns the post's `link` ([#84893](https://github.com/WordPress/gutenberg/pull/84893)).
### Internal
diff --git a/packages/editor/src/components/post-url/index.jsx b/packages/editor/src/components/post-url/index.jsx
index 7881013f424bfe..d21929d41220e3 100644
--- a/packages/editor/src/components/post-url/index.jsx
+++ b/packages/editor/src/components/post-url/index.jsx
@@ -143,9 +143,9 @@ export default function PostURL( { onClose } ) {
}
} }
onBlur={ ( event ) => {
- // Without a slug the field shows a fallback, which for
- // a new post is the `auto-draft` placeholder. Writing
- // it would pin that value as the post slug.
+ // Without a slug the field shows the slug derived from
+ // the title. Writing it would pin that value, so the
+ // slug would stop following the title.
if ( hasSlug ) {
editPost( {
slug: cleanForSlug(
@@ -168,15 +168,22 @@ export default function PostURL( { onClose } ) {
href={ postLink }
target="_blank"
>
-
- { permalinkPrefix }
-
-
- { postSlug }
-
-
- { permalinkSuffix }
-
+ { postSlug ? (
+ <>
+
+ { permalinkPrefix }
+
+
+ { postSlug }
+
+
+ { permalinkSuffix }
+
+ >
+ ) : (
+ // Without a slug the post only has its plain permalink.
+ permalink
+ ) }
>
diff --git a/packages/editor/src/components/post-url/panel.jsx b/packages/editor/src/components/post-url/panel.jsx
index 596493f97ee6d5..6d1adaaf827ce6 100644
--- a/packages/editor/src/components/post-url/panel.jsx
+++ b/packages/editor/src/components/post-url/panel.jsx
@@ -79,7 +79,8 @@ function PostURLToggle( { isOpen, onClick } ) {
slug: select( editorStore ).getEditedPostSlug(),
};
}, [] );
- const decodedSlug = safeDecodeURIComponent( slug );
+ // A draft without a slug or a title has no slug yet.
+ const decodedSlug = safeDecodeURIComponent( slug ) || __( 'None' );
return (