open-design/apps/web/tests/runtime/jsx-module-refs.test.ts
Chris Seifert ce68097f6b
feat(web): point .jsx module previews at their HTML entry (#2748)
* feat(web): point .jsx module previews at their HTML entry

Multi-file React prototypes load .jsx modules from an HTML entry via
<script type="text/babel" src>. A module previewed on its own has no
standalone component, so it dead-ended on the React runtime error
"No React component export found".

Modules are now detected (a .jsx/.tsx referenced by a sibling HTML
entry's babel script src) and handled:
- The Preview shows a pointer to the HTML entry(ies) that render the
  module; clicking one opens that page and closes the module tab.
- The Code tab still renders the raw source.
- Such modules no longer auto-open as preview tabs after a write.

* fix(web): bound module-detection cache, ignore commented-out scripts

Review follow-up on the .jsx module preview pointer:
- ProjectView: key the HTML content cache by file name with mtime stored
  alongside, so a rewrite replaces the file's single entry instead of
  leaking a new name@mtime key per revision.
- extractBabelScriptSrcs: strip HTML comments before scanning so a
  commented-out babel script is not collected as a live reference.
- i18n: normalize the three new jsxModule values to single quotes across
  all 19 locales to match each file's existing style.
2026-05-23 11:49:22 +08:00

155 lines
5.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
collectReferencedJsxNames,
extractBabelScriptSrcs,
findHtmlEntriesReferencing,
htmlLoadsJsxModule,
isJsxModule,
} from '../../src/runtime/jsx-module-refs';
const MULTI_FILE_HTML = `<!doctype html>
<html>
<head><title>Backups Panel</title></head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="icons.jsx"></script>
<script type="text/babel" src="chrome.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>`;
describe('extractBabelScriptSrcs', () => {
it('returns [] for empty / nullish input', () => {
expect(extractBabelScriptSrcs('')).toEqual([]);
expect(extractBabelScriptSrcs(null)).toEqual([]);
expect(extractBabelScriptSrcs(undefined)).toEqual([]);
});
it('lists only text/babel module srcs, in document order', () => {
expect(extractBabelScriptSrcs(MULTI_FILE_HTML)).toEqual([
'tweaks-panel.jsx',
'icons.jsx',
'chrome.jsx',
'app.jsx',
]);
});
it('ignores CDN/library <script src> that are not type=text/babel', () => {
expect(extractBabelScriptSrcs(MULTI_FILE_HTML)).not.toContain(
'https://unpkg.com/react@18.3.1/umd/react.development.js',
);
});
it('ignores inline text/babel scripts with no src', () => {
const html = '<script type="text/babel">function App(){return null;}</script>';
expect(extractBabelScriptSrcs(html)).toEqual([]);
});
it('handles src-before-type attribute order', () => {
const html = '<script src="app.jsx" type="text/babel"></script>';
expect(extractBabelScriptSrcs(html)).toEqual(['app.jsx']);
});
it('normalizes a leading ./ and strips query/hash', () => {
const html =
'<script type="text/babel" src="./icons.jsx?v=2"></script>' +
'<script type="text/babel" src="chrome.jsx#frag"></script>';
expect(extractBabelScriptSrcs(html)).toEqual(['icons.jsx', 'chrome.jsx']);
});
it('ignores babel scripts commented out in HTML', () => {
const html =
'<!-- <script type="text/babel" src="legacy.jsx"></script> -->' +
'<script type="text/babel" src="app.jsx"></script>';
expect(extractBabelScriptSrcs(html)).toEqual(['app.jsx']);
expect(extractBabelScriptSrcs(html)).not.toContain('legacy.jsx');
});
});
describe('htmlLoadsJsxModule', () => {
it('matches an exact src reference', () => {
expect(htmlLoadsJsxModule(MULTI_FILE_HTML, 'icons.jsx')).toBe(true);
});
it('matches by basename when the project name has no slash', () => {
const html = '<script type="text/babel" src="parts/icons.jsx"></script>';
expect(htmlLoadsJsxModule(html, 'icons.jsx')).toBe(true);
});
it('is false when the module is not referenced', () => {
expect(htmlLoadsJsxModule(MULTI_FILE_HTML, 'unused.jsx')).toBe(false);
});
it('is false for an empty module name', () => {
expect(htmlLoadsJsxModule(MULTI_FILE_HTML, '')).toBe(false);
});
});
describe('findHtmlEntriesReferencing', () => {
it('returns every HTML entry that loads the module, in map order', () => {
const sources = new Map<string, string>([
['Backups Panel.html', MULTI_FILE_HTML],
['Overview Panel.html', '<script type="text/babel" src="icons.jsx"></script>'],
['Unrelated.html', '<script type="text/babel" src="other.jsx"></script>'],
]);
expect(findHtmlEntriesReferencing('icons.jsx', sources)).toEqual([
'Backups Panel.html',
'Overview Panel.html',
]);
});
it('returns [] when no HTML references the module (standalone artifact)', () => {
const sources = new Map<string, string>([['Page.html', '<div>no scripts</div>']]);
expect(findHtmlEntriesReferencing('icons.jsx', sources)).toEqual([]);
});
});
describe('isJsxModule', () => {
it('is true when at least one HTML entry loads the file', () => {
const sources = new Map<string, string>([['Backups Panel.html', MULTI_FILE_HTML]]);
expect(isJsxModule('app.jsx', sources)).toBe(true);
});
it('is false when nothing references the file', () => {
const sources = new Map<string, string>([['Backups Panel.html', MULTI_FILE_HTML]]);
expect(isJsxModule('standalone-component.jsx', sources)).toBe(false);
});
});
describe('collectReferencedJsxNames', () => {
const files = [
{ name: 'Backups Panel.html' },
{ name: 'tweaks-panel.jsx' },
{ name: 'icons.jsx' },
{ name: 'chrome.jsx' },
{ name: 'app.jsx' },
{ name: 'standalone.jsx' },
{ name: 'styles.css' },
];
it('returns project file names loaded by an HTML entry, excluding unreferenced ones', async () => {
const read = async (name: string) =>
name === 'Backups Panel.html' ? MULTI_FILE_HTML : null;
const result = await collectReferencedJsxNames(files, read);
expect(result).toEqual(new Set(['tweaks-panel.jsx', 'icons.jsx', 'chrome.jsx', 'app.jsx']));
// A .jsx that no HTML loads stays a normal standalone artifact.
expect(result.has('standalone.jsx')).toBe(false);
});
it('ignores <script src> that point at files the project does not have', async () => {
const read = async () => '<script type="text/babel" src="ghost.jsx"></script>';
const result = await collectReferencedJsxNames(files, read);
expect(result.size).toBe(0);
});
it('returns an empty set when no HTML entries exist', async () => {
const read = async () => null;
const result = await collectReferencedJsxNames([{ name: 'only.jsx' }], read);
expect(result).toEqual(new Set());
});
});