Skip to content

DataViews: remove all private API usage #81230

Closed

Description

@youknowriad

Supersedes #76100

@wordpress/dataviews is a bundled package — it declares neither wpScript nor wpModuleExports in its package.json. Per AGENTS.md:

Avoid using private APIs in bundled packages (packages without wpScript or wpModuleExports). Private APIs are intended for Core usage; bundled packages may also be imported via npm into plugin scripts, causing incompatibilities.

Concretely: lockedData is a module-scoped WeakMap and __private is a plain Symbol() — not Symbol.for() (implementation.ts#L187-L193). Two copies of @wordpress/private-apis in one runtime cannot unlock each other's objects, so a plugin that loads wp.components from the WP global alongside a bundled DataViews throws Cannot unlock an object that was not locked before at module-eval time, before anything renders.

Secondary cost: unlock() is typed <T = any>( object: unknown ): T, so every unlocked symbol lands as any. All 23 call sites below are effectively unchecked by TypeScript — swapping any of them for a public equivalent tends to surface pre-existing type mismatches.

@wordpress/components

18 of the 25 symbols in components/src/private-apis.ts, across 21 production files:

Symbol Where Why it's private
Menu add-filter.tsx:22, dataviews-view-config/index.tsx:37, dataviews-item-actions/index.tsx:27, column-header-menu.tsx:33, list/index.tsx:62 Ariakit-based menu; no public replacement exists yet — @wordpress/ui has no menu
Badge picker-grid/index.tsx:36, composite-grid.tsx:45 A public Badge already ships in @wordpress/ui and DataViews uses it in validation-badge.tsx — this one is a stale holdout
kebabCase dataviews-item-actions/index.tsx:27 String util, private to avoid committing to a public utils surface
DateCalendar, DateRangeCalendar date.tsx:57, datetime.tsx:24 New calendar components still stabilizing
withIgnoreIMEEvents richtext/control.tsx:47 IME keydown wrapper
Validated* × 12 one per control in dataform-controls/ — InputControl, Checkbox, Combobox, Number, Select, Radio, ContentEditable, Textarea, Toggle, ToggleGroup, FormTokenField (+Text in a story) The whole validated-form-controls layer is private; DataForm is its only real consumer

@wordpress/rich-text

5 of the 9 symbols in rich-text/src/private-apis.js, all in one file — dataform-controls/richtext/control.tsx:

Symbol Where Why it's private
useRichText richtext/control.tsx:61 Core editing hook; the public surface is RichText in @wordpress/block-editor, which DataViews can't depend on
KeyboardShortcutContext, InputEventContext richtext/control.tsx:61 The same context objects that format types' RichTextShortcut / RichTextInputEvent read
shortcutsListener, inputEventsListener richtext/control.tsx:61 Dispatch the registered callbacks from the editable element's own events

Exposed by DataViews

Symbol Where Notes
RichTextControl locked in dataviews/src/private-apis.ts, unlocked by editor note-form.js:28 DataViews' own private export — one symbol, one consumer. Needs to become a public export or move to the consumer

Todo

Activity

  1. changed the title [-]DataViews: remove `@wordpress/components` private API usage[/-] [+]DataViews: remove all private API usage[/+] on Aug 5, 2026
  2. ciampo commented on Aug 5, 2026

    @ciampo
    Contributor

    @wordpress/ui will soon have a Menu component :) #79560.

  3. ntsekouras commented on Aug 7, 2026

    @ntsekouras
    Contributor

    DateCalendar / DateRangeCalendar (2 sites) — blocked: still stabilizing in @wordpress/components.

    Would it make sense to just copy for now in DataViews package since it's the sole consumer of them, and when ready to stabilize move to ui package? --cc @WordPress/gutenberg-components

  4. 72 remaining items

  5. oandregal commented on Aug 24, 2026

    @oandregal
    Member

    #81984 adds the ValidatedTextArea control to wordpress/ui and removes the last private API instance from DataViews.

  6. oandregal commented on Aug 24, 2026

    @oandregal
    Member

    Every private API that DataViews used has been removed. 🥳 Thanks everyone for lending a hand to getting this done in 3 weeks.

  7. youknowriad commented on Aug 24, 2026

    @youknowriad
    ContributorAuthor

    Awesome, let's continue the collaboration on potential shortcuts we took to get here.

  8. manzoorwanijk commented on Aug 25, 2026

    @manzoorwanijk
    Member

    Doesn’t this mean that we no longer need the /wp export for the package?

  9. oandregal commented on Aug 25, 2026

    @oandregal
    Member

    Doesn’t this mean that we no longer need the /wp export for the package?

    The /wp bundle is still necessary for consumers wanting to support different WordPress versions / combinations (Gutenberg enabled/disabled).

    The issue with using the regular bundle via wordpress/scripts is that some packages are externalized (@wordpress/components becomes wp.components). The reason this is problematic is that wp.components is defined by the platform (WordPress version, Gutenberg, etc.). Different platforms may have different wp.components versions, but a single DataViews version requires at least the wp.components version it was built against. [1]

    For example:

    • A NewComponent is added to @wordpress/components.
    • DataViews starts using it.
    • A WordPress X version with the component is released.
    • If a consumer that uses the latest DataViews with the regular bundle needs to support the current WordPress X version and the one before (X-1), there's an issue: the X-1 platform doesn't yet have the wp.components.NewComponent. [2]

    The same can happen for updates/removals that affect any wp.* global used by DataViews. Arguably, public API drift is very rare, but adding new globals can still be problematic (example). By design, private APIs could change at will, so they were more problematic. Now that they're gone, I expect more stability for consumers of the regular bundle as well.


    [1] I use wp.components as an example, but the same will happen for any package that is externalized.
    [2] For the sake of brevity, I used major WordPress versions as an example, but the issue can be reproduced with Gutenberg versions. Imagine a consumer that uses the latest npm DataViews package: it may have an error for all platforms that don't have the Gutenberg plugin enabled (because the changes weren't yet published to any major WordPress version).

  10. ciampo commented on Aug 25, 2026

    @ciampo
    Contributor

    I think we may have to consider consuming APIs conditionally and enabling features depending on the availability of such APIs?

  11. oandregal commented on Aug 25, 2026

    @oandregal
    Member

    I think we may have to consider consuming APIs conditionally and enabling features depending on the availability of such APIs?

    That's neat. Though, in practice, we'd need to evaluate how feasible this is (we need to understand all the wp.* globals in use by bundled packages and have mechanisms that tell us about them changing so we can cover).

  12. manzoorwanijk commented on Aug 25, 2026

    @manzoorwanijk
    Member

    The /wp bundle is still necessary for consumers wanting to support different WordPress versions / combinations (Gutenberg enabled/disabled).

    That means #81843 (comment) is not true and we need those dependencies to be declared.

    CC: @youknowriad

  13. ciampo commented on Aug 25, 2026

    @ciampo
    Contributor

    Yeah. At the same time, it's potentially very limiting to have dataviews require the same or a later version of their dependencies. In practice, consumers of dataviews will never want to be on latest version in case they support older WordPress versions?

  14. oandregal commented on Aug 25, 2026

    @oandregal
    Member

    I threw some tokens to create a script that reported the wp global in use by a package:

    list-wp-globals.js
    #!/usr/bin/env node
    /**
     * Lists the `wp.*` globals a package relies on when built with
     * `@wordpress/dependency-extraction-webpack-plugin`, by scanning its source
     * for import specifiers and applying the plugin's own externalization rules.
     *
     * Usage:
     *   node bin/list-wp-globals.js <packageDir> [--transitive] [--json] [--files]
     *
     *   packageDir     Path to a package, e.g. packages/dataviews.
     *   --transitive   Also follow bundled (non-externalized) @wordpress packages
     *                  such as @wordpress/ui or @wordpress/icons, which are inlined
     *                  into the bundle and therefore pull in their own externals.
     *   --json         Output machine-readable JSON.
     *   --files        List the files each global is imported from.
     */
    
    const fs = require( 'fs' );
    const path = require( 'path' );
    const {
    	defaultRequestToExternal,
    } = require( '../packages/dependency-extraction-webpack-plugin/lib/util' );
    
    const ROOT = path.resolve( __dirname, '..' );
    const SOURCE_EXTENSIONS = new Set( [ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs' ] );
    const IGNORED_DIR = /(^|\/)(test|tests|__tests__|stories|__mocks__|node_modules|build[a-z-]*)(\/|$)/;
    const IGNORED_FILE = /\.(test|spec|stories)\.[jt]sx?$|\.d\.ts$/;
    
    // Static and dynamic imports, re-exports and require() calls.
    const SPECIFIER_PATTERNS = [
    	/\b(?:import|export)\b[^'"`]*?\bfrom\s*['"]([^'"]+)['"]/g,
    	/\bimport\s*['"]([^'"]+)['"]/g,
    	/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
    	/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
    ];
    
    function collectSourceFiles( dir, files = [] ) {
    	for ( const entry of fs.readdirSync( dir, { withFileTypes: true } ) ) {
    		const fullPath = path.join( dir, entry.name );
    		const relative = path.relative( ROOT, fullPath );
    		if ( entry.isDirectory() ) {
    			if ( ! IGNORED_DIR.test( relative + '/' ) ) {
    				collectSourceFiles( fullPath, files );
    			}
    		} else if (
    			SOURCE_EXTENSIONS.has( path.extname( entry.name ) ) &&
    			! IGNORED_FILE.test( entry.name )
    		) {
    			files.push( fullPath );
    		}
    	}
    	return files;
    }
    
    function extractSpecifiers( source ) {
    	const specifiers = new Set();
    	for ( const pattern of SPECIFIER_PATTERNS ) {
    		for ( const match of source.matchAll( pattern ) ) {
    			specifiers.add( match[ 1 ] );
    		}
    	}
    	return specifiers;
    }
    
    function isPackageRequest( specifier ) {
    	return ! specifier.startsWith( '.' ) && ! specifier.startsWith( '/' );
    }
    
    function analyze( packageDir, { transitive } ) {
    	const globals = new Map(); // global name -> { request, sources: Map<pkg, Set<file>> }
    	const bundled = new Map(); // request -> Set of files
    	const visited = new Set();
    	const rootPackage = readPackageName( path.resolve( ROOT, packageDir ) );
    	const queue = [ { dir: packageDir, pkg: rootPackage } ];
    
    	while ( queue.length ) {
    		const { dir: nextDir, pkg: currentPackage } = queue.shift();
    		const dir = path.resolve( ROOT, nextDir );
    		if ( visited.has( dir ) ) {
    			continue;
    		}
    		visited.add( dir );
    
    		const srcDir = fs.existsSync( path.join( dir, 'src' ) )
    			? path.join( dir, 'src' )
    			: dir;
    
    		for ( const file of collectSourceFiles( srcDir ) ) {
    			const relativeFile = path.relative( ROOT, file );
    			const source = fs.readFileSync( file, 'utf8' );
    			for ( const specifier of extractSpecifiers( source ) ) {
    				if ( ! isPackageRequest( specifier ) ) {
    					continue;
    				}
    				const external = defaultRequestToExternal( specifier );
    				if ( Array.isArray( external ) && external[ 0 ] === 'wp' ) {
    					const name = external.join( '.' );
    					if ( ! globals.has( name ) ) {
    						globals.set( name, { request: specifier, sources: new Map() } );
    					}
    					const { sources } = globals.get( name );
    					if ( ! sources.has( currentPackage ) ) {
    						sources.set( currentPackage, new Set() );
    					}
    					sources.get( currentPackage ).add( relativeFile );
    				} else if ( specifier.startsWith( '@wordpress/' ) ) {
    					const pkg = specifier.split( '/' ).slice( 0, 2 ).join( '/' );
    					if ( ! bundled.has( pkg ) ) {
    						bundled.set( pkg, new Set() );
    					}
    					bundled.get( pkg ).add( relativeFile );
    					if ( transitive ) {
    						queue.push( {
    							dir: path.join( 'packages', pkg.replace( '@wordpress/', '' ) ),
    							pkg,
    						} );
    					}
    				}
    			}
    		}
    	}
    
    	return { globals, bundled, visited, rootPackage };
    }
    
    function readPackageName( dir ) {
    	try {
    		return JSON.parse( fs.readFileSync( path.join( dir, 'package.json' ), 'utf8' ) ).name;
    	} catch {
    		return path.relative( ROOT, dir );
    	}
    }
    
    function main() {
    	const args = process.argv.slice( 2 );
    	const flags = new Set( args.filter( ( a ) => a.startsWith( '--' ) ) );
    	const packageDir = args.find( ( a ) => ! a.startsWith( '--' ) );
    	if ( ! packageDir ) {
    		console.error(
    			'Usage: node bin/list-wp-globals.js <packageDir> [--transitive] [--json] [--files]'
    		);
    		process.exit( 1 );
    	}
    	const transitive = flags.has( '--transitive' );
    
    	if ( ! fs.existsSync( path.resolve( ROOT, packageDir ) ) ) {
    		console.error( `Package directory not found: ${ packageDir }` );
    		process.exit( 1 );
    	}
    
    	const { globals, bundled, visited, rootPackage } = analyze( packageDir, { transitive } );
    	const sortedGlobals = [ ...globals.keys() ].sort();
    	const direct = sortedGlobals.filter( ( g ) => globals.get( g ).sources.has( rootPackage ) );
    	const transitiveOnly = sortedGlobals.filter( ( g ) => ! globals.get( g ).sources.has( rootPackage ) );
    	const viaPackages = ( name ) =>
    		[ ...globals.get( name ).sources.keys() ].filter( ( p ) => p !== rootPackage ).sort();
    	const fileCount = ( name, pkg ) => globals.get( name ).sources.get( pkg ).size;
    
    	if ( flags.has( '--json' ) ) {
    		console.log(
    			JSON.stringify(
    				{
    					package: packageDir,
    					transitive,
    					direct: Object.fromEntries(
    						direct.map( ( g ) => [
    							g,
    							{
    								request: globals.get( g ).request,
    								files: [ ...globals.get( g ).sources.get( rootPackage ) ].sort(),
    								alsoVia: viaPackages( g ),
    							},
    						] )
    					),
    					transitiveGlobals: Object.fromEntries(
    						transitiveOnly.map( ( g ) => [
    							g,
    							{
    								request: globals.get( g ).request,
    								via: Object.fromEntries(
    									viaPackages( g ).map( ( p ) => [
    										p,
    										[ ...globals.get( g ).sources.get( p ) ].sort(),
    									] )
    								),
    							},
    						] )
    					),
    					bundled: Object.fromEntries(
    						[ ...bundled.keys() ].sort().map( ( p ) => [ p, [ ...bundled.get( p ) ].sort() ] )
    					),
    				},
    				null,
    				2
    			)
    		);
    		return;
    	}
    
    	const printFiles = ( files ) => {
    		if ( flags.has( '--files' ) ) {
    			for ( const file of [ ...files ].sort() ) {
    				console.log( `      ${ file }` );
    			}
    		}
    	};
    
    	const scanned = [ ...visited ].map( ( d ) => path.relative( ROOT, d ) ).join( ', ' );
    	console.log( `Scanned: ${ scanned }\n` );
    
    	console.log( `Direct wp.* globals used by ${ rootPackage } (${ direct.length }):` );
    	for ( const name of direct ) {
    		const { request, sources } = globals.get( name );
    		const also = viaPackages( name );
    		const alsoText = also.length ? `; also via ${ also.join( ', ' ) }` : '';
    		console.log(
    			`  ${ name.padEnd( 20 ) } <- ${ request } (${ fileCount( name, rootPackage ) } files${ alsoText })`
    		);
    		printFiles( sources.get( rootPackage ) );
    	}
    	console.log();
    
    	if ( transitive ) {
    		console.log( `Transitive wp.* globals, via bundled packages (${ transitiveOnly.length }):` );
    		for ( const name of transitiveOnly ) {
    			const { request } = globals.get( name );
    			const via = viaPackages( name )
    				.map( ( p ) => `${ p } (${ fileCount( name, p ) } files)` )
    				.join( ', ' );
    			console.log( `  ${ name.padEnd( 20 ) } <- ${ request } via ${ via }` );
    			for ( const p of viaPackages( name ) ) {
    				printFiles( globals.get( name ).sources.get( p ) );
    			}
    		}
    		console.log();
    	}
    
    	const bundledNames = [ ...bundled.keys() ].sort();
    	console.log(
    		`Bundled @wordpress packages (inlined, not externalized${ transitive ? ', followed' : ', use --transitive to follow' }) (${ bundledNames.length }):`
    	);
    	for ( const pkg of bundledNames ) {
    		console.log( `  ${ pkg } (${ bundled.get( pkg ).size } files)` );
    	}
    }
    
    main();

    FWIW, without having actually checked that there's no under/over-reporting, I ran the script (list-wp-globals.js packages/dataviews --transitive). Quickly checked by searching, and it seems on a good track (roughly what I expected):

    Scanned: packages/dataviews, packages/ui, packages/icons, packages/kebab-case
    
    Direct wp.* globals used by @wordpress/dataviews (10):
      wp.a11y              <- @wordpress/a11y (4 files; also via @wordpress/ui)
      wp.components        <- @wordpress/components (55 files)
      wp.compose           <- @wordpress/compose (26 files; also via @wordpress/ui)
      wp.data              <- @wordpress/data (5 files)
      wp.date              <- @wordpress/date (8 files)
      wp.deprecated        <- @wordpress/deprecated (1 files)
      wp.element           <- @wordpress/element (82 files; also via @wordpress/icons, @wordpress/ui)
      wp.i18n              <- @wordpress/i18n (52 files; also via @wordpress/ui)
      wp.keycodes          <- @wordpress/keycodes (2 files)
      wp.warning           <- @wordpress/warning (1 files; also via @wordpress/ui)
    
    Transitive wp.* globals, via bundled packages (3):
      wp.primitives        <- @wordpress/primitives via @wordpress/icons (342 files), @wordpress/ui (2 files)
      wp.privateApis       <- @wordpress/private-apis via @wordpress/ui (1 files)
      wp.theme             <- @wordpress/theme via @wordpress/ui (3 files)
    
    Bundled @wordpress packages (inlined, not externalized, followed) (3):
      @wordpress/icons (39 files)
      @wordpress/kebab-case (1 files)
      @wordpress/ui (56 files)
  15. youknowriad commented on Aug 25, 2026

    @youknowriad
    ContributorAuthor

    @manzoorwanijk Indeed, I didn't consider previous WP versions support when I made that comment.

    @oandregal Why is private-apis still used by bundled packages?

    I'm also curious what's the current size difference between /wp bundle and a regular dataviews bundle. Assuming tree shaking is working properly, the extra globals seems small (maybe outside wp.components where we should probably try to migrate to wordpress/ui as much as possible)

  16. oandregal commented on Aug 25, 2026

    @oandregal
    Member

    I took a look at Manzoor's reference, and I understand the issue is that we copy all wordpress dependencies into the dataviews package (all but a few -date,data, i18n, hooks- that are singleton). This means transitive dependencies become actual dataviews dependencies that should be listed in dataviews' package.json.

    I am still looking at this, but I wonder: now that the private APIs are removed, is it possible stop copying these wordpress deps into dataviews and use them as npm packages (no wp globals)? This would stop demanding we add those transitive dependencies. Though I suppose we may have issues with transitive private dependencies. Is it an actual issue in practice? I'm learning and looking into this, happy to hear what folks think.

  17. youknowriad commented on Aug 25, 2026

    @youknowriad
    ContributorAuthor

    I am still looking at this, but I wonder: now that the private APIs are removed, is it possible stop copying these wordpress deps into dataviews and use them as npm packages (no wp globals)?

    How is this different than the /wp bundle?

    To clarify my question, the /wp bundle exist to do this basically. Bundlers (like wp.scripts) can't say "bundle all the dependencies of this particular package aka dataviews but externalize the dependencies of this other script (say a custom third party block using wordpress/components) and this is why we offer a pre-bundled /wp

  18. oandregal commented on Aug 31, 2026

    @oandregal
    Member

    For awareness, we reverted the removal of dataviews from the private-api package allowlist, as that prevents older dataviews versions from accessing private APIs: #82221 (it has been backported to Gutenberg 23.9).

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions