Skip to content
Closed
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
14 changes: 10 additions & 4 deletions examples/rsc-agent-runtime/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const runtimeAppReloadPlugin = (
onAppReload: NonNullable<RscRuntimeRsbuildConfigOptions['onAppReload']>,
): RsbuildPlugin => {
let devServer: RsbuildDevServer | undefined;
let lastAppCompilation: object | string | undefined;
let lastAppCompilation: string | undefined;
return {
name: 'agent-bundle:rsc-runtime-app-reload',
setup(api) {
Expand All @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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', () => {
Expand Down
27 changes: 24 additions & 3 deletions packages/workbench/tests/overview.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string> => {
const value = await runtimeIdentity.getAttribute(name);
if (value === null) throw new Error(`Runtime identity omitted ${name}.`);
Expand All @@ -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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P2] Reject reload ordinals minted during config reconciliation

This prefix assertion allows arbitrary new ordinals after the saved prefix. If the two initial watched-source edits coalesce into generation 1, then a spurious reload caused by the later finite, invalid, or repaired config write can become generation 2 and this helper still passes. That weakens the prior contract that those reconciliations retain the existing preview without emitting another App reload. First wait for the edit-triggered sequence to settle, snapshot it, and require the sequence to remain exactly unchanged throughout the config-reconcile phase (while still tolerating duplicate frames for already-seen ordinals).

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);
Expand Down
Loading