Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,6 @@ package-lock.json
# Release notes temp files (for review only)
releases-docs/temp-files/


# bundle stats written by BIT_UI_BUNDLE_STATS (see scripts/analyze-bundle.mjs)
bundle-stats/
64 changes: 64 additions & 0 deletions e2e/harmony/ui-ssr.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect } from 'chai';
import { IS_WINDOWS } from '@teambit/legacy.constants';
import { Helper } from '@teambit/legacy.e2e-helper';
import { HttpHelper } from '../http-helper';

const PORT = 3025;

/**
* The scope UI root is the only one built with `ssr: true`, and its ssr middleware swallows a render
* failure by falling through to the client-rendered `index.html`. That fallback looks identical to a
* working page in a browser, so a broken ssr bundle is invisible without asserting on the *served
* html* - which is what this file does. It is how "Invalid tag" (react #65), caused by `.cjs` modules
* being emitted as assets in the ssr build, went unnoticed for months.
*/
(IS_WINDOWS ? describe.skip : describe)('scope UI server-side rendering', function () {
this.timeout(0);
let helper: Helper;
let httpHelper: HttpHelper;
let html: string;

before(async () => {
helper = new Helper();
// `--rebuild` so the ssr bundle is compiled from this repo's rspack config. without it the
// server serves the pre-built bundle shipped by the installed bit version, and the assertions
// below would describe that release rather than the code under test.
httpHelper = new HttpHelper(helper, PORT, ['--rebuild']);
helper.scopeHelper.setWorkspaceWithRemoteScope();
helper.fixtures.populateComponents(1, false);
helper.command.tagAllWithoutBuild();
helper.command.export();
await httpHelper.start();
const response = await fetch(`http://localhost:${PORT}/`);
html = await response.text();
});

after(async () => {
await httpHelper.killHttp();
helper.scopeHelper.destroy();
});

it('should render the app on the server, not fall back to an empty client-rendered root', () => {
// the client-only fallback is exactly `<div id="root"></div>`; anything ssr rendered puts markup
// inside it. asserting on the emptiness is what distinguishes the two - a 200 with valid html is
// returned either way.
expect(html).to.not.have.string('<div id="root"></div>');
const rendered = html.match(/<div id="root"[^>]*>([\s\S]*)<\/div>/);
expect(rendered, 'no #root element in the served html').to.not.equal(null);
expect((rendered as RegExpMatchArray)[1].trim()).to.not.have.lengthOf(0);
});

it('should not emit a module as an asset url where a component is expected', () => {
// the "Invalid tag" symptom: an emitted asset path reaching react as a tag name.
expect(html).to.not.match(/<\/?"?\/public\/ssr\//);
});

it('should render the scope name into the markup, not just the document title', () => {
// deliberately scoped to the contents of `#root`: the static `index.html` already carries the
// scope name in its `<title>`, so asserting on the whole document would pass even when the ssr
// render failed and the client fallback was served.
const rendered = html.match(/<div id="root"[^>]*>([\s\S]*)<\/div>/);
expect(rendered, 'no #root element in the served html').to.not.equal(null);
expect((rendered as RegExpMatchArray)[1]).to.have.string(helper.scopes.remote);
});
});
10 changes: 8 additions & 2 deletions e2e/http-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ export class HttpHelper {
httpProcess: ChildProcess;
constructor(
private helper: Helper,
private port = DEFAULT_HTTP_PORT
private port = DEFAULT_HTTP_PORT,
/**
* extra flags for `bit start`. `--rebuild` is the one that matters for anything asserting on the
* UI itself: without it the server resolves the pre-built bundle from the bvm install, so the
* test measures whatever bit version happens to be installed rather than the code under test.
*/
private extraArgs: string[] = []
) {}
async start(): Promise<void> {
// a `bit start` server from an earlier describe-block in the same file shares this port (and the
Expand All @@ -27,7 +33,7 @@ export class HttpHelper {
// (which surfaced as OutdatedIndexJson / MergeConflictOnRemote in the bbit nightly).
await this.waitForPortToBeFree();
return new Promise((resolve, reject) => {
const args = ['start', '--verbose', '--log', '--port', String(this.port)];
const args = ['start', '--verbose', '--log', '--port', String(this.port), ...this.extraArgs];
const cmd = `${this.helper.command.bitBin} ${args.join(' ')}`;
const cwd = this.helper.scopes.remotePath;
if (this.helper.debugMode) console.log(rightpad(chalk.green('cwd: '), 20, ' '), cwd); // eslint-disable-line no-console
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"lint-circle": "mkdir -p junit && oxlint --deny-warnings --format junit scopes e2e components > junit/oxlint-results.xml",
"lint:fix": "oxlint --fix scopes e2e components",
"lint-full": "./scripts/validate-import-named-aspects.sh && ./scripts/validate-no-ramda.sh && node scripts/validate-pkg-exist-in-pkg-json.js && npm run lint",
"analyze-bundle": "node scripts/analyze-bundle.mjs",
"format": "prettier \"{e2e,scopes,components}/**/*.{ts,js,jsx,css,scss,tsx,md,mdx}\" --write",
"prettier:check": "prettier --list-different \"{e2e,scopes,components}/**/*.{ts,js,jsx,css,scss,tsx,md,mdx}\"",
"mocha-circleci": "cross-env NODE_OPTIONS='--no-warnings --max-old-space-size=5000' registry-mock prepare && mocha --require ./babel-register --reporter mocha-multi-reporters --reporter-options configFile=mocha-multi-reporters-config.json --colors --exit",
Expand Down
9 changes: 7 additions & 2 deletions scopes/cloud/hooks/use-current-user/use-current-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,18 @@ export function useCurrentUser(): {
} {
const [setRedirectUrl] = useMutation(SET_REDIRECT_URL_MUTATION);

// read the href during render rather than inside the dependency array: a dependency array is
// evaluated on every render, including the server-side one, where `window` does not exist. the
// effect body itself never runs on the server, so only the dependency needed guarding.
const redirectUrl = typeof window === 'undefined' ? undefined : window.location.href;

React.useEffect(() => {
const redirectUrl = window.location.href;
if (!redirectUrl) return;
setRedirectUrl({ variables: { redirectUrl } }).catch((error) => {
// eslint-disable-next-line no-console
console.error('Error setting redirect URL:', error);
});
}, [window.location.href]);
}, [redirectUrl]);

const { data, loading } = useDataQuery(CURRENT_USER_QUERY, {
fetchPolicy: 'cache-first',
Expand Down
40 changes: 40 additions & 0 deletions scopes/ui-foundation/ui/rspack/bundle-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { join } from 'path';
import { mkdirpSync, writeFileSync } from 'fs-extra';

/**
* Set to a directory to write a stats file per compilation, or to `1`/`true` to write them under
* `<cwd>/bundle-stats`. Off by default: collecting the module graph is not free, and the stats file
* is far larger than the bundle it describes.
*/
export const BUNDLE_STATS_ENV_VAR = 'BIT_UI_BUNDLE_STATS';

export function bundleStatsDir(): string | undefined {
const value = process.env[BUNDLE_STATS_ENV_VAR];
if (!value) return undefined;
if (value === '1' || value === 'true') return join(process.cwd(), 'bundle-stats');
return value;
}

/**
* Write an rspack stats file for a finished compilation, for `scripts/analyze-bundle.mjs` to read.
*
* Deliberately written outside the output directory: everything under an output dir is matched by
* the build task's artifact glob, so a stats file placed there would ship inside the package.
*/
export function writeBundleStats(stats: any, name: string): string | undefined {
const dir = bundleStatsDir();
if (!dir || !stats) return undefined;
const json = stats.toJson({
all: false,
assets: true,
chunks: true,
chunkModules: true,
modules: true,
reasons: false,
source: false,
});
mkdirpSync(dir);
const filePath = join(dir, `${name}.stats.json`);
writeFileSync(filePath, JSON.stringify(json));
Comment on lines +37 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Stats path can break 🐞 Bug ◔ Observability

writeBundleStats() builds the output path with the unsanitized name, so names containing path
separators can create an unintended nested path and make writeFileSync() fail (ENOENT).
UiMain.writeStats() swallows that failure (debug-only), so enabling BIT_UI_BUNDLE_STATS may produce
no stats files without any visible signal.
Agent Prompt
## Issue description
`writeBundleStats(stats, name)` uses `join(dir, `${name}.stats.json`)` and only creates `dir`. If `name` contains `/` or `\\`, the resulting `filePath` includes intermediate directories that do not exist, causing `writeFileSync()` to throw. The caller (`UiMain.writeStats`) catches and logs only at debug level, making the failure effectively silent when diagnostics are explicitly enabled.

## Issue Context
This is an opt-in diagnostics path (`BIT_UI_BUNDLE_STATS`), so it must be robust to odd names and should reliably write a file when enabled.

## Fix Focus Areas
- scopes/ui-foundation/ui/rspack/bundle-stats.ts[24-39]
- scopes/ui-foundation/ui/ui.main.runtime.ts[305-316]

## Suggested fix
- Sanitize `name` into a filename-safe value (e.g., replace `/` and `\\` with `_`, and prevent `..` segments).
- Ensure the parent directory of `filePath` exists (e.g., `mkdirpSync(dirname(filePath))`) before writing.
- (Optional) If writing fails, consider logging a `warn` (still not failing the build) so the user who opted in understands why no file appeared.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return filePath;
}
5 changes: 5 additions & 0 deletions scopes/ui-foundation/ui/rspack/rspack.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export function resolveAlias(opts?: { profile?: boolean }): Record<string, strin
'@teambit/component.ui.component-compare.context': require.resolve(
'@teambit/component.ui.component-compare.context'
),
// carries `ssrBrowserContext`, which the ssr render fills in and `useUserAgent` reads. the ui
// graph pulls in several versions of this package, and an unaliased copy gives the provider and
// the consumer two different contexts - the consumer then sees `undefined`, takes the browser
// fallback, and dereferences `window` while rendering on the server.
'@teambit/ui-foundation.ui.hooks.use-user-agent': require.resolve('@teambit/ui-foundation.ui.hooks.use-user-agent'),
'@teambit/base-react.navigation.link': require.resolve('@teambit/base-react.navigation.link'),
'@teambit/base-ui.graph.tree.recursive-tree': require.resolve('@teambit/base-ui.graph.tree.recursive-tree'),
'@teambit/semantics.entities.semantic-schema': require.resolve('@teambit/semantics.entities.semantic-schema'),
Expand Down
26 changes: 24 additions & 2 deletions scopes/ui-foundation/ui/rspack/rspack.ssr.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,28 @@ export default function createRspackSsrConfig(
},
mode: 'production',
target: 'node',
devtool: 'eval-cheap-module-source-map',
// this bundle ships inside the package, so it follows the browser config's opt-in: the `eval-*`
// devtools inline a base64 source map per module, which was 60% of the 37 MB `ssr/index.js`.
devtool: shouldUseSourceMap ? 'source-map' : false,
experiments: {
css: true,
},

optimization: {
minimize: true,
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
minimizerOptions: {
compress: { ecma: 5, comparisons: false, inline: 2 },
// `keep_classnames` for the same reason as the browser build - react resolves component
// names from the class name, and the ssr output is rendered by the same components.
mangle: { safari10: true, keep_classnames: true },
format: { ecma: 5, comments: false, ascii_only: true },
},
}),
],
},

entry: {
main: entryFiles,
},
Expand Down Expand Up @@ -64,7 +81,12 @@ export default function createRspackSsrConfig(
exportsOnly: true,
}),
{
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/, /\.css$/, /\.s[ac]ss$/, /\.less$/],
// `cjs` must be excluded here exactly as it is in the browser config. without it a `.cjs`
// module is emitted as an asset and its module value becomes the emitted file's url, so a
// component imported from one renders as `<"/public/ssr/<hash>.cjs" />` and react throws
// "Invalid tag" (#65) on every request - which the ssr middleware swallows, silently
// falling back to the client-rendered html.
exclude: [/\.(cjs|js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/, /\.css$/, /\.s[ac]ss$/, /\.less$/],
type: 'asset/resource',
},
],
Expand Down
16 changes: 16 additions & 0 deletions scopes/ui-foundation/ui/ui.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { UIServer } from './ui-server';
import { UIAspect, UIRuntime } from './ui.aspect';
import createRspackBrowserConfig from './rspack/rspack.browser.config';
import createRspackSsrConfig from './rspack/rspack.ssr.config';
import { writeBundleStats } from './rspack/bundle-stats';
import type { StartPlugin, StartPluginOptions } from './start-plugin';
import { BundleUiTask, BUNDLE_UI_HASH_FILENAME } from './bundle-ui.task';

Expand Down Expand Up @@ -270,6 +271,7 @@ export class UiMain {
this.clearConsole();
throw new Error(results?.toString());
}
this.writeStats(results, `${uiRoot.name}-browser`);

if (ssr) {
const ssrConfig = createRspackSsrConfig(outputPath, [mainEntry], publicDir);
Expand All @@ -286,6 +288,7 @@ export class UiMain {
this.clearConsole();
throw new Error(ssrResults?.toString());
}
this.writeStats(ssrResults, `${uiRoot.name}-ssr`);
}

return results;
Expand All @@ -299,6 +302,19 @@ export class UiMain {
}
}

/**
* Opt-in, via `BIT_UI_BUNDLE_STATS` - see `rspack/bundle-stats.ts`. Never fails a build: this is
* diagnostics, and a bundle that compiled is still a bundle worth keeping.
*/
private writeStats(stats: any, name: string) {
try {
const filePath = writeBundleStats(stats, name);
if (filePath) this.logger.console(`${chalk.magenta('[Rspack]')} wrote bundle stats to ${chalk.cyan(filePath)}`);
Comment on lines +311 to +312

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Ad-hoc chalk in writestats 📘 Rule violation ⚙ Maintainability

The new writeStats() CLI output uses direct chalk formatting and a hardcoded [Rspack] prefix
instead of the repository’s shared CLI output formatting toolkit. This risks inconsistent CLI output
styling across commands and bypasses the documented style guide.
Agent Prompt
## Issue description
`UiMain.writeStats()` prints CLI output using ad-hoc `chalk` formatting (e.g. `chalk.magenta('[Rspack]')`) rather than using the shared CLI output formatting toolkit.

## Issue Context
The repo requires CLI output to follow `scopes/harmony/cli/cli-output-style-guide.md` and use the shared formatter utilities from `@teambit/cli` (`scopes/harmony/cli/output-formatter.ts`) to keep output consistent and maintainable.

## Fix Focus Areas
- scopes/ui-foundation/ui/ui.main.runtime.ts[309-315]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} catch (err: any) {
this.logger.debug(`failed writing bundle stats for ${name}: ${err?.message || err}`);
}
}

registerStartPlugin(startPlugin: StartPlugin) {
this.startPluginSlot.register(startPlugin);
return this;
Expand Down
105 changes: 105 additions & 0 deletions scripts/analyze-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env node
/* eslint no-console: 0 */
/**
* Summarize an rspack stats file written by `BIT_UI_BUNDLE_STATS`.
*
* BIT_UI_BUNDLE_STATS=1 bit build "teambit.ui-foundation/ui" --tasks BundleUI --reuse-capsules --unmodified
* node scripts/analyze-bundle.mjs bundle-stats/scope-ssr.stats.json
*
* Prints the assets, then the heaviest npm packages and workspace scopes, so it is obvious which
* dependency is responsible for a bundle's size rather than only how large the bundle is.
*/
import { readFileSync, existsSync } from 'fs';
import { basename } from 'path';

const TOP = Number(process.env.TOP || 25);

function mb(bytes) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}

/**
* Attribute a module to something a human can act on. Anything under node_modules is charged to its
* package (scoped names kept whole); everything else is charged to its workspace directory, which
* for this repo means the aspect or component it came from.
*/
function bucketOf(name) {
if (!name) return '(unknown)';
const clean = name.replace(/^.*?!/, '').replace(/\?.*$/, '');
const segments = clean.split(/[\\/]/);
const lastNodeModules = segments.lastIndexOf('node_modules');
if (lastNodeModules !== -1) {
const first = segments[lastNodeModules + 1];
if (!first) return '(node_modules)';
const pkg = first.startsWith('@') ? `${first}/${segments[lastNodeModules + 2] ?? ''}` : first;
return `node_modules/${pkg}`;
}
for (const root of ['scopes', 'components', 'e2e']) {
const at = segments.indexOf(root);
if (at !== -1) return segments.slice(at, at + 3).join('/');
}
return clean.startsWith('webpack') || clean.startsWith('external') ? '(runtime)' : '(other)';
}

/** rspack nests concatenated/child modules; only leaves carry a size worth counting once. */
function walkModules(modules, visit, seen = new Set()) {
for (const mod of modules ?? []) {
const id = mod.identifier ?? mod.name;
if (id && seen.has(id)) continue;
if (id) seen.add(id);
if (mod.modules?.length) walkModules(mod.modules, visit, seen);
else visit(mod);
}
}

function analyze(file) {
const stats = JSON.parse(readFileSync(file, 'utf8'));

console.log(`\n=== ${basename(file)} ===\n`);

const assets = (stats.assets ?? []).filter((a) => !a.name.endsWith('.map')).sort((a, b) => b.size - a.size);
const assetTotal = assets.reduce((sum, a) => sum + a.size, 0);
console.log(`assets: ${assets.length}, total ${mb(assetTotal)}\n`);
for (const asset of assets.slice(0, TOP)) {
console.log(` ${mb(asset.size).padStart(10)} ${asset.name}`);
}
if (assets.length > TOP) console.log(` ... and ${assets.length - TOP} more`);

const buckets = new Map();
let moduleTotal = 0;
walkModules(stats.modules, (mod) => {
const size = mod.size ?? 0;
moduleTotal += size;
const bucket = bucketOf(mod.name ?? mod.identifier);
buckets.set(bucket, (buckets.get(bucket) ?? 0) + size);
});

if (!buckets.size) {
console.log('\nno module information in this stats file.');
return;
}

const ranked = [...buckets].sort((a, b) => b[1] - a[1]);
console.log(`\nmodules: ${mb(moduleTotal)} across ${buckets.size} packages/scopes (parsed, pre-minification)\n`);
for (const [bucket, size] of ranked.slice(0, TOP)) {
const share = ((size / moduleTotal) * 100).toFixed(1).padStart(5);
console.log(` ${mb(size).padStart(10)} ${share}% ${bucket}`);
}
if (ranked.length > TOP) {
const rest = ranked.slice(TOP).reduce((sum, [, size]) => sum + size, 0);
console.log(` ${mb(rest).padStart(10)} ... and ${ranked.length - TOP} more`);
}
}

const files = process.argv.slice(2);
if (!files.length) {
console.error('usage: node scripts/analyze-bundle.mjs <stats.json> [...]');
process.exit(1);
}
for (const file of files) {
if (!existsSync(file)) {
console.error(`no such stats file: ${file}`);
process.exit(1);
}
analyze(file);
}