Skip to content

Commit 0e55c24

Browse files
fix(playground): defer loading of large emitters until selected (#11508)
Fixes #11506 ## Problem `typespec.io/playground` hangs forever on iOS. The standalone playground (`cadlplayground.z22.web.core.windows.net`) works fine. The difference: `website/src/components/playground-component/import-map.ts` adds `@typespec/http-client-python` and `@typespec/http-client-csharp` to the website's import map, and `browser-host.ts` eagerly `import()`s **every** library in the map at startup — before the user has picked an emitter. `@typespec/http-client-python` boots a full Pyodide (CPython/WASM) runtime as a module-level side effect, which alone pulls ~23 MB of extra payload and ~270 MB of resident memory. Measured with Playwright WebKit under iPhone 15 emulation (`ps` RSS of the `WebKit.WebContent` process): | Page | RSS | | --- | --- | | typespec.io home | 195 MB | | standalone playground (works on real iPhone) | 547 MB | | typespec.io/playground?version=1.13.x (no extra emitters — **works on real iPhone**) | 463 MB | | typespec.io/playground (as shipped — **hangs on real iPhone**) | 741 MB | ## Fix Add a `deferredEmitters` option. Libraries named there are registered as placeholder entries at startup (so they still show up in the emitter dropdown) and are only imported when a compilation actually needs them — i.e. when they're the selected emitter or listed under `emit` in the tspconfig. The website passes the two heavy client emitters. Nothing is downloaded or evaluated for them until you select one. Caveat, documented on the option: a deferred library can't be referenced by an `import` statement in TypeSpec source. That's fine for these two — they're pure emitters with no decorators or `.tsp` surface. ## Relation to #11507 #11507 makes the Pyodide boot lazy inside the Python emitter. That's the right fix at the source, but the website resolves emitter bundles at **runtime** from blob storage (`typespec.blob.core.windows.net/pkgs/<name>/latest.json`), uploaded by a separate ADO stage. So it can't take effect — or be validated in a PR preview — until the emitter is republished. This PR fixes the site independently of that, and also cuts startup cost for the C# emitter. ## Tests - `packages/playground/test/browser-host-deferred.test.ts` — deferral, exclusion from the virtual `package.json` dependencies, load-once memoization, retry after a failed load, no-op for non-deferred libs. - `packages/playground/test/deferred-emitter-compile.test.ts` — end-to-end against the **real** compiler: compiling with a deferred emitter fails to resolve it, then succeeds and writes its output after `loadLibrary()`. Full playground suite: 55/55 green. `tsc --noEmit` clean for `packages/playground` and `website/src`. ## Not fixed here Monaco web workers fail to start on typespec.io on desktop *and* mobile (`Could not create web worker(s). Falling back to loading web worker code in main thread`) — `MonacoEnvironment.getWorker` isn't defined. Pre-existing and unrelated to the hang, but worth a follow-up: everything currently runs on the main thread. ## Why this is an opt-in list rather than "defer every emitter" `isEmitter` comes from `$lib.emitter`, which only exists once the bundle has been evaluated. Neither the blob index (`latest.json` is just `{version, imports}`) nor `package.json` carries an emitter marker, so there is no way to populate the emitter dropdown without importing every candidate. Deferring everything would leave the dropdown empty. Deferring *all* emitters would also break the ones that ship TypeSpec surface — `@typespec/openapi3` (`lib/decorators.tsp`), `@typespec/json-schema`, `@typespec/protobuf` — since an `import "@typespec/json-schema";` in the editor would fail to resolve before the emitter is ever selected. `http-client-python` and `http-client-csharp` are the only two in the map that are pure emitters with no `.tsp`. The payoff is also concentrated in those two: with pyodide blocked the page sits at 474 MB vs 463 MB for `?version=1.13.x` (which loads no additional packages at all), so everything else the website pulls in eagerly — including the ~7 Azure libraries that `http-client-python`'s import map drags along — is only ~11 MB combined. Possible follow-up, not done here: the host's `readFile`/`stat` are async, so a miss under `node_modules/<deferred-lib>/` could trigger the load mid-resolution. That would remove the "cannot be referenced by an `import` statement" restriction and make the list safe to expand without auditing each library.
1 parent 14feb1d commit 0e55c24

9 files changed

Lines changed: 418 additions & 21 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/playground"
5+
---
6+
7+
Add support for deferring the loading of emitter libraries until they are selected. Configure with the new `deferredEmitters` option to avoid downloading and evaluating large emitters on startup.

packages/playground/src/browser-host.ts

Lines changed: 120 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export function resolveVirtualPath(path: string, ...paths: string[]) {
1212
export interface BrowserHostCreateOptions {
1313
readonly compiler: typeof import("@typespec/compiler");
1414
readonly libraries: Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_: any }>;
15+
16+
/**
17+
* Libraries that are known to the playground but have not been imported yet, keyed by name.
18+
* Each entry imports the library when called. See {@link BrowserHost.loadLibrary}.
19+
*/
20+
readonly deferredLibraries?: Record<string, () => Promise<LoadedPlaygroundTspLibrary>>;
1521
}
1622

1723
/**
@@ -21,10 +27,24 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br
2127
const virtualFs = new Map<string, string>();
2228
const jsImports = new Map<string, Promise<any>>();
2329

24-
const libraries: Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_: any }> = {
30+
const libraries: Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_?: any }> = {
2531
...options.libraries,
2632
};
2733

34+
const deferredLoaders = new Map(Object.entries(options.deferredLibraries ?? {}));
35+
const pendingLoads = new Map<string, Promise<void>>();
36+
37+
for (const name of deferredLoaders.keys()) {
38+
// A placeholder keeps the emitter visible in the UI (dropdown, settings) without paying the
39+
// cost of importing it. It is replaced by the real library on the first `loadLibrary` call.
40+
libraries[name] = {
41+
name,
42+
isEmitter: true,
43+
deferred: true,
44+
packageJson: { name, version: "" } as any,
45+
};
46+
}
47+
2848
function registerLibraryFiles(
2949
libName: string,
3050
lib: PlaygroundTspLibrary & { _TypeSpecLibrary_: any },
@@ -43,13 +63,42 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br
4363
JSON.stringify({
4464
name: "playground-pkg",
4565
dependencies: Object.fromEntries(
46-
Object.values(libraries).map((x) => [x.name, x.packageJson.version]),
66+
// Deferred libraries have no files registered yet, so listing them would make the
67+
// compiler resolve a dependency it cannot read.
68+
Object.values(libraries)
69+
.filter((x) => !x.deferred)
70+
.map((x) => [x.name, x.packageJson.version]),
4771
),
4872
}),
4973
);
5074
}
5175

52-
for (const [libName, lib] of Object.entries(libraries)) {
76+
function loadLibrary(name: string): Promise<void> {
77+
const loader = deferredLoaders.get(name);
78+
if (loader === undefined) {
79+
return Promise.resolve();
80+
}
81+
let pending = pendingLoads.get(name);
82+
if (pending === undefined) {
83+
pending = loader().then(
84+
(lib) => {
85+
libraries[name] = lib;
86+
registerLibraryFiles(name, lib);
87+
updatePackageJson();
88+
deferredLoaders.delete(name);
89+
},
90+
(error) => {
91+
// Drop the cached promise so a later compilation can retry after a transient failure.
92+
pendingLoads.delete(name);
93+
throw error;
94+
},
95+
);
96+
pendingLoads.set(name, pending);
97+
}
98+
return pending;
99+
}
100+
101+
for (const [libName, lib] of Object.entries(options.libraries)) {
53102
registerLibraryFiles(libName, lib);
54103
}
55104
updatePackageJson();
@@ -61,6 +110,7 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br
61110
return {
62111
compiler: options.compiler,
63112
libraries,
113+
loadLibrary,
64114
async readUrl(url: string) {
65115
const contents = virtualFs.get(url);
66116
if (contents === undefined) {
@@ -183,6 +233,28 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br
183233
};
184234
}
185235

236+
/**
237+
* A library that has been imported, along with the raw bundle payload used to populate the
238+
* in-memory file system.
239+
* @internal
240+
*/
241+
export type LoadedPlaygroundTspLibrary = PlaygroundTspLibrary & { _TypeSpecLibrary_: any };
242+
243+
async function importPlaygroundLibrary(
244+
libName: string,
245+
importOptions: LibraryImportOptions,
246+
): Promise<LoadedPlaygroundTspLibrary> {
247+
const { _TypeSpecLibrary_, $lib, $linter } = (await importLibrary(libName, importOptions)) as any;
248+
return {
249+
name: libName,
250+
isEmitter: $lib?.emitter,
251+
definition: $lib,
252+
packageJson: JSON.parse(_TypeSpecLibrary_.typespecSourceFiles["package.json"]),
253+
linter: $linter,
254+
_TypeSpecLibrary_,
255+
};
256+
}
257+
186258
/**
187259
* Load libraries in parallel from the given list.
188260
* @param libsToLoad List of library names. Must be available in the webpage importmap.
@@ -191,43 +263,73 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br
191263
export async function loadLibraries(
192264
libsToLoad: readonly string[],
193265
importOptions: LibraryImportOptions = {},
194-
): Promise<Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_: any }>> {
266+
): Promise<Record<string, LoadedPlaygroundTspLibrary>> {
195267
const entries = await Promise.all(
196268
libsToLoad.map(async (libName) => {
197-
const { _TypeSpecLibrary_, $lib, $linter } = (await importLibrary(
198-
libName,
199-
importOptions,
200-
)) as any;
201-
const lib: PlaygroundTspLibrary & { _TypeSpecLibrary_: any } = {
202-
name: libName,
203-
isEmitter: $lib?.emitter,
204-
definition: $lib,
205-
packageJson: JSON.parse(_TypeSpecLibrary_.typespecSourceFiles["package.json"]),
206-
linter: $linter,
207-
_TypeSpecLibrary_,
208-
};
209-
return [libName, lib] as const;
269+
return [libName, await importPlaygroundLibrary(libName, importOptions)] as const;
210270
}),
211271
);
212272
return Object.fromEntries(entries);
213273
}
214274

275+
/**
276+
* Options for creating the browser host.
277+
*/
278+
export interface BrowserHostOptions {
279+
/**
280+
* Emitters that should not be imported until they are used.
281+
*
282+
* Importing a library evaluates its module, which for some emitters means downloading a large
283+
* runtime up front. Names listed here are shown in the emitter list right away but are only
284+
* imported once selected, which keeps the initial load cheap.
285+
*
286+
* Only use this for pure emitters: a deferred library cannot be referenced by an `import`
287+
* statement in the TypeSpec source, since its files are not registered until it is loaded.
288+
*/
289+
readonly deferredEmitters?: readonly string[];
290+
}
291+
292+
/**
293+
* Split the libraries to load into the ones to import now and the ones to import on demand.
294+
* @internal
295+
*/
296+
export function splitDeferredLibraries(
297+
libsToLoad: readonly string[],
298+
deferredEmitters: readonly string[] = [],
299+
): { eager: string[]; deferred: string[] } {
300+
const deferredSet = new Set(deferredEmitters);
301+
return {
302+
eager: libsToLoad.filter((x) => !deferredSet.has(x)),
303+
deferred: libsToLoad.filter((x) => deferredSet.has(x)),
304+
};
305+
}
306+
215307
/**
216308
* Create the browser host from the list of libraries.
217309
* @param libsToLoad List of libraries to load. Those must be set in the webpage importmap.
218310
* @param importOptions Import configuration.
311+
* @param options Additional host options.
219312
* @returns
220313
*/
221314
export async function createBrowserHost(
222315
libsToLoad: readonly string[],
223316
importOptions: LibraryImportOptions = {},
317+
options: BrowserHostOptions = {},
224318
): Promise<BrowserHost> {
319+
const { eager, deferred } = splitDeferredLibraries(libsToLoad, options.deferredEmitters);
320+
225321
const [libraries, compiler] = await Promise.all([
226-
loadLibraries(libsToLoad, importOptions),
322+
loadLibraries(eager, importOptions),
227323
importTypeSpecCompiler(importOptions),
228324
]);
325+
326+
const deferredLibraries = Object.fromEntries(
327+
deferred.map((name) => [name, () => importPlaygroundLibrary(name, importOptions)] as const),
328+
);
329+
229330
return createBrowserHostInternal({
230331
compiler,
231332
libraries,
333+
deferredLibraries,
232334
});
233335
}

packages/playground/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { createBrowserHost } from "./browser-host.js";
1+
export { createBrowserHost, type BrowserHostOptions } from "./browser-host.js";
22
export { registerMonacoDefaultWorkersForVite } from "./monaco-worker.js";
33
export { registerMonacoLanguage } from "./services.js";
44
export { createUrlStateStorage, type StateStorage, type UrlStateStorage } from "./state-storage.js";

packages/playground/src/react/compilation/compile.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,21 @@ export async function compile(
1818
try {
1919
const typespecCompiler = host.compiler;
2020

21+
// Deferred emitters are only imported once they are actually used.
22+
if (selectedEmitter) {
23+
await host.loadLibrary(selectedEmitter);
24+
}
25+
2126
// Resolve the compiler options natively from the tspconfig.yaml so the playground
2227
// honors the full config (emit, options, linter, imports, warn-as-error, ...).
2328
const [resolvedOptions] = await typespecCompiler.resolveCompilerOptions(host, {
2429
cwd: resolveVirtualPath("."),
2530
entrypoint: resolveVirtualPath("main.tsp"),
2631
});
2732

33+
// The tspconfig.yaml can request emitters other than the selected one.
34+
await Promise.all((resolvedOptions.emit ?? []).map((name) => host.loadLibrary(name)));
35+
2836
const options: CompilerOptions = {
2937
...resolvedOptions,
3038
options: {

packages/playground/src/react/standalone.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ import {
3636
export interface ReactPlaygroundConfig extends Partial<PlaygroundProps> {
3737
readonly libraries: readonly string[];
3838
readonly importConfig?: LibraryImportOptions;
39+
/**
40+
* Emitters from {@link libraries} that should only be imported once selected, rather than when
41+
* the playground starts. Only valid for pure emitters, which are never referenced by an `import`
42+
* statement in the TypeSpec source.
43+
*/
44+
readonly deferredEmitters?: readonly string[];
3945
/** Content to show while the playground data is loading(Libraries) */
4046
readonly fallback?: ReactNode;
4147
}
@@ -51,15 +57,17 @@ function useStandalonePlaygroundContext(
5157
const [context, setContext] = useState<StandalonePlaygroundContext | undefined>();
5258
useEffect(() => {
5359
const load = async () => {
54-
const host = await createBrowserHost(config.libraries, config.importConfig);
60+
const host = await createBrowserHost(config.libraries, config.importConfig, {
61+
deferredEmitters: config.deferredEmitters,
62+
});
5563
await registerMonacoLanguage(host);
5664

5765
const stateStorage = createStandalonePlaygroundStateStorage();
5866
const initialState = stateStorage.load();
5967
setContext({ host, initialState, stateStorage });
6068
};
6169
void load();
62-
}, [config.importConfig, config.libraries]);
70+
}, [config.importConfig, config.libraries, config.deferredEmitters]);
6371
return context;
6472
}
6573

packages/playground/src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,23 @@ export interface PlaygroundTspLibrary {
3333
isEmitter: boolean;
3434
definition?: TypeSpecLibrary<any>;
3535
linter?: LinterDefinition;
36+
37+
/**
38+
* Whether the library module has been declared but not imported yet.
39+
*
40+
* Deferred emitters are only imported the first time they are used, so their `definition`,
41+
* `linter` and `packageJson` are placeholders until then.
42+
*/
43+
deferred?: boolean;
3644
}
3745

3846
export interface BrowserHost extends CompilerHost {
3947
compiler: typeof import("@typespec/compiler");
4048
libraries: Record<string, PlaygroundTspLibrary>;
49+
50+
/**
51+
* Import a library that was registered as a deferred emitter and make its files available to the
52+
* compiler. Resolves immediately for libraries that are already loaded.
53+
*/
54+
loadLibrary(name: string): Promise<void>;
4155
}

0 commit comments

Comments
 (0)