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
16 changes: 12 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,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();
});
Expand Down
16 changes: 14 additions & 2 deletions examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} } });
Expand All @@ -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', () => {
Expand Down
25 changes: 23 additions & 2 deletions packages/workbench/tests/overview.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
ScriptedAlchemy marked this conversation as resolved.
};
expectMonotonicOwnedReloadFrames();
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 @@ -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);
Expand Down
Loading