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
52 changes: 38 additions & 14 deletions examples/rsc-agent-runtime/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { createHash } from 'node:crypto';
import { rm } from 'node:fs/promises';
import { dirname, join } from 'node:path';

import { defineConfig, type RsbuildConfig, type RsbuildDevServer, type RsbuildPlugin } from '@rsbuild/core';
import {
defineConfig,
type RsbuildConfig,
type RsbuildDevServer,
type RsbuildPlugin,
type Rspack,
} from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { Layers, pluginRSC } from 'rsbuild-plugin-rsc';

Expand Down Expand Up @@ -76,11 +82,31 @@ export interface RscRuntimeRsbuildConfigOptions {
}>;
}

const appOutputContentHash = (stats: Rspack.Stats): string | undefined => {
try {
const assets = [...stats.compilation.getAssets()].sort((left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
const hash = createHash('sha256');
hash.update(`${assets.length}:`);
for (const asset of assets) {
const name = Buffer.from(asset.name);
const content = asset.source.buffer();
hash.update(`${name.byteLength}:`);
hash.update(name);
hash.update(`${content.byteLength}:`);
hash.update(content);
}
return hash.digest('hex');
} catch {
return undefined;
}
};

const runtimeAppReloadPlugin = (
onAppReload: NonNullable<RscRuntimeRsbuildConfigOptions['onAppReload']>,
): RsbuildPlugin => {
let devServer: RsbuildDevServer | undefined;
let lastAppCompilation: string | undefined;
let lastAppOutput: string | undefined;
return {
name: 'agent-bundle:rsc-runtime-app-reload',
setup(api) {
Expand All @@ -91,24 +117,22 @@ const runtimeAppReloadPlugin = (
});
api.onBeforeStartDevServer(({ server }) => {
devServer = server;
lastAppCompilation = undefined;
lastAppOutput = undefined;
});
api.onCloseDevServer(() => {
devServer = undefined;
lastAppCompilation = undefined;
lastAppOutput = undefined;
});
api.onAfterEnvironmentCompile(({ environment, isFirstCompile, stats }) => {
if (devServer === undefined || environment.name !== 'app' || stats === undefined || stats.hasErrors()) return;
// Hashed completions dedupe by hash. A hashless success is
// unidentifiable, not proven unchanged, so it still reloads
// (at-least-once; consumers dedupe by generation ordinal) - but it
// must neither dedupe by stats object identity (every completion
// looks unique) nor clobber the retained hash, which would mint a
// spurious frame for the next unchanged hashed completion.
const hash = stats.hash;
if (typeof hash === 'string' && hash.length > 0) {
if (lastAppCompilation === hash) return;
lastAppCompilation = hash;
// Rspack stats hashes can change across watch completions whose
// emitted App bytes are identical. The complete asset set is the
// browser-visible identity; an unreadable set remains unidentifiable
// and reloads at least once without clobbering the retained identity.
const output = appOutputContentHash(stats);
if (output !== undefined) {
if (lastAppOutput === output) return;
lastAppOutput = output;
}
if (isFirstCompile) return;
onAppReload();
Expand Down
43 changes: 30 additions & 13 deletions examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ test('requires the App environment through the public Rsbuild compiler hook', as
expect(() => afterCreate?.({ environments: { app: {} } })).not.toThrow();
});

test('emits one owned App reload for each later successful changed App compilation', async () => {
test('emits one owned App reload for changed output and none for an unchanged-output recompile', async () => {
const reloads: number[] = [];
const config = createRscRuntimeRsbuildConfig({
compilerRoot: join(tmpdir(), 'rsc-provider-app-reload'),
Expand All @@ -302,14 +302,31 @@ test('emits one owned App reload for each later successful changed App compilati
onCloseDevServer: (callback: unknown) => { closeDevServer = callback as () => unknown; },
});

const firstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: true, stats: { hasErrors: () => false, hash: 'app-change-a' } });
const duplicateFirstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-a' } });
const appBUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-b' } });
const appAUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-a' } });
const repeatedAppBUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-b' } });
const appUpdate = (statsHash: string, outputContent: string, isFirstCompile = false) => Object.freeze({
environment: Object.freeze({ name: 'app' }),
isFirstCompile,
stats: Object.freeze({
compilation: Object.freeze({
getAssets: () => Object.freeze([
Object.freeze({
name: 'edit-timeline-v1.html',
source: Object.freeze({ buffer: () => Buffer.from(outputContent) }),
}),
]),
}),
hasErrors: () => false,
hash: statsHash,
}),
});
const firstAppUpdate = appUpdate('app-change-a', 'app-output-a', true);
const duplicateFirstAppUpdate = appUpdate('split-app-change-a', 'app-output-a');
const appBUpdate = appUpdate('app-change-b', 'app-output-b');
const duplicateAppBUpdate = appUpdate('split-app-change-b', 'app-output-b');
const appAUpdate = appUpdate('returned-app-change-a', 'app-output-a');
const repeatedAppBUpdate = appUpdate('returned-app-change-b', 'app-output-b');
const failedAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => true } });
const unidentifiableAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'unreadable-output' } });
const hashlessAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false } });
const emptyHashAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: '' } });
const nonAppUpdate = Object.freeze({ environment: { name: 'widget' }, isFirstCompile: false, stats: { hasErrors: () => false } });

afterCompiler?.({ environments: { app: {}, widget: {} } });
Expand All @@ -324,17 +341,17 @@ test('emits one owned App reload for each later successful changed App compilati

afterEnvironmentCompile?.(appBUpdate);
expect(reloads).toEqual([1]);
afterEnvironmentCompile?.(duplicateAppBUpdate);
expect(reloads).toEqual([1]);
afterEnvironmentCompile?.(appAUpdate);
expect(reloads).toEqual([1, 2]);
afterEnvironmentCompile?.(repeatedAppBUpdate);
expect(reloads).toEqual([1, 2, 3]);
// A hashless success is unidentifiable, not unchanged: it still reloads
// (at-least-once; consumers dedupe by generation ordinal), but it must not
// dedupe by stats object identity or clobber the retained hash - the
// unchanged hashed completion after it stays deduped instead of minting a
// spurious frame (issue #111 secondary defect).
// An unreadable asset set is unidentifiable even when stats has a hash. It
// reloads at least once without clobbering the retained output identity, so
// the later readable completion remains deduped.
afterEnvironmentCompile?.(unidentifiableAppUpdate);
afterEnvironmentCompile?.(hashlessAppUpdate);
afterEnvironmentCompile?.(emptyHashAppUpdate);
expect(reloads).toEqual([1, 2, 3, 4, 5]);
afterEnvironmentCompile?.(repeatedAppBUpdate);
expect(reloads).toEqual([1, 2, 3, 4, 5]);
Expand Down
25 changes: 11 additions & 14 deletions packages/workbench/tests/overview.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,7 @@ e2e('redirects a direct Runtime deep link only after capability discovery report
}
});

// Flaky under hosted-runner load (watch delivery can split one edit into an
// extra reload generation, #200); retry while the root cause stays open.
e2e('offers the host-owned MCP playground handoff only after a selected Runtime App succeeds', { retry: 2, timeout: 120_000 }, async ({ page }) => {
e2e('offers the host-owned MCP playground handoff only after a selected Runtime App succeeds', { timeout: 120_000 }, async ({ page }) => {
const fixture = await startRuntimePlaygroundFixture();
let clientPage: Page | undefined;
let clientSurface: Awaited<ReturnType<typeof fixture.openRuntimeClientSurface>> | undefined;
Expand Down Expand Up @@ -314,7 +312,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime
}, fixture.url);
const initialProjectSource = await readProjectSource();
// Source status carries the package identity derived from package.json (#94).
expect(initialProjectSource).toEqual({ diagnostics: [], packageName: '@agent-bundle/rsc-agent-runtime-demo', revision: sourceRevision, state: 'ready' });
expect(initialProjectSource).toEqual({ diagnostics: [], packageName: '@agent-bundle/rsc-agent-runtime-demo', packageVersion: '1.0.0', revision: sourceRevision, state: 'ready' });
const expectRuntimeProfileInspection = async (preview: Locator, expectedSourceRevision: string): Promise<void> => {
await expect(preview.getByLabel('Simulated MCP App profile')).toContainText('Portable MCP Apps');
await expect(preview.getByLabel('Simulated MCP App profile')).toContainText('agent-bundle:mcp-apps:2026-01-26');
Expand Down Expand Up @@ -405,16 +403,15 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime
expect(runtimeAppRequests.filter((request) => request === 'POST /api/runtime/apps')).toHaveLength(1);
expect(runtimeAppRequests.filter((request) => request.startsWith('DELETE /api/runtime/apps/'))).toHaveLength(0);
expect(runtimeAppResponses).toHaveLength(1);
// Frame delivery is asynchronous relative to the assertions above: the
// two replaced watched sources compile as one coalesced or two split App
// generations (multi-compiler watch delivery skews under load), so a
// second legitimate reload frame may surface after this point. Consume
// the owned channel monotonically over generation ordinals (issue #111)
// instead of pinning an exact frame snapshot: the accepted connection
// replays generation 0, a frame may repeat the current generation, an
// advance is exactly one, and nothing past the edit budget may ever
// arrive - a config reconcile that reloads the retained App still fails
// here once the edits' generations are spent.
// Each atomic replacement changes the emitted App asset set and spends
// one generation. Split watch completions with identical emitted bytes
// are content-deduped before this channel, so load cannot spend another.
// Consume the owned channel monotonically over generation ordinals
// (issue #111) instead of pinning an exact frame snapshot: the accepted
// connection replays generation 0, a frame may repeat the current
// generation, an advance is exactly one, and nothing past the edit
// budget may ever arrive - a config reconcile that reloads the retained
// App still fails here once the edits' generations are spent.
const ownedReloadGenerationBudget = 2;
const expectMonotonicOwnedReloadFrames = (): void => {
const generations = ownedReloadFrames();
Expand Down
Loading