Skip to content

Commit f71b190

Browse files
authored
fix(devtools): correct options cache guard and stop mutating defaults (#1018)
1 parent 077b226 commit f71b190

7 files changed

Lines changed: 133 additions & 276 deletions

File tree

‎packages/devtools/client/composables/storage-options.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ToRefs } from 'vue'
22
import type { NuxtDevToolsOptions } from '../../types'
33
import { watchDebounced } from '@vueuse/core'
44
import { reactive, toRefs } from 'vue'
5-
import { defaultTabOptions } from '../../src/constant'
5+
import { createDefaultTabOptions } from '../../src/constant'
66
import { rpc } from './rpc'
77

88
const cache = new Map<string, any>()
@@ -11,7 +11,7 @@ function getTabOptions<T extends keyof NuxtDevToolsOptions>(tab: T): ToRefs<Nuxt
1111
if (cache.has(tab)) {
1212
return cache.get(tab)
1313
}
14-
const source = reactive({ ...defaultTabOptions[tab] }) as NuxtDevToolsOptions[T]
14+
const source = reactive(createDefaultTabOptions()[tab]) as NuxtDevToolsOptions[T]
1515
const refs = toRefs(source)
1616
cache.set(tab, refs)
1717

‎packages/devtools/src/constant.ts‎

Lines changed: 69 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,51 +16,77 @@ export const defaultOptions: ModuleOptions = {
1616
disableAuthorization: isSandboxed,
1717
}
1818

19-
export const defaultTabOptions: NuxtDevToolsOptions = {
20-
behavior: {
21-
telemetry: null,
22-
openInEditor: undefined,
23-
},
24-
ui: {
25-
componentsView: 'list',
26-
componentsGraphShowNodeModules: false,
27-
componentsGraphShowGlobalComponents: true,
28-
componentsGraphShowPages: false,
29-
componentsGraphShowLayouts: false,
30-
componentsGraphShowWorkspace: true,
31-
interactionCloseOnOutsideClick: false,
32-
showExperimentalFeatures: false,
33-
showHelpButtons: true,
34-
scale: 1,
35-
hiddenTabs: [],
36-
pinnedTabs: [],
37-
hiddenTabCategories: [],
38-
sidebarExpanded: false,
39-
sidebarScrollable: false,
40-
},
41-
serverRoutes: {
42-
selectedRoute: null,
43-
view: 'tree',
44-
inputDefaults: {
45-
query: [],
46-
body: [],
47-
headers: [],
19+
// Nitro's experimental `tasks` feature toggles the default `serverTasks.enabled`
20+
// value; this is the only genuinely dynamic default, so it's tracked as an
21+
// explicit flag rather than by mutating a shared options object (see
22+
// `setServerTasksEnabledByDefault`).
23+
let serverTasksEnabledByDefault = false
24+
25+
/**
26+
* Called when the host Nuxt app has opted into Nitro's experimental `tasks`
27+
* feature, so `createDefaultTabOptions()` defaults `serverTasks.enabled` to
28+
* `true` for apps that haven't explicitly configured it.
29+
*/
30+
export function setServerTasksEnabledByDefault(enabled: boolean) {
31+
serverTasksEnabledByDefault = enabled
32+
}
33+
34+
/**
35+
* Builds a brand-new default tab options object graph.
36+
*
37+
* This is a factory rather than a shared constant on purpose: callers used to
38+
* share (and sometimes mutate) a single module-level object, which let one
39+
* project's settings permanently overwrite the defaults for every other Nuxt
40+
* instance in the same process. Call this every time you need a default —
41+
* never cache or mutate its result.
42+
*/
43+
export function createDefaultTabOptions(): NuxtDevToolsOptions {
44+
return {
45+
behavior: {
46+
telemetry: null,
47+
openInEditor: undefined,
4848
},
49-
sendFrom: 'app',
50-
},
51-
serverTasks: {
52-
enabled: false,
53-
selectedTask: null,
54-
view: 'list',
55-
inputDefaults: {
56-
query: [],
57-
body: [],
58-
headers: [{ active: true, key: 'Content-Type', value: 'application/json', type: 'string' }],
49+
ui: {
50+
componentsView: 'list',
51+
componentsGraphShowNodeModules: false,
52+
componentsGraphShowGlobalComponents: true,
53+
componentsGraphShowPages: false,
54+
componentsGraphShowLayouts: false,
55+
componentsGraphShowWorkspace: true,
56+
interactionCloseOnOutsideClick: false,
57+
showExperimentalFeatures: false,
58+
showHelpButtons: true,
59+
scale: 1,
60+
hiddenTabs: [],
61+
pinnedTabs: [],
62+
hiddenTabCategories: [],
63+
sidebarExpanded: false,
64+
sidebarScrollable: false,
5965
},
60-
},
61-
assets: {
62-
view: 'grid',
63-
},
66+
serverRoutes: {
67+
selectedRoute: null,
68+
view: 'tree',
69+
inputDefaults: {
70+
query: [],
71+
body: [],
72+
headers: [],
73+
},
74+
sendFrom: 'app',
75+
},
76+
serverTasks: {
77+
enabled: serverTasksEnabledByDefault,
78+
selectedTask: null,
79+
view: 'list',
80+
inputDefaults: {
81+
query: [],
82+
body: [],
83+
headers: [{ active: true, key: 'Content-Type', value: 'application/json', type: 'string' }],
84+
},
85+
},
86+
assets: {
87+
view: 'grid',
88+
},
89+
}
6490
}
6591

6692
export const defaultAllowedExtensions = [

‎packages/devtools/src/module-main.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { join } from 'pathe'
1212
import sirv from 'sirv'
1313
import { searchForWorkspaceRoot } from 'vite'
1414
import { version } from '../package.json'
15-
import { defaultTabOptions } from './constant'
15+
import { createDefaultTabOptions, setServerTasksEnabledByDefault } from './constant'
1616
import { clientDir, packageDir, runtimeDir } from './dirs'
1717
import { setupRPC } from './server-rpc'
1818
import { readLocalOptions } from './utils/local-options'
@@ -113,9 +113,7 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) {
113113
filename: 'devtools/settings.mjs',
114114
async getContents() {
115115
const uiOptions = await readLocalOptions<NuxtDevToolsOptions['ui']>(
116-
{
117-
...defaultTabOptions.ui,
118-
},
116+
createDefaultTabOptions().ui,
119117
{ root: nuxt.options.rootDir },
120118
)
121119
return `export default ${JSON.stringify({
@@ -127,7 +125,7 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) {
127125
nuxt.hook('nitro:config', (config) => {
128126
// Check user opted-in for tasks
129127
if (config.experimental?.tasks)
130-
defaultTabOptions.serverTasks.enabled = true
128+
setServerTasksEnabledByDefault(true)
131129

132130
// Inject inline script
133131
config.externals = config.externals || {}

‎packages/devtools/src/server-rpc/options.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { NuxtDevToolsOptions, NuxtDevtoolsServerContext, ServerFunctions } from '../types'
2-
import { defaultTabOptions } from '../constant'
2+
import { createDefaultTabOptions } from '../constant'
33
import { clearLocalOptions, readLocalOptions, writeLocalOptions } from '../utils/local-options'
44

55
let options: NuxtDevToolsOptions | undefined
@@ -9,20 +9,30 @@ export function getOptions() {
99
}
1010

1111
export function setupOptionsRPC({ nuxt }: NuxtDevtoolsServerContext) {
12+
const hasReadOnce = new Set<keyof NuxtDevToolsOptions>()
13+
1214
async function getOptions<T extends keyof NuxtDevToolsOptions>(tab: T): Promise<NuxtDevToolsOptions[T]> {
13-
if (!options || options[tab]) {
14-
options = defaultTabOptions
15+
// `createDefaultTabOptions()` always returns a brand-new object graph, so
16+
// this can never alias (or leak mutations back into) another instance's
17+
// defaults the way sharing a single module-level constant used to.
18+
if (!options)
19+
options = createDefaultTabOptions()
20+
if (!hasReadOnce.has(tab))
1521
await read(tab)
16-
}
1722

18-
return options![tab]
23+
return options[tab]
1924
}
2025

2126
async function read<T extends keyof NuxtDevToolsOptions>(tab: T) {
22-
options![tab] = await readLocalOptions<NuxtDevToolsOptions[T]>(defaultTabOptions[tab], {
27+
// Source defaults fresh from `createDefaultTabOptions()` rather than the
28+
// cached `options![tab]`, so a dynamic default set after the cache was
29+
// first created — e.g. `serverTasks.enabled` toggled by Nitro's `tasks`
30+
// feature — is still honored the first time this tab is actually read.
31+
options![tab] = await readLocalOptions<NuxtDevToolsOptions[T]>(createDefaultTabOptions()[tab], {
2332
root: nuxt.options.rootDir,
2433
key: tab !== 'ui' && tab,
2534
})
35+
hasReadOnce.add(tab)
2636
return options
2737
}
2838

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createDefaultTabOptions, setServerTasksEnabledByDefault } from '../src/constant'
3+
import { setupOptionsRPC } from '../src/server-rpc/options'
4+
5+
describe('createDefaultTabOptions', () => {
6+
it('returns a brand-new object graph on every call', () => {
7+
const a = createDefaultTabOptions()
8+
const b = createDefaultTabOptions()
9+
expect(a).not.toBe(b)
10+
expect(a.ui).not.toBe(b.ui)
11+
expect(a.ui.hiddenTabs).not.toBe(b.ui.hiddenTabs)
12+
})
13+
14+
it('does not leak mutations across calls when the options RPC mutates its cache', async () => {
15+
// Guards against the regression where the options RPC aliased (and later
16+
// shallow-copied from) a shared defaults object, letting callers corrupt
17+
// it via nested objects/arrays such as `ui.hiddenTabs`.
18+
const ctx = {
19+
nuxt: { options: { rootDir: '/mock/root' }, callHook: () => {} },
20+
} as any
21+
22+
const rpc = setupOptionsRPC(ctx)
23+
const uiOptions = await rpc.getOptions('ui')
24+
25+
// Mutate a nested array to verify isolation.
26+
uiOptions.hiddenTabs.push('test-leak')
27+
expect(createDefaultTabOptions().ui.hiddenTabs).not.toContain('test-leak')
28+
})
29+
30+
it('reflects setServerTasksEnabledByDefault in subsequently created objects', () => {
31+
expect(createDefaultTabOptions().serverTasks.enabled).toBe(false)
32+
33+
setServerTasksEnabledByDefault(true)
34+
try {
35+
expect(createDefaultTabOptions().serverTasks.enabled).toBe(true)
36+
}
37+
finally {
38+
// Reset the module-level flag so it doesn't leak into other tests.
39+
setServerTasksEnabledByDefault(false)
40+
}
41+
})
42+
})

0 commit comments

Comments
 (0)