From d5b4d347dc2964e41b4e8482f1ca082e4c583675 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Thu, 10 Sep 2026 00:27:46 -0400 Subject: [PATCH 1/2] fix(vfs): don't read localStorage during feature detection in Node Node 26 exposes `localStorage` as a global accessor that emits an ExperimentalWarning when read - `typeof` included - unless the process was started with `--localstorage-file`. The existing feature detect reads it at module evaluation, so simply importing `@typescript/vfs` prints a warning on Node 26 before any API of the package is called. The surrounding try/catch does not help: the accessor warns and returns undefined rather than throwing. Skip the probe entirely in a bare Node process. The only thing the value is used for is looking up a `DEBUG` key, and in Node `process.env.DEBUG` already covers that. Hosts that expose both `process` and a DOM (Electron renderers) still probe as before, as do browsers. Also folds the `typeof localStorage.getItem === 'function'` guard added in #3450 into `hasLocalStorage`, where it belongs. Signed-off-by: C. Spencer Beggs Claude-Session: https://claude.ai/code/session_01TTDH2nJgCerAnT3DQPSTew --- .changeset/olive-tigers-hammer.md | 5 +++++ packages/typescript-vfs/src/index.ts | 22 +++++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 .changeset/olive-tigers-hammer.md 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 { From 18eac630f6b8f6261a7a6aeee1718d6d9ad01cf5 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Thu, 10 Sep 2026 00:46:25 -0400 Subject: [PATCH 2/2] test(vfs): cover the import-time localStorage probe Installs a counting `localStorage` accessor on `globalThis` before evaluating the module, so the probe's behaviour can be asserted directly rather than inferred from the host's Node version. Verified to fail against the previous implementation (3 reads) and pass against this one. Runs in a `node` test environment for the bare-Node case; a companion case assigns `globalThis.window` to confirm DOM-bearing hosts still probe. Signed-off-by: C. Spencer Beggs Claude-Session: https://claude.ai/code/session_01TTDH2nJgCerAnT3DQPSTew --- .../test/localStorageProbe.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/typescript-vfs/test/localStorageProbe.test.ts 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) +})