diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index b2108c208..c20215cfc 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -80,7 +80,7 @@ const runtimeAppReloadPlugin = ( onAppReload: NonNullable, ): RsbuildPlugin => { let devServer: RsbuildDevServer | undefined; - let lastAppCompilation: object | string | undefined; + let lastAppCompilation: string | undefined; return { name: 'agent-bundle:rsc-runtime-app-reload', setup(api) { @@ -99,9 +99,17 @@ const runtimeAppReloadPlugin = ( }); api.onAfterEnvironmentCompile(({ environment, isFirstCompile, stats }) => { if (devServer === undefined || environment.name !== 'app' || stats === undefined || stats.hasErrors()) return; - const compilation = typeof stats.hash === 'string' && stats.hash.length > 0 ? stats.hash : stats; - if (lastAppCompilation === compilation) return; - lastAppCompilation = compilation; + // 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; + } 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 ea62d8d04..eda04178e 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -308,6 +308,8 @@ test('emits one owned App reload for each later successful changed App compilati 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 failedAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => true } }); + 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: {} } }); @@ -326,14 +328,24 @@ test('emits one owned App reload for each later successful changed App compilati 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). + afterEnvironmentCompile?.(hashlessAppUpdate); + afterEnvironmentCompile?.(emptyHashAppUpdate); + expect(reloads).toEqual([1, 2, 3, 4, 5]); + afterEnvironmentCompile?.(repeatedAppBUpdate); + expect(reloads).toEqual([1, 2, 3, 4, 5]); await closeDevServer?.(); afterEnvironmentCompile?.(appAUpdate); - expect(reloads).toEqual([1, 2, 3]); + expect(reloads).toEqual([1, 2, 3, 4, 5]); beforeStartDevServer?.({ server: { environments: { app: {}, widget: {} } } }); afterEnvironmentCompile?.(appBUpdate); - expect(reloads).toEqual([1, 2, 3, 4]); + expect(reloads).toEqual([1, 2, 3, 4, 5, 6]); }); test('keeps compiler-App HMR out of the opaque browser child', () => { diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index 6074a6e53..740596fea 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -403,7 +403,28 @@ 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); - const hmrMessagesBeforeConfigReconcile = [...runtimePreviewHmrMessages]; + // 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. + const ownedReloadGenerationBudget = 2; + const expectMonotonicOwnedReloadFrames = (): void => { + const generations = ownedReloadFrames(); + // Non-reload traffic on the owned channel is a channel defect. + expect(generations).toHaveLength(runtimePreviewHmrMessages.length); + const violations = generations.filter((generation, index) => (index === 0 + ? generation !== 0 + : generation !== generations[index - 1] && generation !== generations[index - 1]! + 1)); + expect({ generations, violations }).toEqual({ generations, violations: [] }); + expect(Math.max(...generations)).toBeLessThanOrEqual(ownedReloadGenerationBudget); + }; + expectMonotonicOwnedReloadFrames(); const runtimeAttribute = async (name: string): Promise => { const value = await runtimeIdentity.getAttribute(name); if (value === null) throw new Error(`Runtime identity omitted ${name}.`); @@ -427,7 +448,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime await expect(runtimeIdentity).toHaveAttribute('data-runtime-source-revision', sourceRuntimeIdentity.sourceRevision); await expect(runtimeIdentity).toHaveAttribute('data-runtime-state-version', sourceRuntimeIdentity.stateVersion); expect(runtimePreviewHmrSockets).toHaveLength(1); - expect(runtimePreviewHmrMessages).toEqual(hmrMessagesBeforeConfigReconcile); + expectMonotonicOwnedReloadFrames(); 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);