diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json
index 9623263166916..5fa7e37e6acbd 100644
--- a/packages/block-library/src/table-of-contents/block.json
+++ b/packages/block-library/src/table-of-contents/block.json
@@ -13,7 +13,8 @@
"type": "array",
"items": {
"type": "object"
- }
+ },
+ "default": []
},
"onlyIncludeCurrentPage": {
"type": "boolean",
diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js
index 7f3bb6529bf32..915375606b10c 100644
--- a/packages/block-library/src/table-of-contents/edit.js
+++ b/packages/block-library/src/table-of-contents/edit.js
@@ -1,8 +1,3 @@
-/**
- * External dependencies
- */
-import fastDeepEqual from 'fast-deep-equal/es6';
-
/**
* WordPress dependencies
*/
@@ -22,10 +17,8 @@ import {
ToolbarGroup,
} from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
-import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
-import { renderToString, useEffect } from '@wordpress/element';
+import { renderToString } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
-import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
@@ -33,6 +26,7 @@ import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
import icon from './icon';
import TableOfContentsList from './list';
import { linearToNestedHeadingList } from './utils';
+import { useObserveHeadings } from './hooks';
/** @typedef {import('./utils').HeadingData} HeadingData */
@@ -53,6 +47,8 @@ export default function TableOfContentsEdit( {
clientId,
setAttributes,
} ) {
+ useObserveHeadings( clientId );
+
const blockProps = useBlockProps();
const canInsertList = useSelect(
@@ -66,160 +62,7 @@ export default function TableOfContentsEdit( {
[ clientId ]
);
- const { __unstableMarkNextChangeAsNotPersistent, replaceBlocks } =
- useDispatch( blockEditorStore );
-
- /**
- * The latest heading data, or null if the new data deeply equals the saved
- * headings attribute.
- *
- * Since useSelect forces a re-render when its return value is shallowly
- * inequal to its prior call, we would be re-rendering this block every time
- * the stores change, even if the latest headings were deeply equal to the
- * ones saved in the block attributes.
- *
- * By returning null when they're equal, we reduce that to 2 renders: one
- * when there are new latest headings (and so it returns them), and one when
- * they haven't changed (so it returns null). As long as the latest heading
- * data remains the same, further calls of the useSelect callback will
- * continue to return null, thus preventing any forced re-renders.
- */
- const latestHeadings = useSelect(
- ( select ) => {
- const {
- getBlockAttributes,
- getBlockName,
- getClientIdsWithDescendants,
- __experimentalGetGlobalBlocksByName: getGlobalBlocksByName,
- } = select( blockEditorStore );
-
- // FIXME: @wordpress/block-library should not depend on @wordpress/editor.
- // Blocks can be loaded into a *non-post* block editor, so to avoid
- // declaring @wordpress/editor as a dependency, we must access its
- // store by string. When the store is not available, editorSelectors
- // will be null, and the block's saved markup will lack permalinks.
- // eslint-disable-next-line @wordpress/data-no-store-string-literals
- const editorSelectors = select( 'core/editor' );
-
- const pageBreakClientIds = getGlobalBlocksByName( 'core/nextpage' );
-
- const isPaginated = pageBreakClientIds.length !== 0;
-
- // Get the client ids of all blocks in the editor.
- const allBlockClientIds = getClientIdsWithDescendants();
-
- // If onlyIncludeCurrentPage is true, calculate the page (of a paginated post) this block is part of, so we know which headings to include; otherwise, skip the calculation.
- let tocPage = 1;
-
- if ( isPaginated && onlyIncludeCurrentPage ) {
- // We can't use getBlockIndex because it only returns the index
- // relative to sibling blocks.
- const tocIndex = allBlockClientIds.indexOf( clientId );
-
- for ( const [
- blockIndex,
- blockClientId,
- ] of allBlockClientIds.entries() ) {
- // If we've reached blocks after the Table of Contents, we've
- // finished calculating which page the block is on.
- if ( blockIndex >= tocIndex ) {
- break;
- }
- if ( getBlockName( blockClientId ) === 'core/nextpage' ) {
- tocPage++;
- }
- }
- }
-
- const _latestHeadings = [];
-
- /** The page (of a paginated post) a heading will be part of. */
- let headingPage = 1;
-
- /**
- * A permalink to the current post. If the core/editor store is
- * unavailable, this variable will be null.
- */
- const permalink = editorSelectors?.getPermalink() ?? null;
-
- let headingPageLink = null;
-
- // If the core/editor store is available, we can add permalinks to the
- // generated table of contents.
- if ( typeof permalink === 'string' ) {
- headingPageLink = isPaginated
- ? addQueryArgs( permalink, { page: headingPage } )
- : permalink;
- }
-
- for ( const blockClientId of allBlockClientIds ) {
- const blockName = getBlockName( blockClientId );
- if ( blockName === 'core/nextpage' ) {
- headingPage++;
-
- // If we're only including headings from the current page (of
- // a paginated post), then exit the loop if we've reached the
- // pages after the one with the Table of Contents block.
- if ( onlyIncludeCurrentPage && headingPage > tocPage ) {
- break;
- }
-
- if ( typeof permalink === 'string' ) {
- headingPageLink = addQueryArgs(
- removeQueryArgs( permalink, [ 'page' ] ),
- { page: headingPage }
- );
- }
- }
- // If we're including all headings or we've reached headings on
- // the same page as the Table of Contents block, add them to the
- // list.
- else if (
- ! onlyIncludeCurrentPage ||
- headingPage === tocPage
- ) {
- if ( blockName === 'core/heading' ) {
- const headingAttributes =
- getBlockAttributes( blockClientId );
-
- const canBeLinked =
- typeof headingPageLink === 'string' &&
- typeof headingAttributes.anchor === 'string' &&
- headingAttributes.anchor !== '';
-
- _latestHeadings.push( {
- // Convert line breaks to spaces, and get rid of HTML tags in the headings.
- content: stripHTML(
- headingAttributes.content.replace(
- /(
)+/g,
- ' '
- )
- ),
- level: headingAttributes.level,
- link: canBeLinked
- ? `${ headingPageLink }#${ headingAttributes.anchor }`
- : null,
- } );
- }
- }
- }
-
- if ( fastDeepEqual( headings, _latestHeadings ) ) {
- return null;
- }
- return _latestHeadings;
- },
- [ clientId, onlyIncludeCurrentPage, headings ]
- );
-
- useEffect( () => {
- if ( latestHeadings !== null ) {
- // This is required to keep undo working and not create 2 undo steps
- // for each heading change.
- __unstableMarkNextChangeAsNotPersistent();
- setAttributes( { headings: latestHeadings } );
- }
- }, [ latestHeadings ] );
+ const { replaceBlocks } = useDispatch( blockEditorStore );
const headingTree = linearToNestedHeadingList( headings );
diff --git a/packages/block-library/src/table-of-contents/hooks.js b/packages/block-library/src/table-of-contents/hooks.js
new file mode 100644
index 0000000000000..af7b66568123f
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/hooks.js
@@ -0,0 +1,156 @@
+/**
+ * External dependencies
+ */
+import fastDeepEqual from 'fast-deep-equal/es6';
+
+/**
+ * WordPress dependencies
+ */
+import { useRegistry } from '@wordpress/data';
+import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
+import { useEffect } from '@wordpress/element';
+import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+
+function getLatestHeadings( select, clientId ) {
+ const {
+ getBlockAttributes,
+ getBlockName,
+ getClientIdsWithDescendants,
+ __experimentalGetGlobalBlocksByName: getGlobalBlocksByName,
+ } = select( blockEditorStore );
+
+ // FIXME: @wordpress/block-library should not depend on @wordpress/editor.
+ // Blocks can be loaded into a *non-post* block editor, so to avoid
+ // declaring @wordpress/editor as a dependency, we must access its
+ // store by string. When the store is not available, editorSelectors
+ // will be null, and the block's saved markup will lack permalinks.
+ // eslint-disable-next-line @wordpress/data-no-store-string-literals
+ const permalink = select( 'core/editor' ).getPermalink() ?? null;
+
+ const isPaginated = getGlobalBlocksByName( 'core/nextpage' ).length !== 0;
+ const { onlyIncludeCurrentPage } = getBlockAttributes( clientId ) ?? {};
+
+ // Get the client ids of all blocks in the editor.
+ const allBlockClientIds = getClientIdsWithDescendants();
+
+ // If onlyIncludeCurrentPage is true, calculate the page (of a paginated post) this block is part of, so we know which headings to include; otherwise, skip the calculation.
+ let tocPage = 1;
+
+ if ( isPaginated && onlyIncludeCurrentPage ) {
+ // We can't use getBlockIndex because it only returns the index
+ // relative to sibling blocks.
+ const tocIndex = allBlockClientIds.indexOf( clientId );
+
+ for ( const [
+ blockIndex,
+ blockClientId,
+ ] of allBlockClientIds.entries() ) {
+ // If we've reached blocks after the Table of Contents, we've
+ // finished calculating which page the block is on.
+ if ( blockIndex >= tocIndex ) {
+ break;
+ }
+ if ( getBlockName( blockClientId ) === 'core/nextpage' ) {
+ tocPage++;
+ }
+ }
+ }
+
+ const latestHeadings = [];
+
+ /** The page (of a paginated post) a heading will be part of. */
+ let headingPage = 1;
+ let headingPageLink = null;
+
+ // If the core/editor store is available, we can add permalinks to the
+ // generated table of contents.
+ if ( typeof permalink === 'string' ) {
+ headingPageLink = isPaginated
+ ? addQueryArgs( permalink, { page: headingPage } )
+ : permalink;
+ }
+
+ for ( const blockClientId of allBlockClientIds ) {
+ const blockName = getBlockName( blockClientId );
+ if ( blockName === 'core/nextpage' ) {
+ headingPage++;
+
+ // If we're only including headings from the current page (of
+ // a paginated post), then exit the loop if we've reached the
+ // pages after the one with the Table of Contents block.
+ if ( onlyIncludeCurrentPage && headingPage > tocPage ) {
+ break;
+ }
+
+ if ( typeof permalink === 'string' ) {
+ headingPageLink = addQueryArgs(
+ removeQueryArgs( permalink, [ 'page' ] ),
+ { page: headingPage }
+ );
+ }
+ }
+ // If we're including all headings or we've reached headings on
+ // the same page as the Table of Contents block, add them to the
+ // list.
+ else if ( ! onlyIncludeCurrentPage || headingPage === tocPage ) {
+ if ( blockName === 'core/heading' ) {
+ const headingAttributes = getBlockAttributes( blockClientId );
+
+ const canBeLinked =
+ typeof headingPageLink === 'string' &&
+ typeof headingAttributes.anchor === 'string' &&
+ headingAttributes.anchor !== '';
+
+ latestHeadings.push( {
+ // Convert line breaks to spaces, and get rid of HTML tags in the headings.
+ content: stripHTML(
+ headingAttributes.content.replace(
+ /(
)+/g,
+ ' '
+ )
+ ),
+ level: headingAttributes.level,
+ link: canBeLinked
+ ? `${ headingPageLink }#${ headingAttributes.anchor }`
+ : null,
+ } );
+ }
+ }
+ }
+
+ return latestHeadings;
+}
+
+function observeCallback( select, dispatch, clientId ) {
+ const { getBlockAttributes } = select( blockEditorStore );
+ const { updateBlockAttributes, __unstableMarkNextChangeAsNotPersistent } =
+ dispatch( blockEditorStore );
+
+ /**
+ * If the block no longer exists in the store, skip the update.
+ * The "undo" action recreates the block and provides a new `clientId`.
+ * The hook still might be observing the changes while the old block unmounts.
+ */
+ const attributes = getBlockAttributes( clientId );
+ if ( attributes === null ) {
+ return;
+ }
+
+ const headings = getLatestHeadings( select, clientId );
+ if ( ! fastDeepEqual( headings, attributes.headings ) ) {
+ __unstableMarkNextChangeAsNotPersistent();
+ updateBlockAttributes( clientId, { headings } );
+ }
+}
+
+export function useObserveHeadings( clientId ) {
+ const registry = useRegistry();
+ useEffect( () => {
+ // Todo: Limit subscription to block editor store when data no longer depends on `getPermalink`.
+ // See: https://github.com/WordPress/gutenberg/pull/45513
+ return registry.subscribe( () =>
+ observeCallback( registry.select, registry.dispatch, clientId )
+ );
+ }, [ registry, clientId ] );
+}
diff --git a/test/integration/fixtures/blocks/core__table-of-contents__empty.json b/test/integration/fixtures/blocks/core__table-of-contents__empty.json
index 396280b2892af..62e5d60f0ddae 100644
--- a/test/integration/fixtures/blocks/core__table-of-contents__empty.json
+++ b/test/integration/fixtures/blocks/core__table-of-contents__empty.json
@@ -3,6 +3,7 @@
"name": "core/table-of-contents",
"isValid": true,
"attributes": {
+ "headings": [],
"onlyIncludeCurrentPage": false
},
"innerBlocks": []