diff --git a/.changeset/olive-tigers-hammer.md b/.changeset/olive-tigers-hammer.md new file mode 100644 index 000000000000..f718444cc40d --- /dev/null +++ b/.changeset/olive-tigers-hammer.md @@ -0,0 +1,5 @@ +--- +"@typescript/vfs": patch +--- + +Skip the localStorage feature detect in Node so importing the package no longer emits an ExperimentalWarning on Node 26 diff --git a/packages/typescript-vfs/src/index.ts b/packages/typescript-vfs/src/index.ts index 9dfeec90f1b7..cf1b4decebb6 100755 --- a/packages/typescript-vfs/src/index.ts +++ b/packages/typescript-vfs/src/index.ts @@ -17,13 +17,25 @@ interface LocalStorageLike { declare var localStorage: LocalStorageLike | undefined; declare var fetch: FetchLike | undefined; +const hasProcess = typeof process !== `undefined` + +// Node >= 26 exposes `localStorage` as a global accessor which emits an +// ExperimentalWarning when it is read - including via `typeof` - unless the process +// was started with `--localstorage-file`. Probing it here would print that warning at +// module evaluation for every consumer, so skip the probe entirely in a bare Node +// process, where `process.env.DEBUG` below is the only reachable way to opt in anyway. +// DOM-bearing hosts that also expose `process` (Electron renderers) still get probed. +const isBareNodeProcess = + hasProcess && typeof process.versions?.node === `string` && !(`window` in globalThis) + let hasLocalStorage = false -try { - hasLocalStorage = typeof localStorage !== `undefined` -} catch (error) { } +if (!isBareNodeProcess) { + try { + hasLocalStorage = typeof localStorage !== `undefined` && typeof localStorage.getItem === `function` + } catch (error) { } +} -const hasProcess = typeof process !== `undefined` -const shouldDebug = (hasLocalStorage && typeof localStorage!.getItem === 'function' && localStorage!.getItem("DEBUG")) || (hasProcess && process.env.DEBUG) +const shouldDebug = (hasLocalStorage && localStorage!.getItem("DEBUG")) || (hasProcess && process.env.DEBUG) const debugLog = shouldDebug ? console.log : (_message?: any, ..._optionalParams: any[]) => "" export interface VirtualTypeScriptEnvironment { diff --git a/packages/typescript-vfs/test/localStorageProbe.test.ts b/packages/typescript-vfs/test/localStorageProbe.test.ts new file mode 100644 index 000000000000..99dc2b42845e --- /dev/null +++ b/packages/typescript-vfs/test/localStorageProbe.test.ts @@ -0,0 +1,54 @@ +/** + * @jest-environment node + */ + +// Regression coverage for the import-time `localStorage` feature detect. +// +// Node >= 26 exposes `localStorage` as a global accessor which emits an +// ExperimentalWarning when it is read - `typeof` included - unless the process was +// started with `--localstorage-file`. The probe therefore must not touch the global +// in a bare Node process. Installing a counting accessor lets us assert that +// directly, on whichever Node version CI happens to run. + +const globals = globalThis as any + +/** Defines `localStorage` as an accessor and reports how many times it was read. */ +const installLocalStorageAccessor = () => { + let reads = 0 + Object.defineProperty(globals, "localStorage", { + configurable: true, + get() { + reads++ + return { getItem: () => null, setItem: () => { }, removeItem: () => { } } + }, + }) + return () => reads +} + +/** Evaluates the module fresh, so the import-time probe runs again. */ +const importModule = () => { + jest.resetModules() + require("../src") +} + +afterEach(() => { + delete globals.localStorage + delete globals.window +}) + +it("does not read the localStorage global when evaluated in a bare Node process", () => { + const reads = installLocalStorageAccessor() + + importModule() + + expect(reads()).toBe(0) +}) + +it("still reads the localStorage global when a DOM is present", () => { + globals.window = {} + const reads = installLocalStorageAccessor() + + importModule() + + expect(reads()).toBeGreaterThan(0) +})