From a043056cb33448b62d8646643afba1e0efa824df Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 31 Jul 2026 10:26:20 -0400 Subject: [PATCH 1/2] fix(playground): defer loading of large emitters until selected The playground eagerly imported every library in its import map on startup. On typespec.io that includes `@typespec/http-client-python` and `@typespec/http-client-csharp`, which are large enough to push iOS Safari past its per-tab memory budget, so the page never finished loading. Emitters listed in the new `deferredEmitters` option are now registered as placeholders and only imported when they are actually used for a compilation. --- ...round-emitter-loading-2026-7-31-10-30-0.md | 7 + packages/playground/src/browser-host.ts | 126 ++++++++++++++--- packages/playground/src/index.ts | 2 +- .../src/react/compilation/compile.ts | 8 ++ packages/playground/src/react/standalone.tsx | 12 +- packages/playground/src/types.ts | 14 ++ .../test/browser-host-deferred.test.ts | 127 ++++++++++++++++++ .../test/deferred-emitter-compile.test.ts | 124 +++++++++++++++++ .../playground-component/playground.tsx | 7 + 9 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 .chronus/changes/defer-playground-emitter-loading-2026-7-31-10-30-0.md create mode 100644 packages/playground/test/browser-host-deferred.test.ts create mode 100644 packages/playground/test/deferred-emitter-compile.test.ts diff --git a/.chronus/changes/defer-playground-emitter-loading-2026-7-31-10-30-0.md b/.chronus/changes/defer-playground-emitter-loading-2026-7-31-10-30-0.md new file mode 100644 index 00000000000..fba6bf40c51 --- /dev/null +++ b/.chronus/changes/defer-playground-emitter-loading-2026-7-31-10-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/playground" +--- + +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. diff --git a/packages/playground/src/browser-host.ts b/packages/playground/src/browser-host.ts index 3e4b23250e9..41d4cdf7392 100644 --- a/packages/playground/src/browser-host.ts +++ b/packages/playground/src/browser-host.ts @@ -12,6 +12,12 @@ export function resolveVirtualPath(path: string, ...paths: string[]) { export interface BrowserHostCreateOptions { readonly compiler: typeof import("@typespec/compiler"); readonly libraries: Record; + + /** + * Libraries that are known to the playground but have not been imported yet, keyed by name. + * Each entry imports the library when called. See {@link BrowserHost.loadLibrary}. + */ + readonly deferredLibraries?: Record Promise>; } /** @@ -21,10 +27,24 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br const virtualFs = new Map(); const jsImports = new Map>(); - const libraries: Record = { + const libraries: Record = { ...options.libraries, }; + const deferredLoaders = new Map(Object.entries(options.deferredLibraries ?? {})); + const pendingLoads = new Map>(); + + for (const name of deferredLoaders.keys()) { + // A placeholder keeps the emitter visible in the UI (dropdown, settings) without paying the + // cost of importing it. It is replaced by the real library on the first `loadLibrary` call. + libraries[name] = { + name, + isEmitter: true, + deferred: true, + packageJson: { name, version: "" } as any, + }; + } + function registerLibraryFiles( libName: string, lib: PlaygroundTspLibrary & { _TypeSpecLibrary_: any }, @@ -43,13 +63,42 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br JSON.stringify({ name: "playground-pkg", dependencies: Object.fromEntries( - Object.values(libraries).map((x) => [x.name, x.packageJson.version]), + // Deferred libraries have no files registered yet, so listing them would make the + // compiler resolve a dependency it cannot read. + Object.values(libraries) + .filter((x) => !x.deferred) + .map((x) => [x.name, x.packageJson.version]), ), }), ); } - for (const [libName, lib] of Object.entries(libraries)) { + function loadLibrary(name: string): Promise { + const loader = deferredLoaders.get(name); + if (loader === undefined) { + return Promise.resolve(); + } + let pending = pendingLoads.get(name); + if (pending === undefined) { + pending = loader().then( + (lib) => { + libraries[name] = lib; + registerLibraryFiles(name, lib); + updatePackageJson(); + deferredLoaders.delete(name); + }, + (error) => { + // Drop the cached promise so a later compilation can retry after a transient failure. + pendingLoads.delete(name); + throw error; + }, + ); + pendingLoads.set(name, pending); + } + return pending; + } + + for (const [libName, lib] of Object.entries(options.libraries)) { registerLibraryFiles(libName, lib); } updatePackageJson(); @@ -61,6 +110,7 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br return { compiler: options.compiler, libraries, + loadLibrary, async readUrl(url: string) { const contents = virtualFs.get(url); if (contents === undefined) { @@ -183,6 +233,28 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br }; } +/** + * A library that has been imported, along with the raw bundle payload used to populate the + * in-memory file system. + * @internal + */ +export type LoadedPlaygroundTspLibrary = PlaygroundTspLibrary & { _TypeSpecLibrary_: any }; + +async function importPlaygroundLibrary( + libName: string, + importOptions: LibraryImportOptions, +): Promise { + const { _TypeSpecLibrary_, $lib, $linter } = (await importLibrary(libName, importOptions)) as any; + return { + name: libName, + isEmitter: $lib?.emitter, + definition: $lib, + packageJson: JSON.parse(_TypeSpecLibrary_.typespecSourceFiles["package.json"]), + linter: $linter, + _TypeSpecLibrary_, + }; +} + /** * Load libraries in parallel from the given list. * @param libsToLoad List of library names. Must be available in the webpage importmap. @@ -191,43 +263,61 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br export async function loadLibraries( libsToLoad: readonly string[], importOptions: LibraryImportOptions = {}, -): Promise> { +): Promise> { const entries = await Promise.all( libsToLoad.map(async (libName) => { - const { _TypeSpecLibrary_, $lib, $linter } = (await importLibrary( - libName, - importOptions, - )) as any; - const lib: PlaygroundTspLibrary & { _TypeSpecLibrary_: any } = { - name: libName, - isEmitter: $lib?.emitter, - definition: $lib, - packageJson: JSON.parse(_TypeSpecLibrary_.typespecSourceFiles["package.json"]), - linter: $linter, - _TypeSpecLibrary_, - }; - return [libName, lib] as const; + return [libName, await importPlaygroundLibrary(libName, importOptions)] as const; }), ); return Object.fromEntries(entries); } +/** + * Options for creating the browser host. + */ +export interface BrowserHostOptions { + /** + * Emitters that should not be imported until they are used. + * + * Importing a library evaluates its module, which for some emitters means downloading a large + * runtime up front. Names listed here are shown in the emitter list right away but are only + * imported once selected, which keeps the initial load cheap. + * + * Only use this for pure emitters: a deferred library cannot be referenced by an `import` + * statement in the TypeSpec source, since its files are not registered until it is loaded. + */ + readonly deferredEmitters?: readonly string[]; +} + /** * Create the browser host from the list of libraries. * @param libsToLoad List of libraries to load. Those must be set in the webpage importmap. * @param importOptions Import configuration. + * @param options Additional host options. * @returns */ export async function createBrowserHost( libsToLoad: readonly string[], importOptions: LibraryImportOptions = {}, + options: BrowserHostOptions = {}, ): Promise { + const deferredEmitters = new Set(options.deferredEmitters ?? []); + const eagerLibs = libsToLoad.filter((x) => !deferredEmitters.has(x)); + const [libraries, compiler] = await Promise.all([ - loadLibraries(libsToLoad, importOptions), + loadLibraries(eagerLibs, importOptions), importTypeSpecCompiler(importOptions), ]); + + const deferredLibraries = Object.fromEntries( + libsToLoad + .filter((x) => deferredEmitters.has(x)) + .map((name) => [name, () => importPlaygroundLibrary(name, importOptions)] as const), + ); + return createBrowserHostInternal({ compiler, libraries, + deferredLibraries, }); } diff --git a/packages/playground/src/index.ts b/packages/playground/src/index.ts index cf7e6db4e43..17646519115 100644 --- a/packages/playground/src/index.ts +++ b/packages/playground/src/index.ts @@ -1,4 +1,4 @@ -export { createBrowserHost } from "./browser-host.js"; +export { createBrowserHost, type BrowserHostOptions } from "./browser-host.js"; export { registerMonacoDefaultWorkersForVite } from "./monaco-worker.js"; export { registerMonacoLanguage } from "./services.js"; export { createUrlStateStorage, type StateStorage, type UrlStateStorage } from "./state-storage.js"; diff --git a/packages/playground/src/react/compilation/compile.ts b/packages/playground/src/react/compilation/compile.ts index 3b793f5812d..89b62b1fc88 100644 --- a/packages/playground/src/react/compilation/compile.ts +++ b/packages/playground/src/react/compilation/compile.ts @@ -18,6 +18,11 @@ export async function compile( try { const typespecCompiler = host.compiler; + // Deferred emitters are only imported once they are actually used. + if (selectedEmitter) { + await host.loadLibrary(selectedEmitter); + } + // Resolve the compiler options natively from the tspconfig.yaml so the playground // honors the full config (emit, options, linter, imports, warn-as-error, ...). const [resolvedOptions] = await typespecCompiler.resolveCompilerOptions(host, { @@ -25,6 +30,9 @@ export async function compile( entrypoint: resolveVirtualPath("main.tsp"), }); + // The tspconfig.yaml can request emitters other than the selected one. + await Promise.all((resolvedOptions.emit ?? []).map((name) => host.loadLibrary(name))); + const options: CompilerOptions = { ...resolvedOptions, options: { diff --git a/packages/playground/src/react/standalone.tsx b/packages/playground/src/react/standalone.tsx index efb951526f0..38a21a2e518 100644 --- a/packages/playground/src/react/standalone.tsx +++ b/packages/playground/src/react/standalone.tsx @@ -36,6 +36,12 @@ import { export interface ReactPlaygroundConfig extends Partial { readonly libraries: readonly string[]; readonly importConfig?: LibraryImportOptions; + /** + * Emitters from {@link libraries} that should only be imported once selected, rather than when + * the playground starts. Only valid for pure emitters, which are never referenced by an `import` + * statement in the TypeSpec source. + */ + readonly deferredEmitters?: readonly string[]; /** Content to show while the playground data is loading(Libraries) */ readonly fallback?: ReactNode; } @@ -51,7 +57,9 @@ function useStandalonePlaygroundContext( const [context, setContext] = useState(); useEffect(() => { const load = async () => { - const host = await createBrowserHost(config.libraries, config.importConfig); + const host = await createBrowserHost(config.libraries, config.importConfig, { + deferredEmitters: config.deferredEmitters, + }); await registerMonacoLanguage(host); const stateStorage = createStandalonePlaygroundStateStorage(); @@ -59,7 +67,7 @@ function useStandalonePlaygroundContext( setContext({ host, initialState, stateStorage }); }; void load(); - }, [config.importConfig, config.libraries]); + }, [config.importConfig, config.libraries, config.deferredEmitters]); return context; } diff --git a/packages/playground/src/types.ts b/packages/playground/src/types.ts index f665e097bec..28a2e6d6be9 100644 --- a/packages/playground/src/types.ts +++ b/packages/playground/src/types.ts @@ -33,9 +33,23 @@ export interface PlaygroundTspLibrary { isEmitter: boolean; definition?: TypeSpecLibrary; linter?: LinterDefinition; + + /** + * Whether the library module has been declared but not imported yet. + * + * Deferred emitters are only imported the first time they are used, so their `definition`, + * `linter` and `packageJson` are placeholders until then. + */ + deferred?: boolean; } export interface BrowserHost extends CompilerHost { compiler: typeof import("@typespec/compiler"); libraries: Record; + + /** + * Import a library that was registered as a deferred emitter and make its files available to the + * compiler. Resolves immediately for libraries that are already loaded. + */ + loadLibrary(name: string): Promise; } diff --git a/packages/playground/test/browser-host-deferred.test.ts b/packages/playground/test/browser-host-deferred.test.ts new file mode 100644 index 00000000000..082f30556fb --- /dev/null +++ b/packages/playground/test/browser-host-deferred.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createBrowserHost } from "../src/browser-host.js"; + +const importLibrary = vi.hoisted(() => vi.fn()); + +vi.mock("../src/core.js", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, importLibrary, importTypeSpecCompiler: async () => ({}) as any }; +}); + +function fakeLibrary(name: string, { emitter }: { emitter: boolean }) { + return { + $lib: { emitter }, + _TypeSpecLibrary_: { + typespecSourceFiles: { + "package.json": JSON.stringify({ name, version: "1.2.3" }), + "main.tsp": `// ${name}`, + }, + jsSourceFiles: {}, + }, + }; +} + +describe("createBrowserHost deferred emitters", () => { + beforeEach(() => { + importLibrary.mockReset(); + importLibrary.mockImplementation(async (name: string) => + fakeLibrary(name, { emitter: name === "@typespec/emitter" }), + ); + }); + + it("does not import deferred emitters when the host is created", async () => { + const host = await createBrowserHost( + ["@typespec/http", "@typespec/emitter"], + {}, + { + deferredEmitters: ["@typespec/emitter"], + }, + ); + + expect(importLibrary.mock.calls.map((x) => x[0])).toEqual(["@typespec/http"]); + // The emitter is still offered in the UI so it can be selected. + expect(host.libraries["@typespec/emitter"]).toMatchObject({ + name: "@typespec/emitter", + isEmitter: true, + deferred: true, + }); + }); + + it("leaves deferred emitters out of the virtual package.json until they are loaded", async () => { + const host = await createBrowserHost( + ["@typespec/http", "@typespec/emitter"], + {}, + { + deferredEmitters: ["@typespec/emitter"], + }, + ); + + const before = JSON.parse((await host.readFile("/test/package.json")).text); + expect(Object.keys(before.dependencies)).toEqual(["@typespec/http"]); + + await host.loadLibrary("@typespec/emitter"); + + const after = JSON.parse((await host.readFile("/test/package.json")).text); + expect(Object.keys(after.dependencies).sort()).toEqual(["@typespec/emitter", "@typespec/http"]); + }); + + it("imports and registers a deferred emitter on loadLibrary", async () => { + const host = await createBrowserHost( + ["@typespec/emitter"], + {}, + { + deferredEmitters: ["@typespec/emitter"], + }, + ); + + await expect(host.readFile("/test/node_modules/@typespec/emitter/main.tsp")).rejects.toThrow(); + + await host.loadLibrary("@typespec/emitter"); + + const file = await host.readFile("/test/node_modules/@typespec/emitter/main.tsp"); + expect(file.text).toEqual("// @typespec/emitter"); + expect(host.libraries["@typespec/emitter"].deferred).toBeUndefined(); + expect(host.libraries["@typespec/emitter"].packageJson.version).toEqual("1.2.3"); + }); + + it("imports a deferred emitter only once across concurrent calls", async () => { + const host = await createBrowserHost( + ["@typespec/emitter"], + {}, + { + deferredEmitters: ["@typespec/emitter"], + }, + ); + + await Promise.all([ + host.loadLibrary("@typespec/emitter"), + host.loadLibrary("@typespec/emitter"), + ]); + await host.loadLibrary("@typespec/emitter"); + + expect(importLibrary.mock.calls.filter((x) => x[0] === "@typespec/emitter")).toHaveLength(1); + }); + + it("retries a deferred emitter whose import failed", async () => { + importLibrary.mockRejectedValueOnce(new Error("network boom")); + const host = await createBrowserHost( + ["@typespec/emitter"], + {}, + { + deferredEmitters: ["@typespec/emitter"], + }, + ); + + await expect(host.loadLibrary("@typespec/emitter")).rejects.toThrow("network boom"); + + await host.loadLibrary("@typespec/emitter"); + expect(host.libraries["@typespec/emitter"].deferred).toBeUndefined(); + }); + + it("resolves immediately for libraries that are not deferred", async () => { + const host = await createBrowserHost(["@typespec/http"], {}, {}); + + await host.loadLibrary("@typespec/http"); + expect(importLibrary.mock.calls.filter((x) => x[0] === "@typespec/http")).toHaveLength(1); + }); +}); diff --git a/packages/playground/test/deferred-emitter-compile.test.ts b/packages/playground/test/deferred-emitter-compile.test.ts new file mode 100644 index 00000000000..525fd7f65a8 --- /dev/null +++ b/packages/playground/test/deferred-emitter-compile.test.ts @@ -0,0 +1,124 @@ +import { readFile, readdir } from "fs/promises"; +import { dirname, join, relative } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import { describe, expect, it } from "vitest"; +import type { LoadedPlaygroundTspLibrary } from "../src/browser-host.js"; +import { createBrowserHostInternal, resolveVirtualPath } from "../src/browser-host.js"; + +const compilerRoot = dirname( + fileURLToPath(await import.meta.resolve!("@typespec/compiler/package.json")), +); + +async function collectFiles(dir: string, root: string): Promise> { + const result: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + Object.assign(result, await collectFiles(full, root)); + } else if (entry.name.endsWith(".tsp")) { + result[relative(root, full).replaceAll("\\", "/")] = await readFile(full, "utf8"); + } + } + return result; +} + +/** Build a bundle-shaped payload for the real on-disk compiler package. */ +async function realCompilerLibrary( + compiler: typeof import("@typespec/compiler"), +): Promise { + const packageJson = await readFile(join(compilerRoot, "package.json"), "utf8"); + return { + name: "@typespec/compiler", + isEmitter: false, + packageJson: JSON.parse(packageJson), + _TypeSpecLibrary_: { + typespecSourceFiles: { + "package.json": packageJson, + ...(await collectFiles(join(compilerRoot, "lib"), compilerRoot)), + }, + jsSourceFiles: { + [JSON.parse(packageJson).main]: compiler, + // The std library `.tsp` files reference these for their extern decorator implementations. + ...Object.fromEntries( + await Promise.all( + ["dist/src/lib/tsp-index.js", "dist/src/lib/intrinsic/tsp-index.js"].map( + async (path) => + [ + path, + await import(/* @vite-ignore */ pathToFileURL(join(compilerRoot, path)).href), + ] as const, + ), + ), + ), + }, + }, + }; +} + +/** A minimal emitter in the shape the playground bundler produces. */ +function fakeEmitterLibrary( + name: string, + onEmit: (outputDir: string) => void, +): LoadedPlaygroundTspLibrary { + const packageJson = JSON.stringify({ name, version: "1.0.0", main: "index.js" }); + return { + name, + isEmitter: true, + packageJson: JSON.parse(packageJson), + _TypeSpecLibrary_: { + typespecSourceFiles: { "package.json": packageJson }, + jsSourceFiles: { + "index.js": { + $onEmit: async (context: any) => { + onEmit(context.emitterOutputDir); + await context.program.host.writeFile( + join(context.emitterOutputDir, "out.txt"), + "emitted", + ); + }, + }, + }, + }, + }; +} + +describe("compiling with a deferred emitter", () => { + it("runs an emitter that was imported after the host was created", async () => { + const compiler = await import("@typespec/compiler"); + let emitted = 0; + let emitterOutputDir: string | undefined; + const emitterName = "@test/deferred-emitter"; + + const host = createBrowserHostInternal({ + compiler, + libraries: { "@typespec/compiler": await realCompilerLibrary(compiler) }, + deferredLibraries: { + [emitterName]: async () => + fakeEmitterLibrary(emitterName, (dir) => { + emitted++; + emitterOutputDir = dir; + }), + }, + }); + + await host.writeFile("main.tsp", "model Foo { name: string }"); + + // The emitter is advertised but not imported yet, so the compiler cannot resolve it. + const before = await compiler.compile(host, resolveVirtualPath("main.tsp"), { + emit: [emitterName], + outputDir: resolveVirtualPath("tsp-output"), + }); + expect(before.diagnostics.map((x) => x.code)).toContain("import-not-found"); + expect(emitted).toBe(0); + + await host.loadLibrary(emitterName); + + const after = await compiler.compile(host, resolveVirtualPath("main.tsp"), { + emit: [emitterName], + outputDir: resolveVirtualPath("tsp-output"), + }); + expect(after.diagnostics.map((x) => `${x.code}: ${x.message}`)).toEqual([]); + expect(emitted).toBe(1); + expect((await host.readFile(join(emitterOutputDir!, "out.txt"))).text).toEqual("emitted"); + }); +}); diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx index 14bab6ced4d..b22c983733a 100644 --- a/website/src/components/playground-component/playground.tsx +++ b/website/src/components/playground-component/playground.tsx @@ -20,6 +20,12 @@ export interface WebsitePlaygroundProps { versionData: VersionData; } +/** + * Emitters that ship a heavy runtime and are not referenced by `import` statements in TypeSpec + * source, so they can be imported the first time they are selected instead of on page load. + */ +const deferredEmitters = ["@typespec/http-client-csharp", "@typespec/http-client-python"]; + export const WebsitePlayground = ({ versionData }: WebsitePlaygroundProps) => { const theme = useTheme(); @@ -35,6 +41,7 @@ export const WebsitePlayground = ({ versionData }: WebsitePlaygroundProps) => { Date: Fri, 31 Jul 2026 10:58:44 -0400 Subject: [PATCH 2/2] test(playground): make deferred emitter tests independent of module mocking The suite runs with `isolate: false`, so `vi.mock("../src/core.js")` was not reliably applied when the whole workspace runs in one vitest instance and the real `importLibrary` was used instead. Test the injectable seams directly instead: `createBrowserHostInternal` takes the loaders, and the eager/deferred split is now a pure `splitDeferredLibraries` helper. No module mocking is involved. Also fix two portability bugs in the compile test: `join()` was used to build virtual FS paths, which produces backslashes on Windows, and `import.meta.resolve` is replaced with `createRequire`. --- packages/playground/src/browser-host.ts | 24 ++- .../test/browser-host-deferred.test.ts | 158 +++++++++--------- .../test/deferred-emitter-compile.test.ts | 12 +- 3 files changed, 103 insertions(+), 91 deletions(-) diff --git a/packages/playground/src/browser-host.ts b/packages/playground/src/browser-host.ts index 41d4cdf7392..5791e3bc9b7 100644 --- a/packages/playground/src/browser-host.ts +++ b/packages/playground/src/browser-host.ts @@ -289,6 +289,21 @@ export interface BrowserHostOptions { readonly deferredEmitters?: readonly string[]; } +/** + * Split the libraries to load into the ones to import now and the ones to import on demand. + * @internal + */ +export function splitDeferredLibraries( + libsToLoad: readonly string[], + deferredEmitters: readonly string[] = [], +): { eager: string[]; deferred: string[] } { + const deferredSet = new Set(deferredEmitters); + return { + eager: libsToLoad.filter((x) => !deferredSet.has(x)), + deferred: libsToLoad.filter((x) => deferredSet.has(x)), + }; +} + /** * Create the browser host from the list of libraries. * @param libsToLoad List of libraries to load. Those must be set in the webpage importmap. @@ -301,18 +316,15 @@ export async function createBrowserHost( importOptions: LibraryImportOptions = {}, options: BrowserHostOptions = {}, ): Promise { - const deferredEmitters = new Set(options.deferredEmitters ?? []); - const eagerLibs = libsToLoad.filter((x) => !deferredEmitters.has(x)); + const { eager, deferred } = splitDeferredLibraries(libsToLoad, options.deferredEmitters); const [libraries, compiler] = await Promise.all([ - loadLibraries(eagerLibs, importOptions), + loadLibraries(eager, importOptions), importTypeSpecCompiler(importOptions), ]); const deferredLibraries = Object.fromEntries( - libsToLoad - .filter((x) => deferredEmitters.has(x)) - .map((name) => [name, () => importPlaygroundLibrary(name, importOptions)] as const), + deferred.map((name) => [name, () => importPlaygroundLibrary(name, importOptions)] as const), ); return createBrowserHostInternal({ diff --git a/packages/playground/test/browser-host-deferred.test.ts b/packages/playground/test/browser-host-deferred.test.ts index 082f30556fb..1cf0d614552 100644 --- a/packages/playground/test/browser-host-deferred.test.ts +++ b/packages/playground/test/browser-host-deferred.test.ts @@ -1,16 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createBrowserHost } from "../src/browser-host.js"; +import { + createBrowserHostInternal, + splitDeferredLibraries, + type LoadedPlaygroundTspLibrary, +} from "../src/browser-host.js"; -const importLibrary = vi.hoisted(() => vi.fn()); - -vi.mock("../src/core.js", async (importOriginal) => { - const original = await importOriginal(); - return { ...original, importLibrary, importTypeSpecCompiler: async () => ({}) as any }; -}); - -function fakeLibrary(name: string, { emitter }: { emitter: boolean }) { +function fakeLibrary(name: string): LoadedPlaygroundTspLibrary { return { - $lib: { emitter }, + name, + isEmitter: true, + definition: { emitter: true } as any, + packageJson: { name, version: "1.2.3" } as any, _TypeSpecLibrary_: { typespecSourceFiles: { "package.json": JSON.stringify({ name, version: "1.2.3" }), @@ -21,107 +21,109 @@ function fakeLibrary(name: string, { emitter }: { emitter: boolean }) { }; } -describe("createBrowserHost deferred emitters", () => { +const emitterName = "@typespec/emitter"; + +describe("splitDeferredLibraries", () => { + it("keeps everything eager when nothing is deferred", () => { + expect(splitDeferredLibraries(["@typespec/http", emitterName])).toEqual({ + eager: ["@typespec/http", emitterName], + deferred: [], + }); + }); + + it("moves the requested libraries to the deferred list", () => { + expect(splitDeferredLibraries(["@typespec/http", emitterName], [emitterName])).toEqual({ + eager: ["@typespec/http"], + deferred: [emitterName], + }); + }); + + it("ignores deferred names that are not loaded at all", () => { + expect(splitDeferredLibraries(["@typespec/http"], ["@typespec/not-in-import-map"])).toEqual({ + eager: ["@typespec/http"], + deferred: [], + }); + }); +}); + +describe("browser host deferred libraries", () => { + let loader: ReturnType; + beforeEach(() => { - importLibrary.mockReset(); - importLibrary.mockImplementation(async (name: string) => - fakeLibrary(name, { emitter: name === "@typespec/emitter" }), - ); + loader = vi.fn(async () => fakeLibrary(emitterName)); }); - it("does not import deferred emitters when the host is created", async () => { - const host = await createBrowserHost( - ["@typespec/http", "@typespec/emitter"], - {}, - { - deferredEmitters: ["@typespec/emitter"], - }, - ); + function createHost() { + return createBrowserHostInternal({ + compiler: {} as any, + libraries: { "@typespec/http": fakeLibrary("@typespec/http") }, + deferredLibraries: { [emitterName]: loader as any }, + }); + } - expect(importLibrary.mock.calls.map((x) => x[0])).toEqual(["@typespec/http"]); + it("does not import deferred libraries when the host is created", () => { + const host = createHost(); + + expect(loader).not.toHaveBeenCalled(); // The emitter is still offered in the UI so it can be selected. - expect(host.libraries["@typespec/emitter"]).toMatchObject({ - name: "@typespec/emitter", + expect(host.libraries[emitterName]).toMatchObject({ + name: emitterName, isEmitter: true, deferred: true, }); }); - it("leaves deferred emitters out of the virtual package.json until they are loaded", async () => { - const host = await createBrowserHost( - ["@typespec/http", "@typespec/emitter"], - {}, - { - deferredEmitters: ["@typespec/emitter"], - }, - ); + it("leaves deferred libraries out of the virtual package.json until they are loaded", async () => { + const host = createHost(); const before = JSON.parse((await host.readFile("/test/package.json")).text); expect(Object.keys(before.dependencies)).toEqual(["@typespec/http"]); - await host.loadLibrary("@typespec/emitter"); + await host.loadLibrary(emitterName); const after = JSON.parse((await host.readFile("/test/package.json")).text); - expect(Object.keys(after.dependencies).sort()).toEqual(["@typespec/emitter", "@typespec/http"]); + expect(Object.keys(after.dependencies).sort()).toEqual([emitterName, "@typespec/http"]); }); - it("imports and registers a deferred emitter on loadLibrary", async () => { - const host = await createBrowserHost( - ["@typespec/emitter"], - {}, - { - deferredEmitters: ["@typespec/emitter"], - }, - ); + it("registers the library files on loadLibrary", async () => { + const host = createHost(); - await expect(host.readFile("/test/node_modules/@typespec/emitter/main.tsp")).rejects.toThrow(); + await expect( + host.readFile(`/test/node_modules/${emitterName}/main.tsp`), + ).rejects.toThrowError(); - await host.loadLibrary("@typespec/emitter"); + await host.loadLibrary(emitterName); - const file = await host.readFile("/test/node_modules/@typespec/emitter/main.tsp"); - expect(file.text).toEqual("// @typespec/emitter"); - expect(host.libraries["@typespec/emitter"].deferred).toBeUndefined(); - expect(host.libraries["@typespec/emitter"].packageJson.version).toEqual("1.2.3"); + const file = await host.readFile(`/test/node_modules/${emitterName}/main.tsp`); + expect(file.text).toEqual(`// ${emitterName}`); + expect(host.libraries[emitterName].deferred).toBeUndefined(); + expect(host.libraries[emitterName].packageJson.version).toEqual("1.2.3"); }); - it("imports a deferred emitter only once across concurrent calls", async () => { - const host = await createBrowserHost( - ["@typespec/emitter"], - {}, - { - deferredEmitters: ["@typespec/emitter"], - }, - ); + it("imports a deferred library only once across concurrent calls", async () => { + const host = createHost(); - await Promise.all([ - host.loadLibrary("@typespec/emitter"), - host.loadLibrary("@typespec/emitter"), - ]); - await host.loadLibrary("@typespec/emitter"); + await Promise.all([host.loadLibrary(emitterName), host.loadLibrary(emitterName)]); + await host.loadLibrary(emitterName); - expect(importLibrary.mock.calls.filter((x) => x[0] === "@typespec/emitter")).toHaveLength(1); + expect(loader).toHaveBeenCalledTimes(1); }); - it("retries a deferred emitter whose import failed", async () => { - importLibrary.mockRejectedValueOnce(new Error("network boom")); - const host = await createBrowserHost( - ["@typespec/emitter"], - {}, - { - deferredEmitters: ["@typespec/emitter"], - }, - ); + it("retries a deferred library whose import failed", async () => { + loader.mockRejectedValueOnce(new Error("network boom")); + const host = createHost(); - await expect(host.loadLibrary("@typespec/emitter")).rejects.toThrow("network boom"); + await expect(host.loadLibrary(emitterName)).rejects.toThrowError("network boom"); - await host.loadLibrary("@typespec/emitter"); - expect(host.libraries["@typespec/emitter"].deferred).toBeUndefined(); + await host.loadLibrary(emitterName); + expect(host.libraries[emitterName].deferred).toBeUndefined(); + expect(loader).toHaveBeenCalledTimes(2); }); it("resolves immediately for libraries that are not deferred", async () => { - const host = await createBrowserHost(["@typespec/http"], {}, {}); + const host = createHost(); await host.loadLibrary("@typespec/http"); - expect(importLibrary.mock.calls.filter((x) => x[0] === "@typespec/http")).toHaveLength(1); + expect(loader).not.toHaveBeenCalled(); }); }); diff --git a/packages/playground/test/deferred-emitter-compile.test.ts b/packages/playground/test/deferred-emitter-compile.test.ts index 525fd7f65a8..30bd4462e11 100644 --- a/packages/playground/test/deferred-emitter-compile.test.ts +++ b/packages/playground/test/deferred-emitter-compile.test.ts @@ -1,12 +1,13 @@ import { readFile, readdir } from "fs/promises"; +import { createRequire } from "module"; import { dirname, join, relative } from "path"; -import { fileURLToPath, pathToFileURL } from "url"; +import { pathToFileURL } from "url"; import { describe, expect, it } from "vitest"; import type { LoadedPlaygroundTspLibrary } from "../src/browser-host.js"; import { createBrowserHostInternal, resolveVirtualPath } from "../src/browser-host.js"; const compilerRoot = dirname( - fileURLToPath(await import.meta.resolve!("@typespec/compiler/package.json")), + createRequire(import.meta.url).resolve("@typespec/compiler/package.json"), ); async function collectFiles(dir: string, root: string): Promise> { @@ -71,10 +72,7 @@ function fakeEmitterLibrary( "index.js": { $onEmit: async (context: any) => { onEmit(context.emitterOutputDir); - await context.program.host.writeFile( - join(context.emitterOutputDir, "out.txt"), - "emitted", - ); + await context.program.host.writeFile(`${context.emitterOutputDir}/out.txt`, "emitted"); }, }, }, @@ -119,6 +117,6 @@ describe("compiling with a deferred emitter", () => { }); expect(after.diagnostics.map((x) => `${x.code}: ${x.message}`)).toEqual([]); expect(emitted).toBe(1); - expect((await host.readFile(join(emitterOutputDir!, "out.txt"))).text).toEqual("emitted"); + expect((await host.readFile(`${emitterOutputDir!}/out.txt`)).text).toEqual("emitted"); }); });