Skip to content

Commit 2e7ab91

Browse files
authored
[Docs] Fix API reference (#2905)
## Motivation for the change, related issues Resolves a crash in the API reference that happened after the initial page hydration: <img width="2138" height="1232" alt="CleanShot 2025-11-14 at 15 03 22@2x" src="https://github.com/user-attachments/assets/2602a5e8-29e3-4342-aefc-b52b0b858bad" /> It also ships E2E tests just for the documentation site. ## Implementation details ### Problem 1 – CommonJS bundles from the TypeDoc plugin `docusaurus-plugin-typedoc-api` ships its React components as CommonJS (module.exports = ApiPage). When Docusaurus’ ComponentCreator hydrates a page, it copies every enumerable export from the module onto the component. CommonJS functions still expose length, which is non‑writable, so the assignment throws `Cannot assign to read only property 'length'`. Solution: Our wrapper plugin (`packages/docs/site/plugins/typedoc-api-wrapper.js`) intercepts the routes the TypeDoc plugin adds and swaps each component path for an ESM wrapper in `packages/docs/site/src/typedoc/*.tsx`. An ESM re‑export only exposes default, so ComponentCreator never tries to overwrite length and hydration succeeds. ### Problem 2 – duplicate Docusaurus contexts The TypeDoc plugin pulls its own copy of @docusaurus/plugin-content-docs (and friends). Webpack bundles that second copy, so components inside the API reference end up reading from a different React context than the one the rest of the site provides, leading to `Hook useDocsPreferredVersionContext is called outside…` errors. Solution: `packages/docs/site/plugins/docusaurus-dedupe-aliases.js` walks each package’s exports map and tells Webpack to resolve every `@docusaurus/*` import to the single, hoisted version. With only one copy of the docs plugin, the contexts align and DocSearch/TypeDoc stop crashing. ## Testing Instructions (or ideally a Blueprint) This PR adds a dedicated Playwright config and test that builds the docs site, serves the static bundle, loads /wordpress-playground/api, and fails if hydration throws page or console errors. A new Nx target and CI job run the smoke test with Chromium so every PR proves the API docs render before we deploy.
1 parent 2ec9494 commit 2e7ab91

File tree

13 files changed

+365
-1022
lines changed

13 files changed

+365
-1022
lines changed

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,19 @@ jobs:
169169
test-e2e-playwright-prepare:
170170
runs-on: ubuntu-latest
171171
steps:
172+
- name: Free up runner disk space
173+
shell: bash
174+
run: |
175+
set -euo pipefail
176+
echo "Disk usage before cleanup:"
177+
df -h
178+
sudo rm -rf /usr/local/lib/android
179+
sudo rm -rf /usr/share/dotnet
180+
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
181+
sudo rm -rf /opt/ghc
182+
sudo rm -rf /opt/hostedtoolcache/CodeQL
183+
echo "Disk usage after cleanup:"
184+
df -h
172185
- uses: actions/checkout@v4
173186
with:
174187
submodules: true
@@ -254,6 +267,18 @@ jobs:
254267
path: packages/playground/components/playwright-report/
255268
if-no-files-found: ignore
256269

270+
test-docs-api-reference:
271+
runs-on: ubuntu-latest
272+
steps:
273+
- uses: actions/checkout@v4
274+
with:
275+
submodules: true
276+
- uses: ./.github/actions/prepare-playground
277+
- name: Install Playwright Browsers
278+
run: npx playwright install --with-deps chromium
279+
- name: Verify docs API reference
280+
run: npx nx run docs-site:api-e2e
281+
257282
test-built-npm-packages:
258283
runs-on: ubuntu-latest
259284
steps:

package-lock.json

Lines changed: 3 additions & 1016 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
"@codemirror/search": "6.5.11",
8080
"@codemirror/state": "6.5.2",
8181
"@codemirror/view": "6.38.3",
82+
"@docusaurus/plugin-content-docs": "3.9.2",
83+
"@docusaurus/theme-common": "3.9.2",
8284
"@preact/signals-react": "1.3.6",
8385
"@reduxjs/toolkit": "2.6.1",
8486
"@types/xml2js": "0.4.14",

packages/docs/site/docusaurus.config.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,7 @@ const config = {
2525
projectName: 'wordpress-playground', // Usually your repo name.
2626

2727
onBrokenLinks: 'throw',
28-
markdown: {
29-
hooks: {
30-
onBrokenMarkdownLinks: 'throw',
31-
},
32-
},
28+
onBrokenMarkdownLinks: 'throw',
3329

3430
// Even if you don't use internalization, you can use this field to set useful
3531
// metadata like HTML lang. For example, if your site is Chinese, you may want
@@ -71,6 +67,7 @@ const config = {
7167
},
7268
themes: ['@docusaurus/theme-live-codeblock'],
7369
plugins: [
70+
'./plugins/docusaurus-dedupe-aliases.js',
7471
getDocusaurusPluginTypedocApiConfig(),
7572
[
7673
'@docusaurus/plugin-ideal-image',
@@ -266,7 +263,7 @@ function getDocusaurusPluginTypedocApiConfig() {
266263
};
267264

268265
return [
269-
'docusaurus-plugin-typedoc-api',
266+
require.resolve('./plugins/typedoc-api-wrapper.js'),
270267
{
271268
projectRoot,
272269
packages,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { defineConfig } from '@playwright/test';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
const port = process.env.DOCS_E2E_PORT ?? '4173';
6+
const host = process.env.DOCS_E2E_HOST ?? '127.0.0.1';
7+
const baseUrl = process.env.DOCS_E2E_BASE_URL ?? `http://${host}:${port}`;
8+
const rawBasePath = process.env.DOCS_E2E_BASE_PATH ?? '/wordpress-playground';
9+
const normalizedBasePath =
10+
rawBasePath === '/' ? '/' : `/${rawBasePath.replace(/^\/|\/$/g, '')}`;
11+
const healthPath =
12+
process.env.DOCS_E2E_HEALTH_PATH ?? `${normalizedBasePath}/index.html`;
13+
const repoRoot =
14+
process.env.DOCS_E2E_REPO_ROOT ?? path.resolve(__dirname, '../../..');
15+
const buildDir =
16+
process.env.DOCS_E2E_BUILD_DIR ?? path.join(repoRoot, 'dist/docs/build');
17+
18+
const mountDir =
19+
normalizedBasePath === '/'
20+
? null
21+
: path.join(buildDir, normalizedBasePath.replace(/^\//, ''));
22+
23+
if (mountDir && fs.existsSync(buildDir) && !fs.existsSync(mountDir)) {
24+
try {
25+
fs.symlinkSync(buildDir, mountDir, 'junction');
26+
} catch (error) {
27+
// eslint-disable-next-line no-console
28+
console.warn(
29+
`docs-site e2e: failed to create symlink for base path ${normalizedBasePath}`,
30+
error
31+
);
32+
}
33+
}
34+
35+
export default defineConfig({
36+
testDir: './tests',
37+
timeout: 60_000,
38+
expect: {
39+
timeout: 10_000,
40+
},
41+
use: {
42+
baseURL: baseUrl,
43+
trace: 'retain-on-failure',
44+
screenshot: 'only-on-failure',
45+
video: 'retain-on-failure',
46+
},
47+
webServer: {
48+
command: `npx http-server "${buildDir}" -p ${port} -a ${host} -c-1`,
49+
url: `${baseUrl}${healthPath}`,
50+
reuseExistingServer: !process.env.CI,
51+
timeout: 120_000,
52+
},
53+
projects: [
54+
{
55+
name: 'chromium',
56+
use: { browserName: 'chromium' },
57+
},
58+
],
59+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const moduleRequire = require('module').createRequire(__dirname);
4+
5+
const PACKAGES_TO_DEDUPE = [
6+
'@docusaurus/plugin-content-docs',
7+
'@docusaurus/theme-common',
8+
'@docusaurus/theme-search-algolia',
9+
];
10+
11+
function getExportKeys(exportsField) {
12+
if (!exportsField) {
13+
return ['.'];
14+
}
15+
16+
if (typeof exportsField === 'string') {
17+
return ['.'];
18+
}
19+
20+
if (Array.isArray(exportsField)) {
21+
return ['.'];
22+
}
23+
24+
if (typeof exportsField === 'object') {
25+
const keys = new Set(['.']);
26+
for (const key of Object.keys(exportsField)) {
27+
if (key === 'default' || key.includes('*')) {
28+
continue;
29+
}
30+
keys.add(key);
31+
}
32+
return Array.from(keys);
33+
}
34+
35+
return ['.'];
36+
}
37+
38+
function normalizeSubpath(pkgName, subpath) {
39+
if (subpath === '.' || !subpath) {
40+
return pkgName;
41+
}
42+
return `${pkgName}/${subpath.replace(/^\.\//, '')}`;
43+
}
44+
45+
function resolvePackageMetadata(pkgName) {
46+
let entryPoint;
47+
try {
48+
entryPoint = moduleRequire.resolve(pkgName);
49+
} catch (error) {
50+
console.warn(
51+
`docusaurus-dedupe-aliases: unable to resolve entry for ${pkgName}`,
52+
error
53+
);
54+
return null;
55+
}
56+
57+
let currentDir = path.dirname(entryPoint);
58+
const fsRoot = path.parse(currentDir).root;
59+
60+
while (currentDir && currentDir !== fsRoot) {
61+
const candidate = path.join(currentDir, 'package.json');
62+
if (fs.existsSync(candidate)) {
63+
const pkgJson = JSON.parse(fs.readFileSync(candidate, 'utf8'));
64+
if (pkgJson?.name === pkgName) {
65+
return { pkgJson, pkgRoot: currentDir };
66+
}
67+
}
68+
currentDir = path.dirname(currentDir);
69+
}
70+
71+
console.warn(
72+
`docusaurus-dedupe-aliases: could not locate package.json for ${pkgName}`
73+
);
74+
return null;
75+
}
76+
77+
function buildAliasesForPackage(pkgName) {
78+
const metadata = resolvePackageMetadata(pkgName);
79+
if (!metadata) {
80+
return [];
81+
}
82+
83+
const { pkgJson, pkgRoot } = metadata;
84+
const exportKeys = getExportKeys(pkgJson.exports);
85+
86+
return exportKeys.flatMap((subpath) => {
87+
const specifier = normalizeSubpath(pkgName, subpath);
88+
try {
89+
const target = moduleRequire.resolve(specifier);
90+
const aliasKey =
91+
subpath === '.' ? `${pkgName}$` : specifier.replace(/\\/g, '/');
92+
return [[aliasKey, target]];
93+
} catch (error) {
94+
const aliasKey =
95+
subpath === '.' ? `${pkgName}$` : specifier.replace(/\\/g, '/');
96+
const fallbackTarget =
97+
subpath === '.'
98+
? path.join(pkgRoot, pkgJson.main ?? 'index.js')
99+
: path.join(pkgRoot, subpath.replace(/^\.\//, ''));
100+
101+
if (fs.existsSync(fallbackTarget)) {
102+
console.warn(
103+
`docusaurus-dedupe-aliases: resolved ${specifier} via fallback path ${fallbackTarget} due to`,
104+
error.message
105+
);
106+
return [[aliasKey, fallbackTarget]];
107+
}
108+
109+
console.warn(
110+
`docusaurus-dedupe-aliases: unable to resolve specifier ${specifier}`,
111+
error
112+
);
113+
return [];
114+
}
115+
});
116+
}
117+
118+
module.exports = function docusaurusDedupeAliases() {
119+
const aliasEntries = PACKAGES_TO_DEDUPE.flatMap(buildAliasesForPackage);
120+
const aliases = Object.fromEntries(aliasEntries);
121+
122+
return {
123+
name: 'docusaurus-dedupe-aliases',
124+
configureWebpack() {
125+
return {
126+
resolve: {
127+
alias: aliases,
128+
},
129+
};
130+
},
131+
};
132+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const path = require('path');
2+
const typedocApiPlugin = require('docusaurus-plugin-typedoc-api');
3+
4+
const typedocPackageRoot = path.dirname(
5+
require.resolve('docusaurus-plugin-typedoc-api/package.json')
6+
);
7+
8+
const componentMap = new Map(
9+
['ApiPage', 'ApiIndex', 'ApiItem', 'ApiChangelog'].map((name) => [
10+
path.join(typedocPackageRoot, `lib/components/${name}.js`),
11+
path.join(__dirname, `../src/typedoc/${name}.tsx`),
12+
])
13+
);
14+
15+
function remapComponent(componentPath) {
16+
if (!componentPath) {
17+
return componentPath;
18+
}
19+
20+
const normalized = path.normalize(componentPath);
21+
for (const [original, replacement] of componentMap.entries()) {
22+
if (normalized === original) {
23+
return replacement;
24+
}
25+
}
26+
27+
return componentPath;
28+
}
29+
30+
function remapRoutes(routes) {
31+
if (!routes) {
32+
return routes;
33+
}
34+
35+
return routes.map(remapRoute);
36+
}
37+
38+
function remapRoute(route) {
39+
if (!route) {
40+
return route;
41+
}
42+
43+
return {
44+
...route,
45+
component: remapComponent(route.component),
46+
routes: remapRoutes(route.routes),
47+
};
48+
}
49+
50+
module.exports = function typedocApiWrapper(context, options) {
51+
const plugin = typedocApiPlugin(context, options);
52+
const originalContentLoaded = plugin.contentLoaded?.bind(plugin);
53+
54+
return {
55+
...plugin,
56+
async contentLoaded(args) {
57+
if (!originalContentLoaded) {
58+
return;
59+
}
60+
61+
const patchedActions = {
62+
...args.actions,
63+
addRoute(routeConfig) {
64+
return args.actions.addRoute(remapRoute(routeConfig));
65+
},
66+
};
67+
68+
return originalContentLoaded({
69+
...args,
70+
actions: patchedActions,
71+
});
72+
},
73+
};
74+
};

packages/docs/site/project.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@
7979
},
8080
"dependsOn": ["build:json"]
8181
},
82+
"api-e2e": {
83+
"executor": "nx:run-commands",
84+
"options": {
85+
"commands": [
86+
"npm run build:docs",
87+
"npx playwright test --config=packages/docs/site/playwright.config.ts --project=chromium"
88+
],
89+
"parallel": false
90+
}
91+
},
8292
"lint": {
8393
"executor": "@nx/linter:eslint",
8494
"outputs": ["{options.outputFile}"],
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type { ComponentType } from 'react';
2+
import ApiChangelog from 'docusaurus-plugin-typedoc-api/lib/components/ApiChangelog.js';
3+
4+
export default ApiChangelog as ComponentType<any>;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type { ComponentType } from 'react';
2+
import ApiIndex from 'docusaurus-plugin-typedoc-api/lib/components/ApiIndex.js';
3+
4+
export default ApiIndex as ComponentType<any>;

0 commit comments

Comments
 (0)