Skip to content

Commit bbf04c6

Browse files
committed
fix(runtime): preserve preact source identity across rerenders
1 parent 65bf144 commit bbf04c6

5 files changed

Lines changed: 133 additions & 3 deletions

File tree

packages/runtime/src/manager.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export class DefaultRuntimeManager implements RuntimeManager {
150150
private readonly browserModuleUrlCache = new Map<string, string>();
151151
private readonly browserModuleInflight = new Map<string, Promise<string>>();
152152
private readonly browserBlobUrls = new Set<string>();
153+
private readonly browserBlobUrlsByCode = new Map<string, string>();
153154
private initialized = false;
154155

155156
constructor(options: RuntimeManagerOptions = {}) {
@@ -209,6 +210,7 @@ export class DefaultRuntimeManager implements RuntimeManager {
209210
this.states.clear();
210211
this.browserModuleUrlCache.clear();
211212
this.browserModuleInflight.clear();
213+
this.browserBlobUrlsByCode.clear();
212214
this.revokeBrowserBlobUrls();
213215
}
214216

@@ -1097,7 +1099,11 @@ export class DefaultRuntimeManager implements RuntimeManager {
10971099
}
10981100

10991101
private createBrowserBlobModuleUrl(code: string): string {
1100-
return createBrowserBlobModuleUrl(code, this.browserBlobUrls);
1102+
return createBrowserBlobModuleUrl(
1103+
code,
1104+
this.browserBlobUrls,
1105+
this.browserBlobUrlsByCode,
1106+
);
11011107
}
11021108

11031109
private revokeBrowserBlobUrls(): void {

packages/runtime/src/runtime-component-runtime.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ type PreactLikeClassComponent = new (
2929
render(...args: unknown[]): unknown;
3030
};
3131

32+
const PREACT_FUNCTION_COMPONENT_WRAPPERS = new WeakMap<
33+
(props: Record<string, JsonValue>) => unknown,
34+
(props: Record<string, JsonValue>) => unknown
35+
>();
36+
const PREACT_CLASS_COMPONENT_WRAPPERS = new WeakMap<
37+
PreactLikeClassComponent,
38+
PreactLikeClassComponent
39+
>();
40+
3241
export type RuntimeComponentFactory = (
3342
props: Record<string, JsonValue>,
3443
context: RuntimeExecutionContext,
@@ -244,7 +253,12 @@ function isPreactClassComponent(value: unknown): boolean {
244253
function wrapPreactClassComponent(
245254
sourceComponent: PreactLikeClassComponent,
246255
): PreactLikeClassComponent {
247-
return class RenderifyPreactSourceClassWrapper extends sourceComponent {
256+
const cached = PREACT_CLASS_COMPONENT_WRAPPERS.get(sourceComponent);
257+
if (cached) {
258+
return cached;
259+
}
260+
261+
const wrapped = class RenderifyPreactSourceClassWrapper extends sourceComponent {
248262
render(...args: unknown[]): unknown {
249263
const output = super.render(...args);
250264
if (isPlainObjectPreactOutput(output)) {
@@ -255,12 +269,20 @@ function wrapPreactClassComponent(
255269
return output;
256270
}
257271
};
272+
273+
PREACT_CLASS_COMPONENT_WRAPPERS.set(sourceComponent, wrapped);
274+
return wrapped;
258275
}
259276

260277
function wrapPreactFunctionComponent(
261278
sourceComponent: (props: Record<string, JsonValue>) => unknown,
262279
): (props: Record<string, JsonValue>) => unknown {
263-
return function RenderifyPreactSourceWrapper(
280+
const cached = PREACT_FUNCTION_COMPONENT_WRAPPERS.get(sourceComponent);
281+
if (cached) {
282+
return cached;
283+
}
284+
285+
const wrapped = function RenderifyPreactSourceWrapper(
264286
props: Record<string, JsonValue>,
265287
): unknown {
266288
const output = sourceComponent(props);
@@ -271,6 +293,9 @@ function wrapPreactFunctionComponent(
271293
}
272294
return output;
273295
};
296+
297+
PREACT_FUNCTION_COMPONENT_WRAPPERS.set(sourceComponent, wrapped);
298+
return wrapped;
274299
}
275300

276301
async function loadPreactModule(): Promise<PreactLikeModule | undefined> {

packages/runtime/src/runtime-source-utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,19 @@ export async function parseImportSpecifiersFromSource(
4848
export function createBrowserBlobModuleUrl(
4949
code: string,
5050
browserBlobUrls: Set<string>,
51+
browserBlobUrlsByCode?: Map<string, string>,
5152
): string {
53+
const cached = browserBlobUrlsByCode?.get(code);
54+
if (cached) {
55+
browserBlobUrls.add(cached);
56+
return cached;
57+
}
58+
5259
const blobUrl = URL.createObjectURL(
5360
new Blob([code], { type: "text/javascript" }),
5461
);
5562
browserBlobUrls.add(blobUrl);
63+
browserBlobUrlsByCode?.set(code, blobUrl);
5664
return blobUrl;
5765
}
5866

tests/runtime-source-utils.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { createBrowserBlobModuleUrl } from "../packages/runtime/src/runtime-source-utils";
4+
5+
test("createBrowserBlobModuleUrl reuses cached urls for identical code", () => {
6+
const originalCreateObjectURL = URL.createObjectURL;
7+
let createdCount = 0;
8+
9+
Object.defineProperty(URL, "createObjectURL", {
10+
configurable: true,
11+
value: (_blob: Blob) => `blob:renderify-${++createdCount}`,
12+
});
13+
14+
try {
15+
const browserBlobUrls = new Set<string>();
16+
const browserBlobUrlsByCode = new Map<string, string>();
17+
18+
const first = createBrowserBlobModuleUrl(
19+
"export default 1;",
20+
browserBlobUrls,
21+
browserBlobUrlsByCode,
22+
);
23+
const second = createBrowserBlobModuleUrl(
24+
"export default 1;",
25+
browserBlobUrls,
26+
browserBlobUrlsByCode,
27+
);
28+
29+
assert.equal(first, second);
30+
assert.equal(createdCount, 1);
31+
assert.deepEqual([...browserBlobUrls], [first]);
32+
} finally {
33+
Object.defineProperty(URL, "createObjectURL", {
34+
configurable: true,
35+
value: originalCreateObjectURL,
36+
});
37+
}
38+
});

tests/runtime.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
renderPlanInBrowser,
2626
renderTrustedPlanInBrowser,
2727
} from "../packages/runtime/src/index";
28+
import { createPreactRenderArtifact } from "../packages/runtime/src/runtime-component-runtime";
2829
import { DefaultSecurityChecker } from "../packages/security/src/index";
2930

3031
class MockLoader implements RuntimeModuleLoader {
@@ -3225,6 +3226,58 @@ test("runtime emits preact render artifact for source.runtime=preact modules", a
32253226
await runtime.terminate();
32263227
});
32273228

3229+
test("runtime reuses stable preact wrapper identities for repeated renders", async () => {
3230+
function FunctionDashboard(): null {
3231+
return null;
3232+
}
3233+
3234+
class ClassDashboard {
3235+
render(): null {
3236+
return null;
3237+
}
3238+
}
3239+
3240+
const firstFunctionArtifact = await createPreactRenderArtifact({
3241+
sourceExport: FunctionDashboard,
3242+
runtimeInput: {},
3243+
diagnostics: [],
3244+
});
3245+
const secondFunctionArtifact = await createPreactRenderArtifact({
3246+
sourceExport: FunctionDashboard,
3247+
runtimeInput: {},
3248+
diagnostics: [],
3249+
});
3250+
assert.equal(firstFunctionArtifact?.mode, "preact-vnode");
3251+
assert.equal(secondFunctionArtifact?.mode, "preact-vnode");
3252+
assert.equal(
3253+
Object.is(
3254+
(firstFunctionArtifact as { payload: { type: unknown } }).payload.type,
3255+
(secondFunctionArtifact as { payload: { type: unknown } }).payload.type,
3256+
),
3257+
true,
3258+
);
3259+
3260+
const firstClassArtifact = await createPreactRenderArtifact({
3261+
sourceExport: ClassDashboard,
3262+
runtimeInput: {},
3263+
diagnostics: [],
3264+
});
3265+
const secondClassArtifact = await createPreactRenderArtifact({
3266+
sourceExport: ClassDashboard,
3267+
runtimeInput: {},
3268+
diagnostics: [],
3269+
});
3270+
assert.equal(firstClassArtifact?.mode, "preact-vnode");
3271+
assert.equal(secondClassArtifact?.mode, "preact-vnode");
3272+
assert.equal(
3273+
Object.is(
3274+
(firstClassArtifact as { payload: { type: unknown } }).payload.type,
3275+
(secondClassArtifact as { payload: { type: unknown } }).payload.type,
3276+
),
3277+
true,
3278+
);
3279+
});
3280+
32283281
test("runtime preact rendering rejects plain object component output", async () => {
32293282
const runtime = new DefaultRuntimeManager({
32303283
sourceTranspiler: new PassthroughSourceTranspiler(),

0 commit comments

Comments
 (0)