diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 283ec72d388572..0902536708bf1d 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -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', ]); } diff --git a/lib/internal/webstorage.js b/lib/internal/webstorage.js index 989ccb2dd40eea..47c71676995f09 100644 --- a/lib/internal/webstorage.js +++ b/lib/internal/webstorage.js @@ -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; diff --git a/test/common/index.js b/test/common/index.js index 8056b7d434a8ab..4e2d4166535f43 100755 --- a/test/common/index.js +++ b/test/common/index.js @@ -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; })(); /** diff --git a/test/parallel/test-global.js b/test/parallel/test-global.js index 257f4619830c7d..93970fc7d2efd0 100644 --- a/test/parallel/test-global.js +++ b/test/parallel/test-global.js @@ -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) => { diff --git a/test/parallel/test-webstorage.js b/test/parallel/test-webstorage.js index cc5f0366f7116e..139c5f1d6c0102 100644 --- a/test/parallel/test-webstorage.js +++ b/test/parallel/test-webstorage.js @@ -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 () => {