diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index b2108c208..f60f0379f 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,15 @@ 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. Hashless success cannot be proven + // unchanged, so it still reloads; do not fall back to stats object + // identity (every completion looks unique) and do not drop the event + // (that leaves the iframe on stale assets). + 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..6c36f2cc2 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -309,6 +309,8 @@ test('emits one owned App reload for each later successful changed App compilati 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 nonAppUpdate = Object.freeze({ environment: { name: 'widget' }, isFirstCompile: false, stats: { hasErrors: () => false } }); + 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: '' } }); afterCompiler?.({ environments: { app: {}, widget: {} } }); afterEnvironmentCompile?.(appBUpdate); @@ -326,14 +328,19 @@ 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]); + // Hashless success is unidentifiable, not unchanged: still reload (at-least- + // once). Extra frames are consumed monotonically in the overview e2e. + afterEnvironmentCompile?.(hashlessAppUpdate); + afterEnvironmentCompile?.(emptyHashAppUpdate); + 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 58024e4a5..398dce453 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -362,16 +362,35 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime ]); // The owned reload channel carries provider-authored frames only; a // changed App compile advances the generation past the connect replay. + // Frames are at-least-once: the proxy replays the current ordinal on + // connect, and one source change may burn more than one ordinal before + // the App compile coalesces (same shape as #20). Consume unique ordinals + // monotonically instead of counting frames. const ownedReloadFrames = (): readonly number[] => runtimePreviewHmrMessages.flatMap((message) => { try { const parsed = JSON.parse(message) as Readonly<{ readonly generation?: unknown; readonly kind?: unknown }>; - return parsed.kind === 'runtime-app-reload' && typeof parsed.generation === 'number' ? [parsed.generation] : []; + return parsed.kind === 'runtime-app-reload' && typeof parsed.generation === 'number' && Number.isSafeInteger(parsed.generation) + ? [parsed.generation] + : []; } catch { return []; } }); + const ownedReloadOrdinals = (): readonly number[] => [...new Set(ownedReloadFrames())].sort((left, right) => left - right); + const reloadFrameDiagnostic = (): string => + `generations=${JSON.stringify(ownedReloadFrames())} frames=${JSON.stringify(runtimePreviewHmrMessages)} eventHub=${JSON.stringify(fixture.eventHubState)}`; + const expectMonotonicReloadFrames = (): void => { + const generations = ownedReloadFrames(); + for (let index = 1; index < generations.length; index += 1) { + if (generations[index]! < generations[index - 1]!) { + throw new Error(`Runtime App reload generations must be non-decreasing: ${reloadFrameDiagnostic()}`); + } + } + }; await expect.poll(() => ownedReloadFrames().some((generation) => generation > 0), { timeout: browserTimeout }) .toBe(true); + expectMonotonicReloadFrames(); + expect(Math.max(0, ...ownedReloadOrdinals())).toBeGreaterThan(0); const refreshedWidget = async () => { for (const frame of page.frames()) { if (await frame.getByTestId('runtime-hmr-marker').count() === 1) return frame; @@ -391,7 +410,7 @@ 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]; + const reloadOrdinalsBeforeConfigReconcile = ownedReloadOrdinals(); const runtimeAttribute = async (name: string): Promise => { const value = await runtimeIdentity.getAttribute(name); if (value === null) throw new Error(`Runtime identity omitted ${name}.`); @@ -415,7 +434,9 @@ 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); + expectMonotonicReloadFrames(); + const reloadOrdinals = ownedReloadOrdinals(); + expect(reloadOrdinals.slice(0, reloadOrdinalsBeforeConfigReconcile.length)).toEqual(reloadOrdinalsBeforeConfigReconcile); 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);