Repository: nodejs/node
Pull request: lib: return undefined for localStorage without file

Make localStorage return undefined and emit a warning when
--localstorage-file is not provided. Mark the property as
non-enumerable to avoid breaking {...globalThis}.

Fixes: https://github.com/nodejs/node/issues/60303

Changed files:
filename: lib/internal/process/pre_execution.js
patch: @@ -404,8 +404,26 @@ function setupWebStorage() {
 
   // https://html.spec.whatwg.org/multipage/webstorage.html#webstorage
   exposeLazyInterfaces(globalThis, 'internal/webstorage', ['Storage']);
+
+  // localStorage is non-enumerable when --localstorage-file is not provided
+  // to avoid breaking {...globalThis} operations.
+  const localStorageFile = getOptionValue('--localstorage-file');
+  let lazyLocalStorage;
+  ObjectDefineProperty(globalThis, 'localStorage', {
+    __proto__: null,
+    enumerable: localStorageFile !== '',
+    configurable: true,
+    get() {
+      lazyLocalStorage ??= require('internal/webstorage').localStorage;
+      return lazyLocalStorage;
+    },
+    set(value) {
+      lazyLocalStorage = value;
+    },
+  });
+
   defineReplaceableLazyAttribute(globalThis, 'internal/webstorage', [
-    'localStorage', 'sessionStorage',
+    'sessionStorage',
   ]);
 }
 

filename: lib/internal/webstorage.js
patch: @@ -3,7 +3,6 @@ const {
   ObjectDefineProperties,
 } = primordials;
 const { getOptionValue } = require('internal/options');
-const { lazyDOMException } = require('internal/util');
 const { kConstructorKey, Storage } = internalBinding('webstorage');
 const { getValidatedPath } = require('internal/fs/utils');
 const kInMemoryPath = ':memory:';
@@ -12,26 +11,32 @@ module.exports = { Storage };
 
 let lazyLocalStorage;
 let lazySessionStorage;
+let localStorageWarned = false;
+
+// Check at load time if localStorage file is provided to determine enumerability.
+// If not provided, localStorage is non-enumerable to avoid breaking {...globalThis}.
+const localStorageLocation = getOptionValue('--localstorage-file');
 
 ObjectDefineProperties(module.exports, {
   __proto__: null,
   localStorage: {
     __proto__: null,
     configurable: true,
-    enumerable: true,
+    enumerable: localStorageLocation !== '',
     get() {
       if (lazyLocalStorage === undefined) {
-        // For consistency with the web specification, throw from the accessor
-        // if the local storage path is not provided.
-        const location = getOptionValue('--localstorage-file');
-        if (location === '') {
-          throw lazyDOMException(
-            'Cannot initialize local storage without a `--localstorage-file` path',
-            'SecurityError',
-          );
+        if (localStorageLocation === '') {
+          if (!localStorageWarned) {
+            localStorageWarned = true;
+            process.emitWarning(
+              'localStorage is not available because --localstorage-file was not provided.',
+              'ExperimentalWarning',
+            );
+          }
+          return undefined;
         }
 
-        lazyLocalStorage = new Storage(kConstructorKey, getValidatedPath(location));
+        lazyLocalStorage = new Storage(kConstructorKey, getValidatedPath(localStorageLocation));
       }
 
       return lazyLocalStorage;

filename: test/common/index.js
patch: @@ -71,11 +71,10 @@ const hasSQLite = Boolean(process.versions.sqlite);
 const hasQuic = hasCrypto && !!process.features.quic;
 
 const hasLocalStorage = (() => {
-  try {
-    return hasSQLite && globalThis.localStorage !== undefined;
-  } catch {
-    return false;
-  }
+  // Check enumerable property to avoid triggering the getter which emits a warning.
+  // localStorage is enumerable only when --localstorage-file is provided.
+  const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
+  return hasSQLite && desc?.enumerable === true;
 })();
 
 /**

filename: test/parallel/test-global.js
patch: @@ -61,7 +61,12 @@ for (const moduleName of builtinModules) {
     'navigator',
   ];
   if (common.hasSQLite) {
-    expected.push('localStorage', 'sessionStorage');
+    // sessionStorage is always enumerable when SQLite is available.
+    // localStorage is only enumerable when --localstorage-file is provided.
+    expected.push('sessionStorage');
+    if (common.hasLocalStorage) {
+      expected.push('localStorage');
+    }
   }
   assert.deepStrictEqual(new Set(Object.keys(globalThis)), new Set(expected));
   expected.forEach((value) => {

filename: test/parallel/test-webstorage.js
patch: @@ -41,13 +41,22 @@ test('sessionStorage is not persisted', async () => {
   assert.strictEqual((await readdir(tmpdir.path)).length, 0);
 });
 
-test('localStorage throws without --localstorage-file', async () => {
+test('localStorage returns undefined and warns without --localstorage-file', async () => {
   const cp = await spawnPromisified(process.execPath, [
-    '-e', 'localStorage',
+    '-pe', 'localStorage',
   ]);
-  assert.strictEqual(cp.code, 1);
+  assert.strictEqual(cp.code, 0);
   assert.strictEqual(cp.signal, null);
-  assert.match(cp.stderr, /SecurityError:/);
+  assert.match(cp.stdout, /undefined/);
+  assert.match(cp.stderr, /ExperimentalWarning:.*localStorage is not available/);
+});
+
+test('localStorage is not enumerable without --localstorage-file', async () => {
+  const cp = await spawnPromisified(process.execPath, [
+    '-pe', 'Object.keys(globalThis).includes("localStorage")',
+  ]);
+  assert.strictEqual(cp.code, 0);
+  assert.match(cp.stdout, /false/);
 });
 
 test('localStorage is not persisted if it is unused', async () => {