diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index c20215cfc..7c8201e72 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -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'; @@ -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, ): RsbuildPlugin => { let devServer: RsbuildDevServer | undefined; - let lastAppCompilation: string | undefined; + let lastAppOutput: string | undefined; return { name: 'agent-bundle:rsc-runtime-app-reload', setup(api) { @@ -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(); diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index e9c2b2f9a..4055338db 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -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'), @@ -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: {} } }); @@ -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]); diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index 5dbe1265d..7e47bd4e2 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -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> | undefined; @@ -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 => { 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'); @@ -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();