Skip to content

Commit

Permalink
Footnotes: Add block-level caching when parsing content for footnotes (
Browse files Browse the repository at this point in the history
  • Loading branch information
mcsf authored Sep 15, 2023
1 parent 8a43c85 commit 8da8987
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 134 deletions.
136 changes: 2 additions & 134 deletions packages/core-data/src/entity-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,17 @@ import {
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { parse, __unstableSerializeAndClean } from '@wordpress/blocks';
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';

/**
* Internal dependencies
*/
import { STORE_NAME } from './name';
import { unlock } from './private-apis';
import { updateFootnotesFromMeta } from './footnotes';

/** @typedef {import('@wordpress/blocks').WPBlock} WPBlock */

const EMPTY_ARRAY = [];

let oldFootnotes = {};

/**
* Internal dependencies
*/
Expand Down Expand Up @@ -182,136 +179,7 @@ export function useEntityBlockEditor( kind, name, { id: _id } = {} ) {
}, [ editedBlocks, content ] );

const updateFootnotes = useCallback(
( _blocks ) => {
const output = { blocks: _blocks };
if ( ! meta ) return output;
// If meta.footnotes is empty, it means the meta is not registered.
if ( meta.footnotes === undefined ) return output;

const { getRichTextValues } = unlock( blockEditorPrivateApis );
const _content = getRichTextValues( _blocks ).join( '' ) || '';
const newOrder = [];

// This can be avoided when
// https://github.com/WordPress/gutenberg/pull/43204 lands. We can then
// get the order directly from the rich text values.
if ( _content.indexOf( 'data-fn' ) !== -1 ) {
const regex = /data-fn="([^"]+)"/g;
let match;
while ( ( match = regex.exec( _content ) ) !== null ) {
newOrder.push( match[ 1 ] );
}
}

const footnotes = meta.footnotes
? JSON.parse( meta.footnotes )
: [];
const currentOrder = footnotes.map( ( fn ) => fn.id );

if ( currentOrder.join( '' ) === newOrder.join( '' ) )
return output;

const newFootnotes = newOrder.map(
( fnId ) =>
footnotes.find( ( fn ) => fn.id === fnId ) ||
oldFootnotes[ fnId ] || {
id: fnId,
content: '',
}
);

function updateAttributes( attributes ) {
// Only attempt to update attributes, if attributes is an object.
if (
! attributes ||
Array.isArray( attributes ) ||
typeof attributes !== 'object'
) {
return attributes;
}

attributes = { ...attributes };

for ( const key in attributes ) {
const value = attributes[ key ];

if ( Array.isArray( value ) ) {
attributes[ key ] = value.map( updateAttributes );
continue;
}

if ( typeof value !== 'string' ) {
continue;
}

if ( value.indexOf( 'data-fn' ) === -1 ) {
continue;
}

// When we store rich text values, this would no longer
// require a regex.
const regex =
/(<sup[^>]+data-fn="([^"]+)"[^>]*><a[^>]*>)[\d*]*<\/a><\/sup>/g;

attributes[ key ] = value.replace(
regex,
( match, opening, fnId ) => {
const index = newOrder.indexOf( fnId );
return `${ opening }${ index + 1 }</a></sup>`;
}
);

const compatRegex =
/<a[^>]+data-fn="([^"]+)"[^>]*>\*<\/a>/g;

attributes[ key ] = attributes[ key ].replace(
compatRegex,
( match, fnId ) => {
const index = newOrder.indexOf( fnId );
return `<sup data-fn="${ fnId }" class="fn"><a href="#${ fnId }" id="${ fnId }-link">${
index + 1
}</a></sup>`;
}
);
}

return attributes;
}

function updateBlocksAttributes( __blocks ) {
return __blocks.map( ( block ) => {
return {
...block,
attributes: updateAttributes( block.attributes ),
innerBlocks: updateBlocksAttributes(
block.innerBlocks
),
};
} );
}

// We need to go through all block attributes deeply and update the
// footnote anchor numbering (textContent) to match the new order.
const newBlocks = updateBlocksAttributes( _blocks );

oldFootnotes = {
...oldFootnotes,
...footnotes.reduce( ( acc, fn ) => {
if ( ! newOrder.includes( fn.id ) ) {
acc[ fn.id ] = fn;
}
return acc;
}, {} ),
};

return {
meta: {
...meta,
footnotes: JSON.stringify( newFootnotes ),
},
blocks: newBlocks,
};
},
( _blocks ) => updateFootnotesFromMeta( _blocks, meta ),
[ meta ]
);

Expand Down
30 changes: 30 additions & 0 deletions packages/core-data/src/footnotes/get-footnotes-order.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Internal dependencies
*/
import getRichTextValuesCached from './get-rich-text-values-cached';

const cache = new WeakMap();

function getBlockFootnotesOrder( block ) {
if ( ! cache.has( block ) ) {
const content = getRichTextValuesCached( block ).join( '' );
const newOrder = [];

// https://github.com/WordPress/gutenberg/pull/43204 lands. We can then
// get the order directly from the rich text values.
if ( content.indexOf( 'data-fn' ) !== -1 ) {
const regex = /data-fn="([^"]+)"/g;
let match;
while ( ( match = regex.exec( content ) ) !== null ) {
newOrder.push( match[ 1 ] );
}
}
cache.set( block, newOrder );
}

return cache.get( block );
}

export default function getFootnotesOrder( blocks ) {
return blocks.flatMap( getBlockFootnotesOrder );
}
35 changes: 35 additions & 0 deletions packages/core-data/src/footnotes/get-rich-text-values-cached.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* WordPress dependencies
*/
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';

/**
* Internal dependencies
*/
import { unlock } from '../private-apis';

// TODO: The following line should have been:
//
// const unlockedApis = unlock( blockEditorPrivateApis );
//
// But there are hidden circular dependencies in RNMobile code, specifically in
// certain native components in the `components` package that depend on
// `block-editor`. What follows is a workaround that defers the `unlock` call
// to prevent native code from failing.
//
// Fix once https://github.com/WordPress/gutenberg/issues/52692 is closed.
let unlockedApis;

const cache = new WeakMap();

export default function getRichTextValuesCached( block ) {
if ( ! unlockedApis ) {
unlockedApis = unlock( blockEditorPrivateApis );
}

if ( ! cache.has( block ) ) {
const values = unlockedApis.getRichTextValues( [ block ] );
cache.set( block, values );
}
return cache.get( block );
}
119 changes: 119 additions & 0 deletions packages/core-data/src/footnotes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Internal dependencies
*/
import getFootnotesOrder from './get-footnotes-order';

let oldFootnotes = {};

export function updateFootnotesFromMeta( blocks, meta ) {
const output = { blocks };
if ( ! meta ) return output;

// If meta.footnotes is empty, it means the meta is not registered.
if ( meta.footnotes === undefined ) return output;

const newOrder = getFootnotesOrder( blocks );

const footnotes = meta.footnotes ? JSON.parse( meta.footnotes ) : [];
const currentOrder = footnotes.map( ( fn ) => fn.id );

if ( currentOrder.join( '' ) === newOrder.join( '' ) ) return output;

const newFootnotes = newOrder.map(
( fnId ) =>
footnotes.find( ( fn ) => fn.id === fnId ) ||
oldFootnotes[ fnId ] || {
id: fnId,
content: '',
}
);

function updateAttributes( attributes ) {
// Only attempt to update attributes, if attributes is an object.
if (
! attributes ||
Array.isArray( attributes ) ||
typeof attributes !== 'object'
) {
return attributes;
}

attributes = { ...attributes };

for ( const key in attributes ) {
const value = attributes[ key ];

if ( Array.isArray( value ) ) {
attributes[ key ] = value.map( updateAttributes );
continue;
}

if ( typeof value !== 'string' ) {
continue;
}

if ( value.indexOf( 'data-fn' ) === -1 ) {
continue;
}

// When we store rich text values, this would no longer
// require a regex.
const regex =
/(<sup[^>]+data-fn="([^"]+)"[^>]*><a[^>]*>)[\d*]*<\/a><\/sup>/g;

attributes[ key ] = value.replace(
regex,
( match, opening, fnId ) => {
const index = newOrder.indexOf( fnId );
return `${ opening }${ index + 1 }</a></sup>`;
}
);

const compatRegex = /<a[^>]+data-fn="([^"]+)"[^>]*>\*<\/a>/g;

attributes[ key ] = attributes[ key ].replace(
compatRegex,
( match, fnId ) => {
const index = newOrder.indexOf( fnId );
return `<sup data-fn="${ fnId }" class="fn"><a href="#${ fnId }" id="${ fnId }-link">${
index + 1
}</a></sup>`;
}
);
}

return attributes;
}

function updateBlocksAttributes( __blocks ) {
return __blocks.map( ( block ) => {
return {
...block,
attributes: updateAttributes( block.attributes ),
innerBlocks: updateBlocksAttributes( block.innerBlocks ),
};
} );
}

// We need to go through all block attributes deeply and update the
// footnote anchor numbering (textContent) to match the new order.
const newBlocks = updateBlocksAttributes( blocks );

oldFootnotes = {
...oldFootnotes,
...footnotes.reduce( ( acc, fn ) => {
if ( ! newOrder.includes( fn.id ) ) {
acc[ fn.id ] = fn;
}
return acc;
}, {} ),
};

return {
meta: {
...meta,
footnotes: JSON.stringify( newFootnotes ),
},
blocks: newBlocks,
};
}

1 comment on commit 8da8987

@github-actions
Copy link

Choose a reason for hiding this comment

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

Flaky tests detected in 8da8987.
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.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/6197215955
📝 Reported issues:

Please sign in to comment.