Compare commits

..
Author SHA1 Message Date
Tunglies 1105eedeb6 fix(react): avoid redundant DNS and proxy updates (#8543) 2026-11-03 12:58:43 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 891757ede6 chore(deps): update dependency vite to v8.3.3 [security] (#8538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-03 12:36:14 +00:00
23 changed files with 114 additions and 2207 deletions
-34
View File
@@ -256,40 +256,6 @@ export default defineConfig([
},
},
},
{
files: ['scripts/react/*.{mjs,mts,ts,tsx}'],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.json',
},
},
settings: {
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: './scripts/react/tsconfig.json',
}),
],
},
},
{
files: ['scripts/react/*.{mjs,mts}'],
languageOptions: {
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.node.json',
},
},
},
{
files: ['scripts/react/{boundaries,material,scene}.tsx'],
// The isolated fixtures use full reloads and preserve aliased Hook names.
rules: {
'react-refresh/only-export-components': 'off',
'@eslint-react/no-unnecessary-use-prefix': 'off',
},
},
{
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(),
+1 -10
View File
@@ -1,15 +1,6 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"src/polyfills/*.js!",
"scripts/check-contract-changes.mjs!",
"scripts/react/scene.tsx!",
"scripts/react/profiler.ts!",
"scripts/react/boundaries.tsx!",
"scripts/react/material.tsx!",
"scripts/react/yaml.ts!",
"scripts/react/wdyr.ts!"
],
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [
"files",
+2 -9
View File
@@ -40,11 +40,7 @@
"perf:build": "node scripts/perf/build.mjs",
"perf:run": "node scripts/perf/run.mjs",
"perf:compare": "node scripts/perf/compare.mjs",
"perf:test": "node --test scripts/perf/report.test.mjs",
"dev:profile": "cross-env CVR_REACT_DIAGNOSTICS=profiler node scripts/dev.mjs dev",
"react:ab": "node scripts/react/ab.mjs",
"dev:wdyr": "cross-env CVR_REACT_DIAGNOSTICS=wdyr node scripts/dev.mjs dev",
"audit:react": "node scripts/react/doctor.mjs"
"perf:test": "node --test scripts/perf/report.test.mjs"
},
"dependencies": {
"@dnd-kit/collision": "^0.5.0",
@@ -138,10 +134,7 @@
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.1.0",
"ws": "8.22.1",
"playwright": "1.64.1",
"@welldone-software/why-did-you-render": "10.0.1",
"react-doctor": "0.9.21"
"ws": "8.22.1"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
+89 -1027
View File
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
# React diagnostics
`pnpm dev:profile` runs the usual development launcher with a root React
Profiler. In WebView DevTools, `window.__CVR_REACT_PROFILER__.read()` returns
copies of the latest 1,000 standard Profiler callback records; `.clear()` drops
them. Records contain timing and commit metadata, never business props. Normal
development and production builds do not load the collector.
`pnpm dev:wdyr` instead initializes why-did-you-render before loading the app
and uses its development JSX runtime. It reports avoidable renders for
`ProxyItem`, `ProxyItemMini` and `ProxyTunCard` in the console. The modes are
mutually exclusive; `CVR_REACT_DIAGNOSTICS` accepts only `profiler` or `wdyr` on
the development server. Each mode uses its own Vite cache. Production builds
ignore this variable and include neither diagnostic entry nor runtime.
`pnpm audit:react` scans supported source files under `src`. Pass file paths to
limit the scan, for example `pnpm audit:react src/hooks/use-proxy-delay-state.ts`;
add `--json` for the original structured report. Diagnostics are advisory.
Execution errors and incomplete scans fail the command. Telemetry, remote scores
and supply-chain scanning are disabled; the command does not fix code, erase
historical diagnostics or run in CI.
Install the browser explicitly with `pnpm exec playwright install chromium`.
Then run `pnpm react:ab --base <ref> --head <ref>`. Both Git trees run against
one installed dependency set and the same fixed DNS, delay-hook and proxy-tab
fixtures. The command never installs a browser or changes either checkout.
It retains source hashes, dependency identity, browser/environment information,
behavior assertions, render observations and profiles under `/tmp/cvr-react-ab-*`.
Each version and mode has separate Vite caches and output. Three pairs alternate
order in ordinary development, Strict Mode and production. A behavior failure
returns a nonzero exit status. DNS stale serialization and same-value renders
are observations so a known baseline can be compared with its fix. Append
`--assert dns` to require the head version to keep the latest serialized YAML in sync with
continuous field edits; this is the focused DNS regression assertion.
`--assert delay` requires same-value delay updates to retain their reference and memoized consumer,
while each changed field (including clearing elapsed time) propagates.
`--assert tabs` requires zero tab renders during unrelated parent updates. Use
`--assert dns --assert delay --assert tabs` for a cumulative candidate.
The fixtures execute the actual selected components and hook. IPC, data,
editor and UI rendering boundaries are replaced, and outgoing network requests
are rejected. DNS serialization is observed at `js-yaml.dump`; tab execution is
observed at the Material Paper boundary. These are component results, not native
Tauri integration, real DNS/proxy/TUN writes, or whole-application CPU/RSS gains.
Production React does not emit Profiler callbacks. Timing is diagnostic and has
no hard acceptance threshold; measure native release CPU/RSS separately and
calibrate A/A before claiming a performance gain. Keep raw reports out of Git.
-596
View File
@@ -1,596 +0,0 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
symlink,
writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import react from '@vitejs/plugin-react'
import { chromium } from 'playwright'
import { build, createServer, preview } from 'vite'
const { values } = parseArgs({
options: {
base: { type: 'string' },
head: { type: 'string' },
assert: { type: 'string', multiple: true },
},
})
if (!values.base || !values.head)
throw new Error('Usage: pnpm react:ab --base <ref> --head <ref>')
const assertions = new Set(values.assert ?? [])
for (const name of assertions) {
if (!['dns', 'delay', 'tabs'].includes(name))
throw new Error(`Unknown regression assertion: ${name}`)
}
const harness = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(harness, '../..')
const output = await mkdtemp('/tmp/cvr-react-ab-')
const dependencies = path.join(root, 'node_modules')
const frozenHarness = path.join(output, 'harness')
await cp(harness, frozenHarness, {
recursive: true,
filter: (file) => !path.basename(file).startsWith('tsconfig'),
})
const files = [
'src/components/setting/mods/dns-viewer.tsx',
'src/hooks/use-proxy-delay-state.ts',
'src/components/home/proxy-tun-card.tsx',
]
const hash = (content) => createHash('sha256').update(content).digest('hex')
function git(...args) {
const result = spawnSync('git', args, {
cwd: root,
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(result.stderr.toString())
return result.stdout
}
const manifest = {
assertions: [...assertions],
environment: {
platform: process.platform,
architecture: process.arch,
node: process.version,
os: os.release(),
lock: hash(await readFile(path.join(root, 'pnpm-lock.yaml'))),
},
harness: Object.fromEntries(
await Promise.all(
(await readdir(frozenHarness)).map(async (file) => [
file,
hash(await readFile(path.join(frozenHarness, file))),
]),
),
),
installedLock: hash(
await readFile(path.join(dependencies, '.pnpm/lock.yaml')),
),
sources: {},
runs: [],
}
let browser
try {
// Playwright intentionally fails here if its explicitly installed browser is missing.
browser = await chromium.launch({ headless: true })
manifest.environment.browser = browser.version()
for (const [variant, ref] of Object.entries({
base: values.base,
head: values.head,
})) {
const tree = git('rev-parse', '--verify', `${ref}^{tree}`).toString().trim()
const source = path.join(output, variant)
await mkdir(source)
const archive = git('archive', tree, 'src')
const extracted = spawnSync('tar', ['-x', '-C', source], { input: archive })
if (extracted.status !== 0) throw new Error(extracted.stderr.toString())
await cp(frozenHarness, path.join(source, 'fixture'), { recursive: true })
await symlink(dependencies, path.join(source, 'node_modules'), 'dir')
manifest.sources[variant] = {
ref,
tree,
files: Object.fromEntries(
await Promise.all(
files.map(async (file) => [
file,
hash(await readFile(path.join(source, file))),
]),
),
),
}
}
for (const mode of ['development', 'strict', 'production']) {
const servers = {}
try {
for (const variant of ['base', 'head']) {
const source = path.join(output, variant)
const fixture = path.join(source, 'fixture')
const boundary = path.join(fixture, 'boundaries.tsx')
const aliases = [
'@/hooks/use-clash',
'@/hooks/use-verge',
'@/hooks/use-profiles',
'@/hooks/use-system-state',
'@/hooks/use-system-proxy-state',
'@/services/states',
'@/services/cmds',
'@/services/mutate',
'@/services/notice-service',
'@/components/base',
'@/components/shared/proxy-control-switches',
'react-i18next',
].map((find) => ({ find, replacement: boundary }))
const config = {
configFile: false,
root: fixture,
cacheDir: path.join(output, 'cache', variant, mode),
plugins: [react()],
resolve: {
alias: [
...aliases,
{
find: '@/services/delay',
replacement: path.join(fixture, 'delay.ts'),
},
{
find: /^@mui\/material$/,
replacement: path.join(fixture, 'material.tsx'),
},
{
find: 'fixture-material',
replacement: path.join(dependencies, '@mui/material/index.mjs'),
},
{ find: 'js-yaml', replacement: path.join(fixture, 'yaml.ts') },
{
find: 'fixture-yaml',
replacement: path.join(
dependencies,
'js-yaml/dist/js-yaml.mjs',
),
},
{ find: '@', replacement: path.join(source, 'src') },
{
find: /^react(?=\/|$)/,
replacement: path.join(dependencies, 'react'),
},
{
find: /^react-dom(?=\/|$)/,
replacement: path.join(dependencies, 'react-dom'),
},
],
dedupe: ['react', 'react-dom'],
},
define: { OS_PLATFORM: JSON.stringify(process.platform) },
server: {
host: '127.0.0.1',
port: 0,
hmr: false,
fs: {
allow: [
output,
await realpath(output),
await realpath(dependencies),
],
},
},
build: {
outDir: path.join(output, 'dist', variant, mode),
emptyOutDir: true,
},
preview: { host: '127.0.0.1', port: 0 },
}
if (mode === 'production') {
await build(config)
servers[variant] = await preview(config)
} else {
servers[variant] = await createServer(config)
await servers[variant].listen()
}
}
for (const [pair, order] of [
['base', 'head'],
['head', 'base'],
['base', 'head'],
].entries()) {
for (const variant of order) {
const label = `${mode}-${pair + 1}-${variant}`
const context = await browser.newContext()
const errors = []
const address = servers[variant].httpServer.address()
const origin = `http://127.0.0.1:${address.port}`
await context.route('**/*', (route) => {
if (new URL(route.request().url()).origin === origin)
return route.continue()
errors.push(`Unexpected network: ${route.request().url()}`)
return route.abort()
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(String(error)))
const result = { label, variant, mode, pair: pair + 1, errors }
try {
await page.goto(`${origin}/${mode === 'strict' ? '?strict' : ''}`)
await page.waitForSelector('#delay', { timeout: 60000 })
await page.waitForFunction(
() => window.__REACT_AB__.read().listeners.length === 1,
)
result.tabs = await tabs(page)
if (variant === 'head' && assertions.has('tabs')) {
assert.equal(
result.tabs.unrelated,
0,
'Unrelated parent updates must not rerender the memoized tabs',
)
}
result.dns = await dns(page)
if (variant === 'head' && assertions.has('dns')) {
for (const edit of result.dns.edits) {
assert.equal(
edit.serialized.at(-1),
edit.value,
'Latest DNS serialization must match the edited field',
)
}
}
result.delay = await delay(page)
if (variant === 'head' && assertions.has('delay')) {
for (const [index, observation] of result.delay.entries()) {
assert.equal(
observation.sameReference,
index === 1,
'Preserve the reference only when all three delay fields match',
)
}
assert.equal(
result.delay[1].childRenders,
result.delay[0].childRenders,
'Equal delay state must not rerender the memoized consumer',
)
}
result.profile = await page.evaluate(() => {
const before = window.__CVR_REACT_PROFILER__.read()
window.__CVR_REACT_PROFILER__.clear()
return { before, after: window.__CVR_REACT_PROFILER__.read() }
})
assert.equal(result.profile.after.length, 0)
result.collector = await page.evaluate(() =>
window.__REACT_AB__.checkCollector(),
)
assert.deepEqual(result.collector, {
length: 1000,
first: '5',
last: '1004',
copied: true,
cleared: true,
})
if (mode !== 'production')
assert(
result.profile.before.length > 0 &&
result.profile.before.length <= 1000,
)
assert.deepEqual(errors, [])
assert.deepEqual(
await page.evaluate(() =>
window.__REACT_AB__.fixture.notices.filter(
(notice) => notice.error,
),
),
[],
)
result.ok = true
} catch (error) {
result.snapshot = await page
.evaluate(() => window.__REACT_AB__?.read())
.catch(() => null)
result.ok = false
result.error = String(error)
throw error
} finally {
manifest.runs.push(result)
await writeFile(
path.join(output, `${label}.json`),
JSON.stringify(result, null, 2),
)
await context.close()
console.log(`${label}: ${result.ok ? 'pass' : 'FAIL'}`)
}
}
}
} finally {
for (const server of Object.values(servers)) {
if (server.close) await server.close()
else
await new Promise((resolve, reject) =>
server.httpServer.close((error) =>
error ? reject(error) : resolve(),
),
)
}
}
}
} finally {
await browser?.close()
await writeFile(
path.join(output, 'report.json'),
JSON.stringify(manifest, null, 2),
)
console.log(`Evidence: ${output}`)
}
async function settle(page) {
await page.waitForTimeout(100)
}
async function tabs(page) {
const buttons = page.locator('#tabs .MuiPaper-root')
const readTabs = () =>
page.evaluate(() =>
Object.fromEntries(
window.__REACT_AB__.fixture.tabRenders.map((tab) => [
tab.label.split('.').at(-1),
tab,
]),
),
)
assert.equal(await buttons.count(), 2)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
const before = await page.evaluate(
() => window.__REACT_AB__.fixture.tabRenders.length,
)
for (let index = 0; index < 5; index++) {
await page.evaluate(() => window.__REACT_AB__.update())
await settle(page)
}
const unrelated =
(await page.evaluate(() => window.__REACT_AB__.fixture.tabRenders.length)) -
before
await buttons.nth(1).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, false)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'tun',
)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: true }))
await settle(page)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal((await readTabs()).systemProxy.active, false)
await buttons.nth(0).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'system',
)
await page.evaluate(() => {
window.__REACT_AB__.fixture.indicator = true
window.__REACT_AB__.fixture.verge.enable_tun_mode = true
window.__REACT_AB__.update()
})
await settle(page)
assert(Object.values(await readTabs()).every((tab) => tab.indicator))
await page.evaluate(() => {
window.__REACT_AB__.fixture.language = 'zh'
window.__REACT_AB__.update()
})
await settle(page)
const updated = Object.values(await readTabs())
assert(updated.every((tab) => tab.label.startsWith('zh:')))
assert(
(await page.locator('#tabs').textContent()).includes(
'zh:home.components.proxyTun.status.systemProxyEnabled',
),
)
const beforeColor = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
await page.evaluate(() => {
window.__REACT_AB__.fixture.theme = 'dark'
window.__REACT_AB__.update()
})
await settle(page)
const color = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
assert.notEqual(color, beforeColor)
return { unrelated, updated, beforeColor, color }
}
async function dns(page) {
await page.evaluate(() => window.__REACT_AB__.open())
const listen = page.locator('#dns input[placeholder=":53"]')
await page.waitForFunction(
() =>
document.querySelector('#dns input[placeholder=":53"]')?.value ===
':5353',
)
const edits = []
for (const value of [':54', ':55', ':56']) {
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(value)
await settle(page)
assert.equal(await listen.inputValue(), value)
edits.push({
value,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map(
(config) => config.dns.listen,
),
),
})
}
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(':')
await listen.pressSequentially('567', { delay: 0 })
await settle(page)
assert.equal(await listen.inputValue(), ':567')
edits.push({
value: ':567',
rapid: true,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map((config) => config.dns.listen),
),
})
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 1,
)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.fixture.saved[0].dns.listen),
':567',
)
await page.evaluate(() => window.__REACT_AB__.open())
await page.waitForSelector('#dns')
await page
.getByRole('button', { name: /shared.actions.resetToDefault$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':53')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
const editor = page.locator('#fixture-monaco')
assert.match(await editor.inputValue(), /listen: ["']?:53/)
await editor.fill(
'dns:\n enable: true\n listen: ":8053"\nhosts:\n example.test: 127.0.0.1\n',
)
await page
.getByRole('button', { name: /shared.editorModes.visualization$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':8053')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 2,
)
const saved = await page.evaluate(() => window.__REACT_AB__.fixture.saved)
assert.equal(saved[1].dns.listen, ':8053')
assert.equal(saved[1].hosts['example.test'], '127.0.0.1')
return { edits, saved }
}
async function delay(page) {
const updates = [
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 101 },
{ delay: 11, updatedAt: 101 },
{ delay: -1, updatedAt: 102 },
{ delay: 0, updatedAt: 103 },
{ delay: 1000000, elapsed: 20, updatedAt: 104 },
]
const observations = []
for (const update of updates) {
await page.evaluate((value) => {
window.__REACT_AB__.remember()
window.__REACT_AB__.delay.emit('Node 1', 'Group 1', value)
}, update)
await settle(page)
const result = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(result.delay, update)
observations.push({
update,
sameReference: result.sameReference,
childRenders: result.childRenders,
})
}
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({ delay: 0, updatedAt: 200 }),
)
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
0,
)
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({
delay: 1000000,
elapsed: 20,
updatedAt: 201,
}),
)
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().delay),
{ delay: 1000000, elapsed: 20, updatedAt: 201 },
)
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
let state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 1:Node 2'])
assert.deepEqual(state.delay, { delay: -1, updatedAt: 0 })
await page.evaluate(() => window.__REACT_AB__.update({ group: 'Group 2' }))
await settle(page)
state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 2:Node 2'])
for (const name of ['DIRECT', 'Unknown']) {
await page.evaluate((name) => window.__REACT_AB__.update({ name }), name)
await settle(page)
const preset = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(preset.listeners, [])
assert.deepEqual(preset.delay, { delay: -1, updatedAt: 0 })
}
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
['Group 2:Node 2'],
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
[],
)
return observations
}
-68
View File
@@ -1,68 +0,0 @@
import { Switch as MuiSwitch } from '@mui/material'
import { fixture } from './state'
const translations = {
en: (key: string) => `en:${key}`,
zh: (key: string) => `zh:${key}`,
}
export const useTranslation = () => ({
t: translations[fixture.language as 'en' | 'zh'],
})
export const useVerge = () => ({ verge: fixture.verge })
export const useProfiles = () => ({ current: { uid: 'fixture' } })
export const useClash = () => ({
mutateClash: () => {
throw new Error('Unexpected mutateClash')
},
})
export const useSystemState = () => ({ isTunModeAvailable: fixture.available })
export const useSystemProxyState = () => ({ indicator: fixture.indicator })
export const useThemeMode = () => fixture.theme
export const getDnsConfigContent = async () => {
fixture.commands.push('getDnsConfigContent')
return fixture.initialDns
}
export const saveDnsConfig = async (config: any) => {
fixture.commands.push('saveDnsConfig')
fixture.saved.push(structuredClone(config))
}
export const validateDnsConfig = async () => {
fixture.commands.push('validateDnsConfig')
return { status: 'valid' }
}
export const applyDnsConfig = () => {
throw new Error('Unexpected applyDnsConfig')
}
export const mutate = (fn: () => unknown) => fn()
export const showNotice = {
error: (...args: any[]) => fixture.notices.push({ error: args }),
success: (...args: any[]) => fixture.notices.push({ success: args }),
}
export const Switch = MuiSwitch
export function BaseDialog({ open, title, children, onClose, onOk }: any) {
return open ? (
<section id="dns">
{title}
<button id="close-dns" onClick={onClose}>
close
</button>
<button id="save-dns" onClick={onOk}>
save
</button>
{children}
</section>
) : null
}
export function MonacoEditor({ value, onChange }: any) {
return (
<textarea
id="fixture-monaco"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
)
}
export default function ProxyControlSwitches({ label }: any) {
return <output id="fixture-controls">{label}</output>
}
-36
View File
@@ -1,36 +0,0 @@
type Update = { delay: number; elapsed?: number; updatedAt: number }
const listeners = new Map<string, (update: Update) => void>()
const cache = new Map<string, Update>()
let finish: ((update: Update) => void) | undefined
const key = (name: string, group: string) => `${group}:${name}`
export const delayFixture = {
listeners,
emit(name: string, group: string, update: Update) {
cache.set(key(name, group), update)
listeners.get(key(name, group))?.(update)
},
finish(update: Update) {
if (!finish) throw new Error('No pending delay request')
finish(update)
finish = undefined
},
}
export default {
setListener(name: string, group: string, listener: (update: Update) => void) {
listeners.set(key(name, group), listener)
},
removeListener(name: string, group: string) {
listeners.delete(key(name, group))
},
getDelayUpdate(name: string, group: string) {
return cache.get(key(name, group))
},
getDelayFix() {
return -1
},
checkDelay() {
return new Promise<Update>((resolve) => {
finish = resolve
})
},
}
-77
View File
@@ -1,77 +0,0 @@
import { spawnSync } from 'node:child_process'
import { statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import { globSync } from 'glob'
const { values, positionals } = parseArgs({
options: { json: { type: 'boolean' } },
allowPositionals: true,
})
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const files = positionals.length
? positionals
: globSync('src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,html}', {
cwd: root,
nodir: true,
}).sort()
if (files.length === 0) throw new Error('No source files found')
for (const file of files) {
if (
!/\.(?:[jt]sx?|[cm][jt]s|html)$/.test(file) ||
!statSync(path.resolve(root, file)).isFile()
)
throw new Error(`Expected a source file: ${file}`)
}
const executable = fileURLToPath(
new URL('../bin/react-doctor.js', import.meta.resolve('react-doctor')),
)
const result = spawnSync(
process.execPath,
[
executable,
...files,
'--json',
'--no-telemetry',
'--no-supply-chain',
'--blocking',
'none',
'--yes',
],
{
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
maxBuffer: 64 * 1024 * 1024,
},
)
if (result.error) throw result.error
if (result.signal)
throw new Error(`React Doctor terminated by ${result.signal}`)
const report = JSON.parse(result.stdout)
if (values.json) {
process.stdout.write(result.stdout)
} else {
for (const diagnostic of report.diagnostics) {
console.log(
`${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.plugin}/${diagnostic.rule}\n ${diagnostic.message}`,
)
}
console.log(
`${report.summary.totalDiagnosticCount} advisory diagnostics across ${report.projects.reduce((count, project) => count + project.analyzedFileCount, 0)} analyzed files`,
)
}
// In 0.9.21, --blocking none also masks linter execution failures.
const incomplete = report.projects.filter((project) => !project.complete)
if (report.error) console.error(report.error.message)
for (const project of incomplete) {
console.error(
'Incomplete React Doctor scan:',
project.directory,
project.skippedCheckReasons,
)
}
process.exitCode =
result.status || (!report.ok || incomplete.length > 0 ? 1 : 0)
-5
View File
@@ -1,5 +0,0 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><title>React component A/B</title></head>
<body><div id="root"></div><script type="module" src="./scene.tsx"></script></body>
</html>
-16
View File
@@ -1,16 +0,0 @@
import { Paper as MuiPaper } from 'fixture-material'
export * from 'fixture-material'
import { fixture } from './state'
export function Paper(props: any) {
const { onClick, elevation, children, sx } = props
if (onClick && sx?.maxWidth === 160) {
fixture.tabRenders.push({
active: elevation === 2,
indicator: Boolean(children[2]),
label: children[1].props.children,
})
}
return <MuiPaper {...props} />
}
-19
View File
@@ -1,19 +0,0 @@
import type { ProfilerOnRenderCallback } from 'react'
export type ProfileRecord = Parameters<ProfilerOnRenderCallback>
const records: ProfileRecord[] = []
export const recordRender: ProfilerOnRenderCallback = (...record) => {
if (records.length === 1000) records.shift()
records.push(record)
}
export const profiler = {
read: () => records.map((record) => [...record]),
clear: () => {
records.length = 0
},
}
Object.assign(window, { __CVR_REACT_PROFILER__: profiler })
-124
View File
@@ -1,124 +0,0 @@
import { ThemeProvider, createTheme } from '@mui/material'
import {
Profiler,
StrictMode,
memo,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createRoot } from 'react-dom/client'
import { ProxyTunCard } from '@/components/home/proxy-tun-card'
import { DnsViewer } from '@/components/setting/mods/dns-viewer'
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
import { delayFixture } from './delay'
import { profiler, recordRender } from './profiler'
import { fixture } from './state'
const member = (name: string): any =>
name === 'Unknown'
? {
kind: 'unresolved',
ref: { kind: 'unresolved', name, reason: 'missing' },
}
: {
kind: 'node',
ref: { kind: 'node', recordId: name, name },
node: { name, history: [], source: { kind: 'core', proxyName: name } },
}
let update: (patch?: any) => void
let open: () => void
let delay: ReturnType<typeof useProxyDelayState>
let previous: unknown
let childRenders = 0
const DelayValue = memo(({ state }: any) => {
childRenders++
return <output id="delay">{JSON.stringify(state)}</output>
})
function DelayProbe({ name, group }: any) {
const resolved = useMemo(() => member(name), [name])
const current = useProxyDelayState(resolved, group)
useEffect(() => {
delay = current
})
return <DelayValue state={current.delayState} />
}
function Scene() {
const [state, setState] = useState({
revision: 0,
name: 'Node 1',
group: 'Group 1',
mounted: true,
})
const ref = useRef<any>(null)
useEffect(() => {
update = (patch = {}) =>
setState((old) => ({ ...old, ...patch, revision: old.revision + 1 }))
open = () => ref.current.open()
}, [])
const mode = fixture.theme
const theme = useMemo(() => createTheme({ palette: { mode } }), [mode])
return (
<ThemeProvider theme={theme}>
<main data-revision={state.revision}>
<section id="tabs" key={state.mounted ? 'mounted' : 'reset'}>
<ProxyTunCard />
</section>
<DnsViewer ref={ref} />
{state.mounted && <DelayProbe name={state.name} group={state.group} />}
</main>
</ThemeProvider>
)
}
const strict = new URLSearchParams(location.search).has('strict')
const app = (
<Profiler id="fixtures" onRender={recordRender}>
<Scene />
</Profiler>
)
createRoot(document.getElementById('root')!).render(
strict ? <StrictMode>{app}</StrictMode> : app,
)
Object.assign(window, {
__REACT_AB__: {
update: (patch?: any) => update(patch),
open: () => open(),
fixture,
delay: delayFixture,
request: () => {
void delay.onDelay()
},
remember: () => {
previous = delay.delayState
},
checkCollector: () => {
profiler.clear()
for (let index = 0; index < 1005; index++)
recordRender(String(index), 'update', 1, 2, 3, 4)
const records = profiler.read()
records[0][0] = 'changed copy'
const preserved = profiler.read()
const result = {
length: preserved.length,
first: preserved[0][0],
last: preserved.at(-1)![0],
copied: preserved[0][0] === '5',
cleared: false,
}
profiler.clear()
result.cleared = profiler.read().length === 0
return result
},
read: () => ({
fixture,
profile: profiler.read(),
childRenders,
sameReference: previous === delay.delayState,
delay: delay.delayState,
listeners: [...delayFixture.listeners.keys()],
}),
},
})
-13
View File
@@ -1,13 +0,0 @@
export const fixture = {
language: 'en',
theme: 'light' as 'light' | 'dark',
verge: { enable_tun_mode: false },
indicator: false,
available: true,
initialDns: 'dns:\n enable: true\n listen: ":5353"\n',
saved: [] as any[],
serialized: [] as any[],
tabRenders: [] as any[],
notices: [] as any[],
commands: [] as string[],
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["../../src/*"],
"@root/*": ["../../*"],
"fixture-material": ["../../node_modules/@mui/material"],
"fixture-yaml": ["../../node_modules/js-yaml"]
}
},
"include": ["./*.ts", "./*.tsx", "../../src"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": true,
"types": ["node"]
},
"include": ["./*.mjs", "./*.mts"]
}
-61
View File
@@ -1,61 +0,0 @@
import path from 'node:path'
import { normalizePath, type Plugin } from 'vite'
export function reactDiagnostics(mode: string | undefined): Plugin[] {
if (!mode) return []
return [
{
name: 'react-diagnostics',
enforce: 'pre',
apply: 'serve',
config() {
if (mode !== 'profiler' && mode !== 'wdyr')
throw new Error(`Unknown CVR_REACT_DIAGNOSTICS mode: ${mode}`)
return {
cacheDir: path.resolve('node_modules/.vite', mode),
...(mode === 'wdyr'
? {
oxc: {
jsx: {
importSource: '@welldone-software/why-did-you-render',
},
},
optimizeDeps: {
include: [
'@welldone-software/why-did-you-render',
'@welldone-software/why-did-you-render/jsx-dev-runtime',
],
},
}
: {}),
}
},
transformIndexHtml(html) {
if (mode !== 'wdyr') return html
const entry = 'src="./main.tsx"'
if (!html.includes(entry))
throw new Error('WDYR entry no longer matches index.html')
return html.replace(
entry,
`src="/@fs/${normalizePath(path.resolve('scripts/react/wdyr.ts'))}"`,
)
},
transform(code, id) {
if (mode !== 'profiler') return
if (id !== normalizePath(path.resolve('src/main.tsx'))) return
const open = '<React.StrictMode>'
const close = '</React.StrictMode>'
if (!code.includes(open) || !code.includes(close)) {
throw new Error('React profiling entry no longer matches main.tsx')
}
return {
code: `import { Profiler } from 'react'\nimport { recordRender } from '../scripts/react/profiler'\n${code}`
.replace(open, `<Profiler id="app" onRender={recordRender}>${open}`)
.replace(close, `${close}</Profiler>`),
map: null,
}
},
},
]
}
-10
View File
@@ -1,10 +0,0 @@
import whyDidYouRender from '@welldone-software/why-did-you-render'
import React from 'react'
whyDidYouRender(React, {
include: [/^(ProxyItem|ProxyItemMini|ProxyTunCard)$/],
trackAllPureComponents: false,
trackHooks: true,
})
await import('../../src/main')
-9
View File
@@ -1,9 +0,0 @@
import * as yaml from 'fixture-yaml'
export * from 'fixture-yaml'
import { fixture } from './state'
export function dump(value: any, options: any) {
fixture.serialized.push(structuredClone(value))
return yaml.dump(value, options)
}
+12 -7
View File
@@ -14,7 +14,7 @@ import {
useTheme,
Fade,
} from '@mui/material'
import { useState, useMemo, memo, FC } from 'react'
import { useState, useMemo, useCallback, memo, FC } from 'react'
import { useTranslation } from 'react-i18next'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
@@ -152,10 +152,15 @@ export const ProxyTunCard: FC = () => {
showNotice.error(err)
}
const handleTabChange = (tab: string) => {
setActiveTab(tab)
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab)
}
const handleSystemTabClick = useCallback(() => {
setActiveTab('system')
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, 'system')
}, [])
const handleTunTabClick = useCallback(() => {
setActiveTab('tun')
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, 'tun')
}, [])
const tabDescription = useMemo(() => {
if (activeTab === 'system') {
@@ -191,14 +196,14 @@ export const ProxyTunCard: FC = () => {
>
<TabButton
isActive={activeTab === 'system'}
onClick={() => handleTabChange('system')}
onClick={handleSystemTabClick}
icon={ComputerRounded}
label={t('settings.sections.system.toggles.systemProxy')}
hasIndicator={systemProxyIndicator}
/>
<TabButton
isActive={activeTab === 'tun'}
onClick={() => handleTabChange('tun')}
onClick={handleTunTabClick}
icon={TroubleshootRounded}
label={t('settings.sections.system.toggles.tunMode')}
hasIndicator={enable_tun_mode! && isTunModeAvailable}
+4 -14
View File
@@ -586,20 +586,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
? event.target.checked
: event.target.value
setValues((prev) => {
const newValues = {
...prev,
[field]: value,
}
if (visualization) {
setTimeout(() => {
updateYamlFromValues()
}, 0)
}
return newValues
})
setValues((prev) => ({
...prev,
[field]: value,
}))
}
return (
+6 -1
View File
@@ -17,7 +17,12 @@ const PRESET_PROXY_NAMES = [
'COMPATIBLE',
]
const identity = (_: DelayUpdate, next: DelayUpdate): DelayUpdate => next
const identity = (previous: DelayUpdate, next: DelayUpdate): DelayUpdate =>
previous.delay === next.delay &&
previous.elapsed === next.elapsed &&
previous.updatedAt === next.updatedAt
? previous
: next
const INITIAL_DELAY: DelayUpdate = { delay: -1, updatedAt: 0 }
-4
View File
@@ -5,16 +5,12 @@ import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import svgr from 'vite-plugin-svgr'
import { reactDiagnostics } from './scripts/react/vite.mts'
export default defineConfig({
root: 'src',
cacheDir: path.resolve('node_modules/.vite/default'),
server: { port: 3000 },
plugins: [
svgr(),
react(),
...reactDiagnostics(process.env.CVR_REACT_DIAGNOSTICS),
legacy({
modernTargets: ['edge>=109', 'safari>=14'],
renderLegacyChunks: false,