mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-04 06:02:08 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcacd5e0e6 | ||
|
|
d8cce2ee2c | ||
|
|
e69c8aeccf | ||
|
|
d2c6eafc9e |
@@ -102,10 +102,6 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
|
||||
|
||||
To run the development server, execute the following commands after all prerequisites for **Tauri** are installed:
|
||||
|
||||
`pnpm dev` also requires a matching `clash-verge-service-ipc` source checkout at
|
||||
`../clash-verge-service-ipc`; `pnpm prebuild` downloads binaries but does not
|
||||
provide this checkout. See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup details.
|
||||
|
||||
```shell
|
||||
pnpm i
|
||||
pnpm run prebuild
|
||||
|
||||
@@ -256,6 +256,40 @@ 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,6 +1,15 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
|
||||
"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!"
|
||||
],
|
||||
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
|
||||
"include": [
|
||||
"files",
|
||||
|
||||
+9
-2
@@ -40,7 +40,11 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/collision": "^0.5.0",
|
||||
@@ -134,7 +138,10 @@
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.1",
|
||||
"webdriverio": "10.1.0",
|
||||
"ws": "8.22.1"
|
||||
"ws": "8.22.1",
|
||||
"playwright": "1.64.1",
|
||||
"@welldone-software/why-did-you-render": "10.0.1",
|
||||
"react-doctor": "0.9.21"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
|
||||
Generated
+1027
-89
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,596 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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)
|
||||
@@ -0,0 +1,5 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,16 @@
|
||||
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} />
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 })
|
||||
@@ -0,0 +1,124 @@
|
||||
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()],
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
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[],
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"],
|
||||
"@root/*": ["../../*"],
|
||||
"fixture-material": ["../../node_modules/@mui/material"],
|
||||
"fixture-yaml": ["../../node_modules/js-yaml"]
|
||||
}
|
||||
},
|
||||
"include": ["./*.ts", "./*.tsx", "../../src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./*.mjs", "./*.mts"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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')
|
||||
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
useTheme,
|
||||
Fade,
|
||||
} from '@mui/material'
|
||||
import { useState, useMemo, useCallback, memo, FC } from 'react'
|
||||
import { useState, useMemo, memo, FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
|
||||
@@ -152,15 +152,10 @@ export const ProxyTunCard: FC = () => {
|
||||
showNotice.error(err)
|
||||
}
|
||||
|
||||
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 handleTabChange = (tab: string) => {
|
||||
setActiveTab(tab)
|
||||
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab)
|
||||
}
|
||||
|
||||
const tabDescription = useMemo(() => {
|
||||
if (activeTab === 'system') {
|
||||
@@ -196,14 +191,14 @@ export const ProxyTunCard: FC = () => {
|
||||
>
|
||||
<TabButton
|
||||
isActive={activeTab === 'system'}
|
||||
onClick={handleSystemTabClick}
|
||||
onClick={() => handleTabChange('system')}
|
||||
icon={ComputerRounded}
|
||||
label={t('settings.sections.system.toggles.systemProxy')}
|
||||
hasIndicator={systemProxyIndicator}
|
||||
/>
|
||||
<TabButton
|
||||
isActive={activeTab === 'tun'}
|
||||
onClick={handleTunTabClick}
|
||||
onClick={() => handleTabChange('tun')}
|
||||
icon={TroubleshootRounded}
|
||||
label={t('settings.sections.system.toggles.tunMode')}
|
||||
hasIndicator={enable_tun_mode! && isTunModeAvailable}
|
||||
|
||||
@@ -586,10 +586,20 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
? event.target.checked
|
||||
: event.target.value
|
||||
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}))
|
||||
setValues((prev) => {
|
||||
const newValues = {
|
||||
...prev,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
if (visualization) {
|
||||
setTimeout(() => {
|
||||
updateYamlFromValues()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return newValues
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,12 +17,7 @@ const PRESET_PROXY_NAMES = [
|
||||
'COMPATIBLE',
|
||||
]
|
||||
|
||||
const identity = (previous: DelayUpdate, next: DelayUpdate): DelayUpdate =>
|
||||
previous.delay === next.delay &&
|
||||
previous.elapsed === next.elapsed &&
|
||||
previous.updatedAt === next.updatedAt
|
||||
? previous
|
||||
: next
|
||||
const identity = (_: DelayUpdate, next: DelayUpdate): DelayUpdate => next
|
||||
|
||||
const INITIAL_DELAY: DelayUpdate = { delay: -1, updatedAt: 0 }
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user