Skip to content

Commit 3ab987c

Browse files
authored
chore(memory): improve (#842)
1 parent c487cef commit 3ab987c

5 files changed

Lines changed: 116 additions & 72 deletions

File tree

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { jsx, toJs } from 'estree-util-to-js';
2+
13
import buildContent from './utils/buildContent.mjs';
24
import { getSortedHeadNodes } from './utils/getSortedHeadNodes.mjs';
35
import { buildNotFoundPage } from './utils/synthetic/404.mjs';
@@ -7,30 +9,32 @@ import getConfig from '../../utils/configuration/index.mjs';
79
import { groupNodesByModule } from '../../utils/generators.mjs';
810

911
/**
10-
* Builds JSX content for all configured synthetic pages.
12+
* Builds the `{ head, entries }` page descriptors for all configured synthetic
13+
* pages. The descriptors are cheap to build; the expensive `buildContent` step
14+
* runs later in a worker (via `processChunk`), so the very large synthetic
15+
* `all` page is never built on the main thread.
1116
*
1217
* @param {Array<import('../metadata/types').MetadataEntry>} input
1318
*/
14-
const buildSyntheticEntries = async input => {
19+
const buildSyntheticDescriptors = input => {
1520
const config = getConfig('jsx-ast');
1621

17-
const descriptors = [
22+
return [
1823
config.generateAllPage && buildAllPage(input),
1924
config.generateIndexPage && buildIndexPage(input),
2025
config.generateNotFoundPage && buildNotFoundPage(),
2126
].filter(Boolean);
22-
23-
return Promise.all(
24-
descriptors.map(({ head, entries }) => buildContent(entries, head))
25-
);
2627
};
2728

2829
/**
2930
* Process a chunk of items in a worker thread.
30-
* Transforms metadata entries into JSX AST nodes.
3131
*
32-
* Each item is a SlicedModuleInput containing the head node
33-
* and all entries for that module - no need to recompute grouping.
32+
* Each item is a `{ head, entries }` descriptor (one module, or a synthetic
33+
* page). The JSX AST is built AND serialized to a code string here, inside the
34+
* worker, so the heavy AST — most notably the giant `all` page, which
35+
* concatenates every module — is dropped in the worker and never crosses back
36+
* to or accumulates on the main thread. Only the much smaller code string and
37+
* the page metadata are returned.
3438
*
3539
* @type {import('./types').Generator['processChunk']}
3640
*/
@@ -42,14 +46,16 @@ export async function processChunk(slicedInput, itemIndices) {
4246

4347
const content = await buildContent(entries, head);
4448

45-
results.push(content);
49+
const { value: code } = toJs(content, { handlers: jsx });
50+
51+
results.push({ data: content.data, code });
4652
}
4753

4854
return results;
4955
}
5056

5157
/**
52-
* Generates a JSX AST
58+
* Generates per-page JSX code from API metadata.
5359
*
5460
* @type {import('./types').Generator['generate']}
5561
*/
@@ -60,18 +66,16 @@ export async function* generate(input, worker) {
6066
// Create sliced input: each item contains head + its module's entries
6167
// This avoids sending all 4700+ entries to every worker
6268
const groupedModules = groupNodesByModule(input);
63-
const entries = getSortedHeadNodes(input).map(head => ({
69+
const descriptors = getSortedHeadNodes(input).map(head => ({
6470
head,
6571
entries: groupedModules.get(head.api),
6672
}));
6773

68-
for await (const chunkResult of worker.stream(entries)) {
69-
yield chunkResult;
70-
}
71-
72-
const syntheticEntries = await buildSyntheticEntries(moduleInput);
74+
// Process the synthetic pages through the worker pool as well, so their
75+
// (potentially enormous) content is built and converted off the main thread.
76+
descriptors.push(...buildSyntheticDescriptors(moduleInput));
7377

74-
if (syntheticEntries.length > 0) {
75-
yield syntheticEntries;
78+
for await (const chunkResult of worker.stream(descriptors)) {
79+
yield chunkResult;
7680
}
7781
}

src/generators/web/__tests__/generate.test.mjs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import assert from 'node:assert/strict';
22
import { describe, it } from 'node:test';
33

4+
import { jsx, toJs } from 'estree-util-to-js';
5+
46
import { setConfig } from '../../../utils/configuration/index.mjs';
57
import buildContent from '../../jsx-ast/utils/buildContent.mjs';
68
import { buildNotFoundPage } from '../../jsx-ast/utils/synthetic/404.mjs';
79
import { generate } from '../generate.mjs';
810

11+
/**
12+
* Converts a JSX AST entry into the `{ data, code }` shape `web` now consumes,
13+
* mirroring the conversion the jsx-ast worker performs before streaming.
14+
*/
15+
const toCodeItem = content => ({
16+
data: content.data,
17+
code: toJs(content, { handlers: jsx }).value,
18+
});
19+
920
const createEntry = (api, name) => {
1021
const heading = {
1122
type: 'heading',
@@ -39,10 +50,11 @@ describe('web generate', () => {
3950

4051
const fs = createEntry('fs', 'File system');
4152
const notFoundPage = buildNotFoundPage();
42-
const input = await Promise.all([
53+
const contents = await Promise.all([
4354
buildContent([fs], fs),
4455
buildContent(notFoundPage.entries, notFoundPage.head),
4556
]);
57+
const input = contents.map(toCodeItem);
4658

4759
const [fsPage, notFoundResult] = await generate(input);
4860

@@ -69,7 +81,7 @@ describe('web generate', () => {
6981
};
7082

7183
const fs = createEntry('fs', 'File system');
72-
const [fsPage] = await generate([await buildContent([fs], fs)]);
84+
const [fsPage] = await generate([toCodeItem(await buildContent([fs], fs))]);
7385

7486
assert.match(fsPage.html, /Custom project docs/);
7587
assert.match(fsPage.html, /https:\/\/example\.com\/og\.png/);

src/generators/web/generate.mjs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import { readFile } from 'node:fs/promises';
44
import { join } from 'node:path';
55

66
import { copyStaticAssets } from './utils/copying.mjs';
7-
import { processJSXEntries } from './utils/processing.mjs';
7+
import { createCodeConverter, processBundles } from './utils/processing.mjs';
88
import getConfig from '../../utils/configuration/index.mjs';
99
import { writeFile } from '../../utils/file.mjs';
1010

1111
/**
12-
* Main generation function that processes JSX AST entries into web bundles.
12+
* Main generation function that bundles per-page JSX code into web output.
1313
*
14-
* Bundles all JSX AST entries in a single pass so shared component chunks and
15-
* CSS are produced once.
14+
* Receives `jsx-ast`'s output as `{ data, code }` items — the JSX AST was
15+
* already serialized to `code` in the jsx-ast worker, so no AST is held here.
16+
* Bundling and rendering then run once over the accumulated code, since shared
17+
* component chunks, CSS, and the sidebar need every entry together.
1618
*
1719
* @type {import('./types').Generator['generate']}
1820
*/
@@ -21,14 +23,30 @@ export async function generate(input) {
2123

2224
const template = await readFile(config.templatePath, 'utf-8');
2325

26+
const converter = createCodeConverter();
27+
28+
// Per-page metadata, in render order. Each item is already just
29+
// `{ data, code }` — the heavy JSX AST was converted to `code` and discarded
30+
// in the jsx-ast worker, so nothing large is held here.
31+
const datas = [];
32+
33+
for (const item of input) {
34+
converter.add(item);
35+
datas.push(item.data);
36+
}
37+
2438
// Sidebar lists only the real module pages.
25-
const sidebarEntries = input.filter(entry => entry.data.synthetic !== true);
39+
const sidebarEntries = datas
40+
.filter(data => data.synthetic !== true)
41+
.map(data => ({ data }));
2642

27-
const { results, css, chunks } = await processJSXEntries(
28-
input,
43+
const { results, css, chunks } = await processBundles({
44+
serverCodeMap: converter.serverCodeMap,
45+
clientCodeMap: converter.clientCodeMap,
46+
datas,
47+
sidebarEntries,
2948
template,
30-
sidebarEntries
31-
);
49+
});
3250

3351
if (config.output) {
3452
for (const { html, path } of results) {

src/generators/web/index.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import { createLazyGenerator } from '../../utils/generators.mjs';
1313
* - Client-side JavaScript with code splitting
1414
* - Bundled CSS styles
1515
*
16-
* Note: This generator does NOT support streaming/chunked processing because
17-
* processJSXEntries needs all entries together to generate code-split bundles.
16+
* `jsx-ast` serializes each page's JSX AST to a `code` string inside its worker,
17+
* so this generator only ever handles small `{ data, code }` items — the heavy
18+
* ASTs (notably the giant `all` page) never reach the main thread. Bundling and
19+
* rendering run once over the accumulated code, since code-splitting and the
20+
* sidebar need every entry together.
1821
*
1922
* @type {import('./types').Generator}
2023
*/

src/generators/web/utils/processing.mjs

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { randomUUID } from 'node:crypto';
22
import { createRequire } from 'node:module';
33

4-
import { jsx, toJs } from 'estree-util-to-js';
54
import { transform } from 'lightningcss-wasm';
65

76
import bundleCode from './bundle.mjs';
@@ -81,31 +80,37 @@ export const buildHead = ({ meta = [], links = [], html = [] }) =>
8180
].join('\n ');
8281

8382
/**
84-
* Converts JSX AST entries to server and client JavaScript code.
83+
* Creates an accumulator that wraps per-page JSX code into server and client
84+
* programs one at a time. The JSX AST has already been serialized to a code
85+
* string upstream (in the `jsx-ast` worker), so the heavy AST never reaches
86+
* the main thread — only the code string and page metadata stream in here.
8587
*
86-
* @param {Array<import('../../jsx-ast/utils/buildContent.mjs').JSXContent>} entries - JSX AST entries
87-
* @param {function} buildServerProgram - Wraps code for server execution
88-
* @param {function} buildClientProgram - Wraps code for client hydration
89-
* @returns {{serverCodeMap: Map<string, string>, clientCodeMap: Map<string, string>}}
88+
* @returns {{ add: (item: { data: import('../../metadata/types').MetadataEntry, code: string }) => void, serverCodeMap: Map<string, string>, clientCodeMap: Map<string, string> }}
9089
*/
91-
function convertJSXToCode(entries, { buildServerProgram, buildClientProgram }) {
90+
export function createCodeConverter() {
91+
const { buildServerProgram, buildClientProgram } = createASTBuilder();
92+
9293
const serverCodeMap = new Map();
9394
const clientCodeMap = new Map();
9495

95-
for (const entry of entries) {
96-
const fileName = `${entry.data.api}.jsx`;
97-
98-
// Convert AST to JavaScript string with JSX syntax
99-
const { value: code } = toJs(entry, { handlers: jsx });
100-
101-
// Prepare code for server-side execution (wrapped for SSR)
102-
serverCodeMap.set(fileName, buildServerProgram(code));
103-
104-
// Prepare code for client-side execution (wrapped for hydration)
105-
clientCodeMap.set(fileName, buildClientProgram(code));
106-
}
107-
108-
return { serverCodeMap, clientCodeMap };
96+
return {
97+
/**
98+
* Records the server/client programs for a single page's JSX code.
99+
*
100+
* @param {{ data: import('../../metadata/types').MetadataEntry, code: string }} item
101+
*/
102+
add: ({ data, code }) => {
103+
const fileName = `${data.api}.jsx`;
104+
105+
// Prepare code for server-side execution (wrapped for SSR)
106+
serverCodeMap.set(fileName, buildServerProgram(code));
107+
108+
// Prepare code for client-side execution (wrapped for hydration)
109+
clientCodeMap.set(fileName, buildClientProgram(code));
110+
},
111+
serverCodeMap,
112+
clientCodeMap,
113+
};
109114
}
110115

111116
/**
@@ -141,32 +146,34 @@ async function executeServerCode(serverCodeMap, requireFn, virtualImports) {
141146
}
142147

143148
/**
144-
* Processes JSX AST entries into complete HTML pages, client JS bundles, and CSS.
149+
* Bundles pre-converted JSX code into complete HTML pages, client JS bundles,
150+
* and CSS. Conversion (JSX AST → code) happens upstream via
151+
* {@link createCodeConverter} so the heavy ASTs are already discarded; this
152+
* step needs every entry together for code-splitting and the shared sidebar.
145153
*
146-
* @param {Array<import('../../jsx-ast/utils/buildContent.mjs').JSXContent>} entries - The JSX AST entries to process.
147-
* @param {string} template - The HTML template string for the output pages.
148-
* @param {Array<{ data: import('../../metadata/types').MetadataEntry }>} [sidebarEntries] - Entries used to build the sidebar page list. Defaults to `entries`. Pass the full set when rendering a subset (e.g. the `all` page) so the sidebar still links to every module.
154+
* @param {object} params
155+
* @param {Map<string, string>} params.serverCodeMap - Server-side code per page.
156+
* @param {Map<string, string>} params.clientCodeMap - Client-side code per page.
157+
* @param {Array<import('../../metadata/types').MetadataEntry>} params.datas - Per-page metadata, in render order.
158+
* @param {Array<{ data: import('../../metadata/types').MetadataEntry }>} params.sidebarEntries - Entries used to build the sidebar page list (real module pages only).
159+
* @param {string} params.template - The HTML template string for the output pages.
149160
*/
150-
export async function processJSXEntries(
151-
entries,
161+
export async function processBundles({
162+
serverCodeMap,
163+
clientCodeMap,
164+
datas,
165+
sidebarEntries,
152166
template,
153-
sidebarEntries = entries
154-
) {
167+
}) {
155168
const config = getConfig('web');
156-
const astBuilders = createASTBuilder();
157169
const requireFn = createRequire(import.meta.url);
158170
const virtualImports = {
159171
'#theme/config': createConfigSource(sidebarEntries),
160172
...config.virtualImports,
161173
};
162-
// Step 1: Convert JSX AST to JavaScript
163-
const { serverCodeMap, clientCodeMap } = convertJSXToCode(
164-
entries,
165-
astBuilders
166-
);
167174

168-
// Step 2: Bundle server and client code in parallel
169-
// Both need all entries for code-splitting, but are independent of each other
175+
// Bundle server and client code in parallel. Both need all entries for
176+
// code-splitting, but are independent of each other.
170177
const [serverBundle, clientBundle] = await Promise.all([
171178
executeServerCode(serverCodeMap, requireFn, virtualImports),
172179
bundleCode(clientCodeMap, virtualImports),
@@ -182,9 +189,9 @@ export async function processJSXEntries(
182189
// template authors avoid nested template-literal escaping.
183190
const head = buildHead(config.head);
184191

185-
// Step 3: Render final HTML pages
192+
// Render final HTML pages
186193
const results = await Promise.all(
187-
entries.map(async ({ data }) => {
194+
datas.map(async data => {
188195
const root = resolvePageRoot(data);
189196

190197
// Replace template placeholders with actual content

0 commit comments

Comments
 (0)