Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/x-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@
"node": ">=14.0.0"
},
"imports": {
"#formatErrorMessage": "@mui/x-internals/formatErrorMessage"
"#formatErrorMessage": "@mui/x-internals/formatErrorMessage",
"#remend": {
"import": "remend",
"default": "./src/internals/remendUnavailable.ts"
}
}
}
14 changes: 14 additions & 0 deletions packages/x-chat/src/internals/remendUnavailable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* CommonJS resolution target for the `#remend` subpath import (see this package's
* `imports` field, which points ESM at the real `remend` package and CJS here).
*
* `remend` is ESM-only — its `exports` map declares no `require` condition — so the
* CJS build must not name it at all. A bundler that statically resolves a
* `require('remend')` in that output fails the build outright ("not exported under
* the conditions [...require...]"), which is why the specifier cannot simply be
* inlined for every format.
*
* Exporting no repair function is the signal: `loadRemend` sees a non-function and
* degrades to `fallbackRepair`, exactly as it does when `remend` is absent.
*/
export default undefined;
59 changes: 59 additions & 0 deletions packages/x-chat/src/internals/streamingMarkdownRepair.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Node-only checks out of browser tests

The x-chat browser configuration includes this test file, so pnpm test:browser --project "x-chat" --run will load these top-level node:fs, node:path, and node:url imports in Chromium. Vite externalizes Node built-ins for browser compatibility, and the top-level path.resolve(...) or later fs.readFileSync(...) will fail the suite before these assertions can run. Move the filesystem-based regression checks into a separately excluded Node-only test file, as is already done for docsCorrectnessGuard, while leaving the runtime tests browser-compatible.

AGENTS.md reference: AGENTS.md:L40-L47

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — this was the cause of the test_browser / test_browser_react_18 failures, and you're right that the file never even got to the assertions: it failed to collect in Chromium (streamingMarkdownRepair.test.ts (0 test)), while the other 284 browser tests passed. I'd only run test:unit locally, which is why it slipped through.

Fixed in e12a218 exactly as suggested, following the docsCorrectnessGuard precedent:

  • moved the two filesystem-based checks to src/tests/packagingGuard/remendSpecifier.test.ts
  • added **/packagingGuard/** to the exclude list in vitest.config.browser.mts
  • left the runtime tests in streamingMarkdownRepair.test.ts

Keeping the runtime tests browser-compatible turned out to be worth more than just unblocking CI — they now assert in Chromium that #remend resolves and that remend actually loads, which is precisely the behaviour #23160 was about. That file went from 0 collected tests to 11.

Verified: browser 27 files / 295 tests passing, unit 940 passing / 33 skipped. I also re-ran the mutation check (restoring the old variable specifier) to confirm the guard still fails from its new location.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correction to my reply above: I attributed the test_browser failures to this issue, and that was wrong. I inferred it from timing instead of reading the CI log. Pulling the log showed the original run had failed identically — all 27 x-chat suites, same setupVitest error — both before and after this fix. Your finding was a real defect (the file did fail to collect in Chromium locally) but it was not what CI was red on.

The actual cause was a side effect of the fix working. Now that import('#remend') genuinely resolves, the browser suite loads remend for real, and Vite's scanner can't see it up front behind a lazy subpath import:

[vite] (client) dependency optimized: remend
[vite] (client) optimized dependencies changed. reloading
[vitest] Vite unexpectedly reloaded a test.

The mid-run re-optimization reloads the page and drops every in-flight suite in the project. It passed locally only because my optimizer cache was warm — I reproduced it by clearing node_modules/.vite. Fixed in 862a26f by adding remend to optimizeDeps.include, as that warning recommends.

test_browser now gets through the whole x-chat project; the only remaining failure is an unrelated flaky DataGridPro data-source test (expected 11 to equal 12, retried 4x).

import { describe, expect, it, vi, beforeEach } from 'vitest';
import { fallbackRepair, loadRemend, resetRemendCache } from './streamingMarkdownRepair';

const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for looking, but I don't think this one holds — it's one .. level off.

The test file is at packages/x-chat/src/internals/streamingMarkdownRepair.test.ts, so path.dirname(fileURLToPath(import.meta.url)) is packages/x-chat/src/internals, not packages/x-chat/src. Resolving '../..' from there walks up twice — internalssrcx-chat — landing on the package root:

packageRoot        = /…/packages/x-chat
package.json       exists: true
src/internals/streamingMarkdownRepair.ts  exists: true

The reads resolve to packages/x-chat/package.json and packages/x-chat/src/internals/streamingMarkdownRepair.ts, both of which exist. It's also confirmed empirically: these tests pass locally and CI's test_unit job is green — a src/src/... path would have failed the suite outright.

Note the code has since moved to src/tests/packagingGuard/remendSpecifier.test.ts (to keep Node-only imports out of the browser project), where the same resolution is '../../..' for the extra directory level.


describe('streamingMarkdownRepair', () => {
beforeEach(() => {
resetRemendCache();
Expand Down Expand Up @@ -65,4 +70,58 @@ describe('streamingMarkdownRepair', () => {
expect(repair('x')).to.equal('x-2');
});
});

// Regression coverage for the `remend` specifier resolving in bundled apps.
// Importing it under a specifier a bundler can't statically resolve left the upgrade
// as dead code in every browser bundle and — where the bundler wraps dynamic imports
// in a preload helper — dispatched a global load-error event on each render.
// See https://github.com/mui/mui-x/issues/23160.
describe('remend specifier resolution', () => {
it('resolves the real remend through the default importer', async () => {
// No injected importer: exercises `import('#remend')` for real, so a specifier
// that stopped resolving would fail here rather than silently degrade.
const repair = await loadRemend();

expect(repair).not.to.equal(fallbackRepair);
// remend completes the unterminated inline marker; fallbackRepair never would.
expect(repair('a **bold')).to.equal('a **bold**');
});

it('degrades to fallbackRepair on the CommonJS `#remend` stub', async () => {
// What `require('#remend')` resolves to in the CJS build, where the ESM-only
// `remend` cannot be named at all.
const stub = await import('./remendUnavailable');
const repair = await loadRemend(() => Promise.resolve(stub));

expect(repair).to.equal(fallbackRepair);
});

it('imports a statically analyzable specifier', () => {
const source = fs.readFileSync(
path.join(packageRoot, 'src/internals/streamingMarkdownRepair.ts'),
'utf8',
);

expect(source).to.contain("import('#remend')");
// A specifier read from a variable, or hidden behind an ignore hint, is
// unanalyzable: bundlers leave a bare specifier that can never resolve in a
// browser instead of bundling the dependency.
expect(source).not.to.match(/import\(\s*(\/\*[^*]*\*\/\s*)*[A-Za-z_$]/);
});

it('maps `#remend` per module format in package.json', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
);
const remendImport = packageJson.imports['#remend'];

// ESM gets the real package, so bundlers resolve and bundle it.
expect(remendImport.import).to.equal('remend');
// Every other condition (CJS) resolves to a file inside this package rather than
// to `remend`, which declares no `require` export — a bundler resolving that in
// the CJS output fails the build outright.
expect(remendImport.default).to.match(/^\.\//);
expect(packageJson.dependencies.remend).to.be.a('string');
});
});
});
33 changes: 17 additions & 16 deletions packages/x-chat/src/internals/streamingMarkdownRepair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,30 @@ export type RepairMarkdown = (text: string) => string;
*/
export const fallbackRepair: RepairMarkdown = normalizeMarkdownForRender;

// `remend` is an ESM-only package and a declared dependency of this package. The
// build downlevels dynamic `import()` to `require()` in the CJS output, and a static
// `require()` of an ES module throws `ERR_REQUIRE_ESM`. Reading the specifier from a
// variable (plus the `@vite-ignore`/`webpackIgnore` hints) keeps that `require`/
// `import` dynamic and unanalyzable, so it stays a genuine runtime import that fails
// gracefully at call time instead of at build time. The `.catch` in `loadRemend`
// then degrades to `fallbackRepair` — so a runtime that can't resolve the specifier
// (or a CJS `require()` of the ESM module) costs nothing beyond the failed attempt.
const REMEND_SPECIFIER = 'remend';
// `remend` is an ESM-only package and a declared dependency of this package, so the
// specifier it is imported under has to differ per output format. `#remend` is a
// subpath import (see this package's `imports` field) that resolves to the real
// `remend` package under the `import` condition and to a stub under `require`.
//
// The literal specifier matters: it has to stay statically analyzable so a consumer's
// bundler resolves and bundles `remend` like any other dependency. Reading it from a
// variable instead leaves an unresolvable bare `import('remend')` in the browser
// bundle, which can never resolve at runtime (no import map) — making the upgrade dead
// code, and, under bundlers that wrap dynamic imports in a preload helper, dispatching
// a global load-error event on every render.
function defaultRemendImporter(): Promise<unknown> {
// eslint-disable-next-line jsdoc/no-bad-blocks -- bundler hint comments, not JSDoc
return import(/* @vite-ignore */ /* webpackIgnore: true */ REMEND_SPECIFIER);
return import('#remend');
}

let remendPromise: Promise<RepairMarkdown> | undefined;

/**
* Lazily loads `remend` and returns a repair function. If the import rejects — e.g.
* the CJS build downlevelled it to a `require()` of the ESM-only module, or a
* consumer's bundler can't resolve the runtime specifier — it transparently degrades
* to {@link fallbackRepair}. Cached after the first call.
* Lazily loads `remend` and returns a repair function. When it can't be loaded — the
* CJS build resolves `#remend` to a stub, and a consumer may have deduped or blocked
* the dependency — it transparently degrades to {@link fallbackRepair}. Cached after
* the first call.
*
* @param importer Injectable for tests; defaults to `() => import('remend')`.
* @param importer Injectable for tests; defaults to `() => import('#remend')`.
*/
export function loadRemend(
importer: () => Promise<unknown> = defaultRemendImporter,
Expand Down
Loading