diff --git a/.github/scripts/bug-server-dispatch.cjs b/.github/scripts/bug-server-dispatch.cjs new file mode 100644 index 000000000..815be0bec --- /dev/null +++ b/.github/scripts/bug-server-dispatch.cjs @@ -0,0 +1,98 @@ +async function resolveBugServerTarget({ github, context, prNumber, headSha }) { + const defaultBranch = context.payload.repository.default_branch; + if (context.ref !== `refs/heads/${defaultBranch}`) { + throw new Error(`Run this workflow from the default branch (${defaultBranch}).`); + } + if (!/^[1-9][0-9]*$/.test(prNumber) || !Number.isSafeInteger(Number(prNumber))) { + throw new Error('PR number must be a positive integer.'); + } + if (headSha.length !== 40 || !/^[0-9a-f]+$/i.test(headSha)) { + throw new Error('Head SHA must be a full 40-character hexadecimal commit SHA.'); + } + + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, + pull_number: Number(prNumber) + }); + const repository = `${context.repo.owner}/${context.repo.repo}`; + if (pull.base.repo.full_name !== repository) { + throw new Error(`PR base repository must be ${repository}.`); + } + const sha = headSha.toLowerCase(); + if (pull.head.sha !== sha) { + throw new Error( + `PR #${prNumber} head changed: expected ${sha}, current ${pull.head.sha}. Review the current head before retrying.` + ); + } + + if (!pull.head.repo) { + throw new Error('The PR head repository no longer exists.'); + } + const workflowPath = '.github/workflows/bug-server-pr-bundle.yml'; + const { data: workflow } = await github.rest.actions.getWorkflow({ + ...context.repo, + workflow_id: 'bug-server-pr-bundle.yml' + }); + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + ...context.repo, + workflow_id: workflow.id, + event: 'pull_request', + head_sha: sha, + status: 'success', + per_page: 100 + }); + const run = runs + .filter( + candidate => + candidate.workflow_id === workflow.id && + candidate.path === workflowPath && + candidate.event === 'pull_request' && + candidate.status === 'completed' && + candidate.conclusion === 'success' && + candidate.head_sha === sha && + candidate.head_branch === pull.head.ref && + candidate.repository?.id === pull.base.repo.id && + candidate.head_repository?.id === pull.head.repo.id && + // GitHub omits PR associations for fork runs. Repository, branch and SHA still bind the source. + (!candidate.pull_requests?.length || candidate.pull_requests.some(pr => pr.number === Number(prNumber))) + ) + .sort((a, b) => b.id - a.id)[0]; + if (!run) { + throw new Error( + `No successful PR bundle build for PR #${prNumber} at ${sha}. Wait for or re-run Bug Server PR Bundle.` + ); + } + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, + run_id: run.id, + per_page: 100 + }); + const matches = artifacts.filter(artifact => artifact.name === `bug-server-pr-${prNumber}-${sha}`); + if (matches.length !== 1) { + throw new Error(`Expected exactly one PR bundle artifact in run ${run.id}. Re-run Bug Server PR Bundle.`); + } + const artifact = matches[0]; + if (artifact.expired) { + throw new Error('The PR bundle artifact has expired. Re-run Bug Server PR Bundle.'); + } + if ( + artifact.workflow_run?.id !== run.id || + artifact.workflow_run.head_sha !== sha || + artifact.workflow_run.repository_id !== pull.base.repo.id || + artifact.workflow_run.head_repository_id !== pull.head.repo.id + ) { + throw new Error('Artifact provenance does not match the reviewed PR build.'); + } + + return { + prNumber: Number(prNumber), + sha, + headRef: pull.head.ref, + prUrl: pull.html_url, + runId: run.id, + runUrl: run.html_url, + artifactId: artifact.id + }; +} + +module.exports = { resolveBugServerTarget }; diff --git a/.github/scripts/bug-server-dispatch.test.cjs b/.github/scripts/bug-server-dispatch.test.cjs new file mode 100644 index 000000000..4a3e6473a --- /dev/null +++ b/.github/scripts/bug-server-dispatch.test.cjs @@ -0,0 +1,285 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { resolveBugServerTarget } = require('./bug-server-dispatch.cjs'); + +const sha = '40be3619d1608aa1d5827f0a465704eeb036a7d3'; + +function fixture(overrides = {}) { + const calls = []; + const context = { + repo: { owner: 'VisActor', repo: 'VRender' }, + ref: 'refs/heads/develop', + payload: { repository: { default_branch: 'develop' } } + }; + const pull = { + base: { repo: { id: 1, full_name: 'VisActor/VRender' } }, + head: { sha, ref: 'feat/line-render-contribution', repo: { id: 2, full_name: 'g1f9/VRender' } }, + html_url: 'https://github.com/VisActor/VRender/pull/2128' + }; + const run = { + id: 100, + workflow_id: 50, + path: '.github/workflows/bug-server-pr-bundle.yml', + event: 'pull_request', + status: 'completed', + conclusion: 'success', + head_sha: sha, + head_branch: pull.head.ref, + repository: { id: 1 }, + head_repository: { id: 2 }, + pull_requests: [], + html_url: 'https://github.com/VisActor/VRender/actions/runs/100' + }; + const artifact = { + id: 200, + name: `bug-server-pr-2128-${sha}`, + expired: false, + workflow_run: { id: 100, repository_id: 1, head_repository_id: 2, head_sha: sha } + }; + const runs = [run]; + const artifacts = [artifact]; + const github = { + paginate: async (method, params) => { + const { data } = await method(params); + return data.workflow_runs ?? data.artifacts; + }, + rest: { + actions: { + getWorkflow: async params => { + assert.equal(params.workflow_id, 'bug-server-pr-bundle.yml'); + return { data: { id: 50, path: '.github/workflows/bug-server-pr-bundle.yml' } }; + }, + listWorkflowRuns: async params => { + assert.equal(params.workflow_id, 50); + assert.equal(params.head_sha, sha); + assert.equal(params.event, 'pull_request'); + assert.equal(params.status, 'success'); + return { data: { workflow_runs: runs } }; + }, + listWorkflowRunArtifacts: async params => { + assert.equal(params.run_id, 100); + return { data: { artifacts } }; + } + }, + pulls: { + get: async params => { + calls.push(params); + return { data: pull }; + } + } + } + }; + return { + args: { github, context, prNumber: '2128', headSha: sha, ...overrides }, + calls, + pull, + run, + artifact, + runs, + artifacts + }; +} + +test('resolves the reviewed fork head, including source metadata', async () => { + const { args, calls } = fixture({ headSha: sha.toUpperCase() }); + assert.deepEqual(await resolveBugServerTarget(args), { + prNumber: 2128, + sha, + headRef: 'feat/line-render-contribution', + prUrl: 'https://github.com/VisActor/VRender/pull/2128', + runId: 100, + runUrl: 'https://github.com/VisActor/VRender/actions/runs/100', + artifactId: 200 + }); + assert.deepEqual(calls, [{ owner: 'VisActor', repo: 'VRender', pull_number: 2128 }]); +}); + +for (const prNumber of ['', '0', '-1', '1.5', '2128;echo injected', '9007199254740992']) { + test(`rejects invalid PR number ${JSON.stringify(prNumber)} before API access`, async () => { + const { args, calls } = fixture({ prNumber }); + await assert.rejects(resolveBugServerTarget(args), /PR number/); + assert.equal(calls.length, 0); + }); +} + +for (const headSha of ['', '40be3619', 'g'.repeat(40), `${sha}\n`]) { + test(`rejects invalid SHA ${JSON.stringify(headSha)} before API access`, async () => { + const { args, calls } = fixture({ headSha }); + await assert.rejects(resolveBugServerTarget(args), /40-character/); + assert.equal(calls.length, 0); + }); +} + +test('rejects stale approval when the PR has a different head', async () => { + const { args, pull } = fixture(); + pull.head.sha = 'a'.repeat(40); + await assert.rejects(resolveBugServerTarget(args), /head changed/); +}); + +test('rejects a workflow launched from a non-default branch', async () => { + const { args, calls } = fixture(); + args.context.ref = 'refs/heads/feature'; + await assert.rejects(resolveBugServerTarget(args), /default branch/); + assert.equal(calls.length, 0); +}); + +test('rejects a PR belonging to another base repository', async () => { + const { args, pull } = fixture(); + pull.base.repo.full_name = 'someone/VRender'; + await assert.rejects(resolveBugServerTarget(args), /base repository/); +}); + +test('propagates API lookup failures without producing a build target', async () => { + const { args } = fixture(); + args.github.rest.pulls.get = async () => { + throw new Error('Not Found'); + }; + await assert.rejects(resolveBugServerTarget(args), /Not Found/); +}); + +for (const [name, change] of [ + [ + 'wrong workflow', + run => { + run.workflow_id = 51; + } + ], + [ + 'wrong workflow path', + run => { + run.path = '.github/workflows/other.yml'; + } + ], + [ + 'wrong event', + run => { + run.event = 'workflow_dispatch'; + } + ], + [ + 'wrong run SHA', + run => { + run.head_sha = 'a'.repeat(40); + } + ], + [ + 'wrong base repository', + run => { + run.repository.id = 3; + } + ], + [ + 'wrong head repository', + run => { + run.head_repository.id = 3; + } + ], + [ + 'wrong source branch', + run => { + run.head_branch = 'another-branch'; + } + ], + [ + 'wrong PR association', + run => { + run.pull_requests = [{ number: 2135 }]; + } + ], + [ + 'failed build', + run => { + run.conclusion = 'failure'; + } + ], + [ + 'unfinished build', + run => { + run.status = 'in_progress'; + } + ] +]) { + test(`rejects artifact source with ${name}`, async () => { + const { args, run } = fixture(); + change(run); + await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/); + }); +} + +test('accepts an explicit matching PR association', async () => { + const { args, run } = fixture(); + run.pull_requests = [{ number: 2128 }]; + assert.equal((await resolveBugServerTarget(args)).artifactId, 200); +}); + +test('rejects missing workflow runs', async () => { + const { args, runs } = fixture(); + runs.length = 0; + await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/); +}); + +test('does not select an older run instead of the latest matching run', async () => { + const { args, run, runs } = fixture(); + runs.unshift({ ...run, id: 99 }); + assert.equal((await resolveBugServerTarget(args)).runId, 100); +}); + +for (const [name, change] of [ + [ + 'expired', + artifact => { + artifact.expired = true; + } + ], + [ + 'wrong run', + artifact => { + artifact.workflow_run.id = 101; + } + ], + [ + 'wrong head SHA', + artifact => { + artifact.workflow_run.head_sha = 'a'.repeat(40); + } + ], + [ + 'wrong base repository', + artifact => { + artifact.workflow_run.repository_id = 3; + } + ], + [ + 'wrong head repository', + artifact => { + artifact.workflow_run.head_repository_id = 3; + } + ] +]) { + test(`rejects ${name} artifact`, async () => { + const { args, artifact } = fixture(); + change(artifact); + await assert.rejects(resolveBugServerTarget(args), /Artifact provenance|expired/); + }); +} + +test('rejects an artifact from a different PR or SHA', async () => { + const { args, artifact } = fixture(); + artifact.name = `bug-server-pr-2135-${sha}`; + await assert.rejects(resolveBugServerTarget(args), /exactly one/); +}); + +test('rejects missing or ambiguous artifacts', async () => { + for (const count of [0, 2]) { + const { args, artifacts, artifact } = fixture(); + artifacts.splice(0, 1, ...Array(count).fill(artifact)); + await assert.rejects(resolveBugServerTarget(args), /exactly one/); + } +}); + +test('does not silently fall back when the newest run artifact has expired', async () => { + const { args, run, runs, artifact } = fixture(); + runs.push({ ...run, id: 99 }); + artifact.expired = true; + await assert.rejects(resolveBugServerTarget(args), /expired/); +}); diff --git a/.github/scripts/extract_bug_server_bundle.py b/.github/scripts/extract_bug_server_bundle.py new file mode 100644 index 000000000..43b51a7de --- /dev/null +++ b/.github/scripts/extract_bug_server_bundle.py @@ -0,0 +1,33 @@ +"""Read a PR artifact as data without trusting archive paths or file attributes.""" + +import stat +import sys +import zipfile +from pathlib import Path + +MAX_BUNDLE_BYTES = 64 * 1024 * 1024 + + +def extract_bundle(archive_path, destination): + with zipfile.ZipFile(archive_path) as archive: + entries = archive.infolist() + if len(entries) != 1 or entries[0].filename != 'index.js': + raise ValueError('The PR artifact must contain exactly one file named index.js.') + entry = entries[0] + file_type = stat.S_IFMT(entry.external_attr >> 16) + if entry.is_dir() or file_type not in (0, stat.S_IFREG): + raise ValueError('The PR bundle must be a regular file, not a link or directory.') + if entry.file_size > MAX_BUNDLE_BYTES: + raise ValueError('The PR bundle exceeds the 64 MiB limit.') + with archive.open(entry) as source: + data = source.read(MAX_BUNDLE_BYTES + 1) + if len(data) > MAX_BUNDLE_BYTES: + raise ValueError('The PR bundle exceeds the 64 MiB limit.') + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open('xb') as output: + output.write(data) + + +if __name__ == '__main__': + extract_bundle(sys.argv[1], sys.argv[2]) diff --git a/.github/scripts/test_extract_bug_server_bundle.py b/.github/scripts/test_extract_bug_server_bundle.py new file mode 100644 index 000000000..893af7068 --- /dev/null +++ b/.github/scripts/test_extract_bug_server_bundle.py @@ -0,0 +1,78 @@ +import stat +import tempfile +import unittest +import warnings +import zipfile +from pathlib import Path +from unittest.mock import patch + +from extract_bug_server_bundle import extract_bundle + + +class ExtractBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / 'bundle.zip' + self.destination = self.root / 'dist' / 'index.js' + + def archive_entries(self, entries): + with warnings.catch_warnings(): + warnings.simplefilter('ignore', UserWarning) + with zipfile.ZipFile(self.archive, 'w', zipfile.ZIP_DEFLATED) as archive: + for name, data in entries: + archive.writestr(name, data) + + def test_preserves_binary_content(self): + data = b'\x00\xffbundle\n' + self.archive_entries([('index.js', data)]) + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), data) + + def test_executable_text_is_only_data(self): + data = b'throw new Error("BUNDLE_MUST_NOT_EXECUTE");' + self.archive_entries([('index.js', data)]) + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), data) + + def test_rejects_unexpected_names_and_extra_files(self): + for entries in [ + [], [('index.js', b'a'), ('scripts/trigger-test.ts', b'evil')], + [('../scripts/trigger-test.ts', b'evil')], [('/tmp/index.js', b'evil')], + [('index.js', b'a'), ('index.js', b'b')], [('folder/index.js', b'a')], + ]: + with self.subTest(entries=entries): + self.archive_entries(entries) + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for file_type in [stat.S_IFLNK, stat.S_IFDIR, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(file_type=file_type): + entry = zipfile.ZipInfo('index.js') + entry.create_system = 3 + entry.external_attr = (file_type | 0o777) << 16 + self.archive_entries([(entry, b'../scripts/trigger-test.ts')]) + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + + def test_rejects_oversized_bundle(self): + self.archive_entries([('index.js', b'x' * 1025)]) + with patch('extract_bug_server_bundle.MAX_BUNDLE_BYTES', 1024): + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + self.assertFalse(self.destination.exists()) + + def test_does_not_overwrite_existing_file(self): + self.archive_entries([('index.js', b'new')]) + self.destination.parent.mkdir() + self.destination.write_bytes(b'original') + with self.assertRaises(FileExistsError): + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), b'original') + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/bug-server-pr-bundle.yml b/.github/workflows/bug-server-pr-bundle.yml new file mode 100644 index 000000000..fa86e3e01 --- /dev/null +++ b/.github/workflows/bug-server-pr-bundle.yml @@ -0,0 +1,42 @@ +name: Bug Server PR Bundle + +on: + pull_request: + branches: ['main', 'develop', 'dev/**'] + +permissions: + contents: read + +jobs: + build-pr-bundle: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # PR code runs only in the pull_request context; cache writes stay scoped to the PR. + # This workflow never receives the Bug Server token. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Verify checkout + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Install native deps for node-canvas + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libcairo2-dev libpango1.0-dev libpng-dev libjpeg-dev libgif-dev librsvg2-dev + - name: Install and build PR bundle + run: | + node common/scripts/install-run-rush.js update --bypass-policy + node common/scripts/install-run-rush.js install --bypass-policy + node common/scripts/install-run-rush.js build -t @internal/bugserver-trigger + - uses: actions/upload-artifact@v4 + with: + name: bug-server-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: tools/bugserver-trigger/dist/index.js + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/bug-server.yml b/.github/workflows/bug-server.yml index 6f2002701..384fa2d81 100644 --- a/.github/workflows/bug-server.yml +++ b/.github/workflows/bug-server.yml @@ -2,17 +2,31 @@ name: Bug Server CI # 这里业务方根据需求设置 on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to test (including fork PRs)' + required: true + type: string + head_sha: + description: 'Reviewed PR head commit (full 40-character SHA)' + required: true + type: string push: branches: ['main'] pull_request: branches: ['main', 'develop', 'dev/**'] +permissions: + contents: read + jobs: build: + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js 24.x uses: actions/setup-node@v4 with: @@ -20,6 +34,11 @@ jobs: cache: 'npm' cache-dependency-path: './common/config/rush/pnpm-lock.yaml' + - name: Test manual dispatch validation + run: | + node --test .github/scripts/bug-server-dispatch.test.cjs + python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py' + - name: Print All Github Environment Variables run: env @@ -48,3 +67,110 @@ jobs: env: BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} run: node ../../common/scripts/install-run-rushx.js ci + + resolve-manual-target: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + actions: read + outputs: + sha: ${{ steps.target.outputs.sha }} + pr_number: ${{ steps.target.outputs.pr_number }} + head_ref: ${{ steps.target.outputs.head_ref }} + pr_url: ${{ steps.target.outputs.pr_url }} + artifact_id: ${{ steps.target.outputs.artifact_id }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + - name: Validate reviewed PR head + id: target + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ inputs.pr_number }} + HEAD_SHA: ${{ inputs.head_sha }} + with: + script: | + const { resolveBugServerTarget } = require('./.github/scripts/bug-server-dispatch.cjs'); + const target = await resolveBugServerTarget({ + github, context, + prNumber: process.env.PR_NUMBER, + headSha: process.env.HEAD_SHA + }); + core.setOutput('sha', target.sha); + core.setOutput('pr_number', target.prNumber); + core.setOutput('head_ref', target.headRef); + core.setOutput('pr_url', target.prUrl); + core.setOutput('artifact_id', target.artifactId); + await core.summary + .addHeading('Bug Server manual test') + .addLink(`PR #${target.prNumber}`, target.prUrl) + .addLink(`Source build ${target.runId}`, target.runUrl) + .addRaw(`\n\nTested head: \`${target.sha}\`\n`) + .write(); + + submit-manual-bundle: + needs: resolve-manual-target + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + actions: read + steps: + # Use the immutable default-branch workflow commit, never scripts from the PR. + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: | + .github/scripts + tools/bugserver-trigger/scripts + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Install isolated trigger client dependencies + run: | + mkdir -p "$RUNNER_TEMP/bug-server-client" + npm install --prefix "$RUNNER_TEMP/bug-server-client" --ignore-scripts --no-audit --no-fund --package-lock=false \ + node-fetch@2.6.6 form-data@4.0.6 ts-node@10.9.0 typescript@4.9.5 + - name: Download reviewed PR artifact + uses: actions/github-script@v8 + env: + ARTIFACT_ID: ${{ needs.resolve-manual-target.outputs.artifact_id }} + with: + script: | + const archive = await github.rest.actions.downloadArtifact({ + ...context.repo, + artifact_id: Number(process.env.ARTIFACT_ID), + archive_format: 'zip' + }); + const fs = require('node:fs'); + const path = require('node:path'); + fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'bug-server-bundle.zip'), Buffer.from(archive.data)); + - name: Read bundle as data + run: python3 .github/scripts/extract_bug_server_bundle.py "$RUNNER_TEMP/bug-server-bundle.zip" tools/bugserver-trigger/dist/index.js + - name: Trigger Bug Server for reviewed PR + working-directory: tools/bugserver-trigger + env: + BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} + NODE_PATH: ${{ runner.temp }}/bug-server-client/node_modules + TEST_SHA: ${{ needs.resolve-manual-target.outputs.sha }} + TEST_REF: refs/pull/${{ needs.resolve-manual-target.outputs.pr_number }}/head + TEST_BRANCH: ${{ needs.resolve-manual-target.outputs.head_ref }} + TEST_PR_URL: ${{ needs.resolve-manual-target.outputs.pr_url }} + run: | + if [ -z "$BUG_SERVER_TOKEN" ]; then + echo '::error::BUG_SERVER_TOKEN is not configured for this repository.' + exit 1 + fi + test -f dist/index.js + printf 'PR: %s\nTested head: `%s`\n' "$TEST_PR_URL" "$TEST_SHA" >> "$GITHUB_STEP_SUMMARY" + env GITHUB_SHA="$TEST_SHA" GITHUB_REF="$TEST_REF" GITHUB_HEAD_REF="$TEST_BRANCH" \ + node "$RUNNER_TEMP/bug-server-client/node_modules/ts-node/dist/bin.js" \ + --transpile-only --skip-project \ + --compiler-options '{"module":"CommonJS","moduleResolution":"node","esModuleInterop":true}' \ + scripts/trigger-test.ts diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 4cecc9e9c..7293ff485 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -41,7 +41,8 @@ jobs: run: node common/scripts/install-run-rush.js update --bypass-policy - name: Install rush run: node common/scripts/install-run-rush.js install --bypass-policy + - name: Source tests (without build artifacts) + run: node common/scripts/install-run-rush.js test --only tag:package - run: node common/scripts/install-run-rush.js build --only tag:package - - name: Pre unit test - run: cd packages/vrender && node ../../common/scripts/install-run-rushx.js test && cd ../.. - - run: node common/scripts/install-run-rush.js test --only tag:package + - name: Published artifact tests + run: node common/scripts/install-run-rush.js test:artifacts --only tag:package diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index a21e413e2..5b81c28fe 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -35,6 +35,15 @@ "ignoreDependencyOrder": true, "allowWarningsInSuccessfulBuild": true }, + { + "name": "test:artifacts", + "summary": "Validate published artifacts after building packages", + "enableParallelism": false, + "commandKind": "bulk", + "ignoreMissingScript": true, + "ignoreDependencyOrder": true, + "allowWarningsInSuccessfulBuild": true + }, { "name": "run", "summary": "", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 688894385..1044918ea 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: specifier: ~0.5.7 version: 0.5.7 '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../packages/vrender '@visactor/vutils': specifier: ~1.0.12 @@ -95,7 +95,7 @@ importers: ../../packages/react-vrender: dependencies: '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender '@visactor/vutils': specifier: ~1.0.12 @@ -168,10 +168,10 @@ importers: ../../packages/react-vrender-utils: dependencies: '@visactor/react-vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../react-vrender '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender '@visactor/vutils': specifier: ~1.0.12 @@ -241,16 +241,16 @@ importers: ../../packages/vrender: dependencies: '@visactor/vrender-animate': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-animate '@visactor/vrender-components': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-components '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-kits devDependencies: '@internal/bundler': @@ -320,7 +320,7 @@ importers: ../../packages/vrender-animate: dependencies: '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vutils': specifier: ~1.0.12 @@ -393,13 +393,13 @@ importers: ../../packages/vrender-components: dependencies: '@visactor/vrender-animate': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-animate '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-kits '@visactor/vscale': specifier: ~1.0.12 @@ -518,7 +518,7 @@ importers: specifier: 2.4.1 version: 2.4.1 '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../vrender-core '@visactor/vutils': specifier: ~1.0.12 @@ -649,19 +649,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vrender': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender '@visactor/vrender-animate': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-animate '@visactor/vrender-components': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-components '@visactor/vrender-core': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.10 + specifier: workspace:1.1.11 version: link:../../packages/vrender-kits devDependencies: '@internal/bundler': diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index de55d2119..20f8b2bf1 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vrenderMain","version":"1.1.10","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vrenderMain","version":"1.1.11","nextBump":"patch"}] diff --git a/docs/agent/VRENDER_TEST_AND_VERIFICATION.md b/docs/agent/VRENDER_TEST_AND_VERIFICATION.md index 3a420dcb0..0682ff115 100644 --- a/docs/agent/VRENDER_TEST_AND_VERIFICATION.md +++ b/docs/agent/VRENDER_TEST_AND_VERIFICATION.md @@ -12,6 +12,7 @@ - `rush compile -t @visactor/vrender-core` - `rush test` - `rush test -t @visactor/vrender` +- `rush test:artifacts --only tag:package`(先 build,再检查发布产物) - `rush eslint` - `rush lint-staged` - `rush start` @@ -41,6 +42,23 @@ ## 单包 Test +`rush test`、各包 `rushx test` 和 pre-push 只运行源码测试:仓库内包通过 +`share/jest-config/source-module-name-mapper.js` 解析到本地 `src`,不依赖 `es/cjs/dist`。 +第三方 npm 依赖仍使用安装版本。CI 在 build 之前运行源码测试,避免旧产物掩盖源码问题。 + +构建产物断言位于各包 `__tests__/artifacts/`,默认单测和 Electron 测试不会收集它们。 +`vrender-core`、`vrender-kits`、`vrender` 提供独立的 `rushx test:artifacts`;这些测试不启用源码映射, +并在缺少构建文件时失败。跨包产物检查需要先构建全部 package: + +```bash +rush build --only tag:package +rush test:artifacts --only tag:package +``` + +新增测试时,运行时行为及源码/配置契约归入默认单测;读取构建输出或验证发布包入口的断言归入 artifacts。 +同时包含两类断言的文件应拆分。验证源码测试独立性时,在没有各包 `es/cjs/dist` 的干净 worktree 中执行 +`rush test --only tag:package`,不通过跳过失败用例或自动 build 消除依赖。 + 在对应 package 下: - `cd packages/vrender-core && rushx test` @@ -135,7 +153,7 @@ root: - `packages/vrender/__tests__/unit/shared-browser-lite-entry.test.ts` - `packages/vrender/__tests__/unit/app-bootstrap-binding.test.ts` - `packages/vrender/__tests__/unit/node-app-runtime.test.ts` -- `packages/vrender/__tests__/unit/build-artifact-consistency.test.ts` +- 发布产物检查:`packages/vrender/__tests__/artifacts/*` ## Animate 测试 diff --git a/docs/package.json b/docs/package.json index 9133991fd..6fd1203a7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,7 +13,7 @@ "@visactor/vchart": "1.3.0", "@visactor/vutils": "~1.0.12", "@visactor/vgrammar": "~0.5.7", - "@visactor/vrender": "workspace:1.1.10", + "@visactor/vrender": "workspace:1.1.11", "markdown-it": "^13.0.0", "highlight.js": "^11.8.0", "axios": "^1.4.0", diff --git a/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md b/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md new file mode 100644 index 000000000..23f758b5f --- /dev/null +++ b/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md @@ -0,0 +1,25 @@ +# Glyph 状态与派生属性契约 + +Glyph 与普通 Graphic 共用 `baseAttributes + resolvedStatePatch -> attribute`、同状态刷新、状态动画及清空路径。 + +## 定义来源 + +- 配置 `glyphStateProxy` 时,由 proxy 决定完整状态贡献;返回空值不回退 `glyphStates` 或 Group。 +- 无 proxy、配置非空 `glyphStates` 时,读取其 `.attributes`;`subAttributes` 不自动传播。 +- 旧输入按目标状态列表顺序合并,配置 `stateSort` 时先排序,后面的状态覆盖前面的状态;不修改调用方数组。 +- 没有旧输入时,完全使用标准状态定义与 Group-first、priority/rank 规则。旧输入与 Group 不隐式逐状态混合。 +- 动态值变化但状态名不变时,用 `setStates(names, { animate: false })` 刷新;需要动画时同时设置 `animate` 和 `animateSameStatePatchChange`。 + +## 派生图形 + +子图形和编码上下文准备好后调用 `setSubGraphicEncoder(encoder)`。注册时立即同步一次,后续回调读取已提交的 `glyph.attribute`,包括基础更新、状态恢复及动画中间帧。编码器仅修改子图形,不修改宿主属性或宿主状态。 + +`commitSubGraphicAttributes(child, patch, removedKeys, context)` 在一次提交中更新值并删除已经撤销的 own keys,同时维护子图形基础属性、状态、更新标记及继承关系。上层负责输出键归属;删除后可重新读取当前宿主继承值。不要直接删除 `child.attribute` 的键,也不要用写入 `undefined` 代替属性删除。 + +更新顺序为:宿主提交、继承绑定、派生同步、外部通知。`skipUpdateCallback` 跳过观察回调和服务通知,但不跳过派生同步;编码器应将 context 传给子图形提交。`onUpdate` 属于观察回调。 + +clone 保留已编码外观,不复制宿主编码器;独立使用的 clone 应自行注册。release 解除编码器、子图形继承关系并释放子图形。 + +## 动画中断 + +内部切换/取消状态停止旧动画后,由状态系统恢复静态真值,不将旧动画终值提交为基础属性。公开 `animate.stop('start' | 'end' | attrs)` 仍是显式静态提交 API。 diff --git a/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md b/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md new file mode 100644 index 000000000..6348882f2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md @@ -0,0 +1,122 @@ +# Invalid Point Bounds 修复计划与收敛结果 + +基线:`fix/invalid-point-bounds-1.0`,HEAD `b568bb44ce006f821decf514d1187fd18bb21111`,PR #2117。 + +2026-09-16 按用户要求收敛。本记录替代此前范围较大的实施记录;当前修改只保留 area 缺失点所需的路径组织、缓存失效和增量入口修复。 + +## 目标与根因 + +保留 PR 排除 `defined: false` 点的 bounds 行为,让实际填充也不受这些坐标影响。原实现的两处问题已用真实 Canvas 复现: + +1. `basis + connectedType: none`:插值器仍读取无效点,导致有效邻段越界。 +2. 首个 styled segment 只有一个无效点:该点被当成后续 top 起点,bottom 又独立使用原始点,产生错误填充和上下边界错配。 + +两例的收紧 bounds 均不包含 `(95,20)`,旧实际填充却覆盖该位置,造成漏拾取和脏区风险。修复落在插值前的输入组织;不恢复无效点 bounds、不增加 padding、不关闭剔除。 + +复现数据(有效点下边界均为 `y1: 0`): + +- basis:`(0,0), (10,10), undefined(500,500,y1=-500), (20,10), (30,0)`。 +- connect 分段:第一段为 `undefined(500,500,y1=-500)`;第二段为 `(0,0), (10,10)`。 + +验收同时确认有效位置仍被填充,不能靠整图不绘制消除越界。 + +## 收敛范围 + +| 保留 | 必要性 | +| ------------------------------- | ----------------------------------------------------------------- | +| area 专用有效区间编译 | none 在缺失点处分段;connect 跳过缺失点;上下边界选取同一组有效点 | +| 原始 segment 索引和有效首尾方向 | 过滤后保持样式归属,避免无效坐标影响裁剪方向 | +| `connectedType` 的 shape 失效 | 同一图形切换 none/connect 时重建对应路径 | +| 增量 area 同类修复 | 上下边界均忽略无效坐标,跨批次只承接有效点 | + +| 从本次移出 | 当前处理 | +| ------------------------------------ | ---------------------------------------------------- | +| 共享曲线/area 裁剪的零投影、NaN 修正 | `drawSegments` 和 `render-area.ts` 恢复到 HEAD | +| 全有效 closed/Catmull–Rom 的行为修正 | 保留既有 `startPoint` 和闭合承接语义,以基线对照验收 | +| `closePath` 缓存失效补充 | 留待独立问题处理 | +| incremental WeakMap 连续性状态 | 删除;有有效数据要绘制时才向前查找最近有效点 | +| 增量下边界 offset 修正 | 保持既有行为,留待独立修复 | + +最终涉及 5 个产品源码文件(含 1 个新增内部 helper)。收敛针对行为和状态管理范围,代码行数没有大幅减少;未同时保留新旧两套 area 编译器。 + +## 实现边界 + +- `common/area-cache.ts` 在缓存重建时选择有效区间,再调用现有曲线生成器。全有效区间直接复用原始 points 数组。 +- 一个样式段保持一个缓存项和一次绘制流程。多个区间以 `defined: false` 曲线分隔;分隔只使用相邻有效区间端点,不产生可见连接面。 +- 缓存仍兼容 `{top, bottom}` 及其数组形式,内部增加原始段索引与方向,不新增 package export。 +- top 沿用曲线生成器的 `startPoint` 参数,bottom 沿用逆序及 stepBefore/stepAfter 互换;不借机修正既有全有效曲线语义。 +- `area-render.ts` 消费成对缓存,保留全有效 linear 快速绘制入口。缓存后重绘不新增有效点分段遍历。 +- 增量入口继续只处理既有基础能力,不扩展曲线、clipRange 或拾取。跨空段/缺失段需要连接时向前定位有效点;纯缺失批次跳过历史查询,避免连续追加缺失批次反复扫描前缀。 +- 不修改调用方 points/segments,不增加持久连续性状态,不引入其他分支架构。 + +## 实施与验收 + +- [x] 两处真实 Canvas 回归:异常远点不填充、有效区间仍填充、none 缺口为空。 +- [x] none/connect、有效/无效单点、空段、连续缺失、样式映射、上下边界一致性。 +- [x] 11 种曲线的缺失点等价性,clipRange、横纵方向、上下边单独描边。 +- [x] `connectedType` 更新刷新缓存;纯重绘和 clipRange 更新复用缓存。 +- [x] 增量跨批次与普通 linear 像素对照、多图形交错、替换数据、纯缺失批次扫描计数。 +- [x] Stage 拾取、平移、局部重绘与全量重绘像素对照、rough 缓存输入兼容。 +- [x] 全有效路径与原始 HEAD 隔离工作区比较:**704/704 组绘制命令一致**。覆盖 11 种曲线 × 4 个 clipRange × 2 种连接模式 × 2 个方向 × 4 种布局;布局包括非分段、普通分段、首段单点及三段承接。 +- [x] core 全量:**7 suites / 78 tests 通过**。 +- [x] vrender 定向回归:**4 suites / 10 tests 通过**。 +- [x] core 无增量类型检查、跨包 compile 通过;ESLint 0 errors,保留 12 条既有 warning;Prettier 检查通过。 +- [ ] 远端 Bugserver 用例登记及本次修复的视觉 CI 验证。 + +全有效对照与定向性能脚本保存在本机 `/tmp/vrender-2117-narrow-verification-benchmark.test.ts`,未把依赖绝对工作区路径的临时测试留在仓库。对照结果为 `/tmp/vrender-2117-narrow-path-comparison.json`。 + +## 定向性能证据 + +原生 Canvas、1000×120 画布,1k/10k 点;cold 包含新图形及缓存生成,cached 复用缓存。预热 10 次,交替顺序运行 7 轮,记录每次 draw 的中位数和 min/max。basis 每 17 点有一个缺失点,分段 linear 每段 100 点。 + +首次测量波动较大,完成其他验证后单独复测一次。下面同时保留两次结果,避免只挑较快数据;数值为修复版相对基线的中位数时间变化。 + +| 场景 | 首次 cold / cached | 复测 cold / cached | +| --------------- | ------------------ | ------------------ | +| 1k linear | +1.2% / -4.0% | -4.3% / +10.0% | +| 1k basis 缺失 | +9.6% / -8.0% | +1.6% / +4.1% | +| 1k 分段 linear | -11.5% / +3.0% | +23.5% / -6.9% | +| 10k linear | -2.7% / +3.7% | +3.2% / -8.4% | +| 10k basis 缺失 | -8.1% / +6.9% | -4.0% / -1.9% | +| 10k 分段 linear | +5.4% / +9.1% | -1.7% / +1.4% | + +复测 10k 分段 linear 的 cold 为 2.156 → 2.119 ms,cached 为 1.538 → 1.560 ms。两次各场景的 min/max 均与基线重叠,部分变化方向反转,未确认稳定退化;这不是性能无回归证明,也不能代替浏览器整页测量。原始数据为 `/tmp/vrender-2117-narrow-performance-first.json` 和 `/tmp/vrender-2117-narrow-performance.json`。 + +## 可重复验证命令 + +在 `packages/vrender-core`: + +```sh +./node_modules/.bin/jest -c jest.config.js --runInBand +./node_modules/.bin/tsc --noEmit --incremental false --composite false --pretty false +./node_modules/.bin/eslint src/common/area-cache.ts src/common/render-curve.ts src/graphic/area.ts src/render/contributions/render/area-render.ts src/render/contributions/render/incremental-area-render.ts +``` + +在仓库根目录: + +```sh +node common/scripts/install-run-rush.js compile -t @visactor/vrender +``` + +在 `packages/vrender`: + +```sh +./node_modules/.bin/jest -c jest.config.js --runInBand __tests__/graphic/area-invalid-point.test.ts __tests__/graphic/graphic-bounds.test.ts __tests__/core/graphic-bounds.test.ts __tests__/core/stage.test.ts +``` + +## 剩余边界 + +本次修复保证无效坐标不参与实际 area 几何;不解决全有效曲线自身的过冲、既有 closed/Catmull–Rom 分段问题、零投影裁剪 NaN 或增量 offset 问题。 + +本地没有 `BUG_SERVER_TOKEN`,未登记远端 case。此前查询到的 #2117 历史 CI 结果不包含本次工作区修改,不能用作本次通过的证据。合并前仍需完成远端视觉检查。 + +本记录描述本地验证完成时的结果,后续提交与推送以 Git 历史为准。未修改其他任务的 `2026-09-16-brush-initial-mask.md`。 + +## Develop 移植(2026-09-16) + +基于远端 develop `3c80bbdf1c10b9b4c32abb4c152f9d8e676d72f5`,依次 cherry-pick `b568bb44` 和 `45fd2eb01`。上文的 1.0.x 验证记录保留为来源证据,不代表 develop 的全量测试结果。 + +- 解决两处导入冲突,保留 develop 已移除 DI 装饰器的 renderer 实现。 +- 新增像素回归测试显式加载现有真实 Canvas 测试适配,避免 develop 的默认 mock 令像素/命中断言失去意义。 +- Stage 集成测试复用 develop 的 `createBrowserStage` 工具,遵循当前 App 初始化与释放方式。 +- 移植后定向验证:core 3 suites / 50 tests、vrender 1 suite / 4 tests 及跨包 compile 均通过。全包测试由推送钩子运行,最终结果记录在 PR。 diff --git a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md new file mode 100644 index 000000000..ced96a8ce --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md @@ -0,0 +1,56 @@ +# Bug Server Manual Dispatch Implementation Plan + +> Historical implementation record. The default-branch build described below has been replaced by the [PR artifact flow](2026-09-17-bug-server-pr-artifact.md); use the current README and design for maintenance. + +> Execute inline in this task; the workflow design was approved in the conversation. The referenced superpowers execution skills are not installed, so implementation uses the available repository tools. + +**Goal:** Allow maintainers to test an external PR at a reviewed SHA without creating a temporary PR. + +**Architecture:** Add manual-only validation, build, and submission jobs to the existing workflow. Keep the credential-bearing runner separate from PR code and use the workflow commit for trusted scripts. + +**Tech Stack:** GitHub Actions, actions/github-script, Node.js 24, existing TypeScript Bug Server client. + +## Global Constraints + +- Base branch: `develop`; implementation branch: `codex/bugserver-workflow-dispatch`. +- Inputs: `pr_number` and full `head_sha`. +- PR artifacts are data only in the submission job. +- Existing automatic workflows and Bug Server API protocol retain their behavior. + +## Task 1: Validate and resolve the manual target + +Files: `.github/scripts/bug-server-dispatch.cjs`, `.github/scripts/bug-server-dispatch.test.cjs`. + +- [x] Write Node tests for valid fork PRs, invalid PR numbers, malformed SHAs, stale SHAs, wrong base repository and non-default workflow branches. +- [x] Run `node --test .github/scripts/bug-server-dispatch.test.cjs` and confirm the missing module fails. +- [x] Implement `resolveBugServerTarget({ github, context, prNumber, headSha })`, returning `{ prNumber, sha, headRef, prUrl }`. Validate locally before calling `github.rest.pulls.get`; compare the returned PR's repository and current head with the requested target. +- [x] Rerun the tests. + +## Task 2: Isolate build and submission + +Files: `.github/workflows/bug-server.yml`. + +- [x] Add the two string inputs, retain existing automatic build behind a non-dispatch condition, and add manual validation/build/submission jobs with read-only repository permissions. +- [x] Checkout PR code by the validated SHA, verify `git rev-parse HEAD`, build with the existing Rush commands, and upload only the generated bundle. +- [x] Checkout the trusted client by `github.workflow_sha` in the submission job. Install `node-fetch@2.6.6`, `form-data@4.0.6`, `ts-node@10.9.0`, and `typescript@4.9.5` outside the repository with lifecycle scripts disabled. +- [x] Run the client through the isolated ts-node executable with explicit CommonJS/esModuleInterop compiler options and reviewed PR metadata; expose the secret only for this command. +- [x] Run actionlint and simulate the trusted client against mocked API responses, verifying that the bundle is uploaded without execution. + +## Task 3: Document and verify + +Files: `tools/bugserver-trigger/README.md`. + +- [x] Document UI and CLI invocation, default-branch availability, tested head versus merge semantics, metadata, and result logs. +- [x] Review the final diff for secret exposure, event regressions and shell interpolation; run `git diff --check`. +- [x] Record completed checks and deliver the local branch. Do not claim a live Bug Server run before the default-branch workflow exists. + +## Verification results + +- Node validation tests: 15 passed; also wired into the automatic Bug Server CI job. +- actionlint 1.7.12: passed. Updated the existing checkout v3 to v4 because actionlint rejects its retired runtime. +- Executed the workflow submission shell block in an isolated directory using the exact dependency versions: mock success, photo-test failure and missing-token cases all passed. The mock verified PR metadata and received a bundle that throws if executed; it was only uploaded. +- `git diff --check`: passed. No live Bug Server call was made. + +## Security verification correction (2026-09-17) + +The original tests, mock integration and actionlint run verified functional behavior and workflow syntax, but did not verify cache permissions. Omitting a cache action does not remove the default-branch cache-write capability of a `workflow_dispatch` run. PR #2134 therefore adds explicit workflow-level read-only API permissions and job-level `cache-mode: none`, verified before PR checkout. See the [security fix plan](2026-09-17-bug-server-security-fix.md) for runtime evidence and scanner compatibility limitations. diff --git a/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md new file mode 100644 index 000000000..5cb1f2821 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md @@ -0,0 +1,61 @@ +# Bug Server PR Artifact Implementation Plan + +> Execute inline as the fallback in the approved security fix plan. The user reported that alert #45 still blocks the latest revision; this plan completes that repair without dismissing the alert. + +**Goal:** Remove PR code execution from the default-branch manual workflow and make the CodeQL security check pass. + +**Architecture:** A separate `pull_request` workflow builds the exact head with read-only repository permissions. The manual workflow retains PR number and reviewed SHA inputs, locates an immutable artifact using GitHub API provenance, and submits only its `index.js` bytes with the trusted client. No PR build scripts or downloaded code execute in the manual workflow. + +**Tech Stack:** GitHub Actions, Node.js tests, Python standard-library ZIP handling. + +## Constraints + +- Keep existing push / PR Bug Server behavior and the trusted client API protocol. +- Keep workflow-wide `contents: read`; grant `actions: read` only to manual artifact lookup/download jobs and `pull-requests: read` only to target validation. +- Remove the superseded `build-manual-bundle`, `cache-mode` and runtime-mode guard; isolation comes from the PR event's cache scope. +- Treat artifacts as untrusted bytes. Never extract archive paths or execute their content. +- Fork run API responses can have an empty `pull_requests` array: verified using PR #2128 run 35075478495. Bind provenance to workflow ID/path, event, repository IDs, source branch and exact run head SHA. If PR associations are present, they must include the requested PR. + +## Task 1: Resolve a PR artifact + +Files: `.github/scripts/bug-server-dispatch.cjs`, `.github/scripts/bug-server-dispatch.test.cjs`. + +- [x] Extend fixture tests to cover successful fork provenance, incorrect workflow/event/SHA/repository/branch/PR, unsuccessful builds, and missing/expired/ambiguous artifacts. Preserve all input validation tests. +- [x] Run `node --test .github/scripts/bug-server-dispatch.test.cjs`; confirm new tests fail before implementation. +- [x] Extend `resolveBugServerTarget({github, context, prNumber, headSha})` to return the existing target fields plus `{runId, runUrl, artifactId}`. Resolve `bug-server-pr-bundle.yml`, list successful PR runs for the reviewed SHA, select the latest matching run, and select exactly one non-expired artifact named `bug-server-pr-${prNumber}-${sha}` whose API provenance matches the run. +- [x] Run the tests again; all provenance rejection cases must pass. + +## Task 2: Move the build and safely consume the artifact + +Files: `.github/workflows/bug-server-pr-bundle.yml`, `.github/workflows/bug-server.yml`, `.github/scripts/extract_bug_server_bundle.py`, `.github/scripts/test_extract_bug_server_bundle.py`. + +- [x] Add a PR-only workflow for `main`, `develop`, `dev/**`, using checkout at `github.event.pull_request.head.sha`, disabled persisted credentials, Node 24, the existing native dependencies/Rush build and upload-artifact v4. Artifact retention: 7 days. No repository secrets or cache action. +- [x] Add ZIP tests for a valid binary bundle, executable text treated as bytes, path traversal, extra files, duplicate names, symlink entries, oversized payloads and existing output files. +- [x] Implement `extract_bundle(archive_path, destination)` with Python `zipfile`: require exactly one regular entry named `index.js`, limit the uncompressed bundle to 64 MiB, and write bytes to the explicit destination with exclusive creation. Do not call `extract` or `extractall`. +- [x] Remove the manual build job. Add trusted API artifact lookup outputs and download the selected artifact ID into a fixed temporary ZIP file. Run the trusted extraction script before the token-bearing submission step; keep the existing client command unchanged. +- [x] Run `python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py'`, the Node tests, actionlint on both workflows and `git diff --check`. + +## Task 3: Verify and document + +- [x] Push the update to PR #2134, check CodeQL alert #45 and #46 on the new commit, and require the CodeQL check to pass without dismissals. +- [x] Wait for the new PR-only bundle workflow to succeed. Invoke the trusted resolver against that real run, download its immutable artifact, verify single-file extraction, and verify the existing upload client with the local mock API. Do not execute the bundle. +- [x] Update README, design, the previous security plan, PR description and the existing Lark maintenance section. Document that maintainers wait for `Bug Server PR Bundle` before dispatch; missing/expired artifacts require a fresh successful PR bundle run. Existing PRs may need a new PR event after the workflow is merged. +- [x] Record separate results for security checks, artifact pipeline and the existing photo CI. End-to-end manual dispatch from the default branch remains a post-merge check. + +## Validation before push + +- Node resolver tests: 36 passed. +- Python archive tests: 6 tests passed, including multiple malicious-entry subcases. +- actionlint 1.7.12: both final workflows pass without ignored diagnostics. +- `git diff --check`: passed. + +## GitHub and integration validation + +Implementation commit: `ae7fc0926581218905a90a3838bfb5d65741f128`. + +- [CodeQL check](https://github.com/VisActor/VRender/runs/105095209881): `success`, no new alerts. Both Actions and JavaScript/TypeScript analyses passed. Alerts #45 and #46 have PR instance state `fixed`; neither was dismissed. +- [PR bundle run 35188318138](https://github.com/VisActor/VRender/actions/runs/35188318138): `success`. Runner initialization confirms `Contents: read` and `Metadata: read`. Its cache mode is `write` in the PR event's cache scope, not the default-branch scope. +- The production resolver and workflow download script were executed locally against the real GitHub API. They selected artifact `10483685493` from that run, bound to PR #2134 and the exact implementation SHA. +- The trusted ZIP reader produced a single 3,210,456-byte bundle, SHA-256 `43e2b49b5edbf3fc1bbc52759b6844ab6608848ec97da666322a510e73f2b79e`. The trusted upload client passed success, photo-failure and missing-token scenarios with a local mock API, which checked that the uploaded bundle bytes were preserved. A separate throwing-JavaScript fixture was also uploaded as data without execution. +- Required pre-push package tests passed. Existing automatic unit/photo CI runs were still running when this record was written; their results are separate from the verified artifact pipeline. +- README, design notes, superseded-plan notices, PR description and Lark maintenance document were updated to the artifact workflow. No merge or default-branch dispatch was performed. The first live manual Bug Server run remains a post-merge check. diff --git a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md new file mode 100644 index 000000000..a9aa46155 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md @@ -0,0 +1,209 @@ +# Bug Server 手动入口安全修复 Implementation Plan + +> **已被替代:** 本文保留第一轮 `cache-mode` 修复及验证记录。该方案未消除最新 CodeQL 告警,不再作为最终实现;当前方案见 [PR artifact 修复计划](2026-09-17-bug-server-pr-artifact.md)。默认分支手动流程现改为只消费 PR 工作流的产物,不执行 PR 构建代码。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 当前环境未安装上述执行技能。用户已授权执行本计划,使用当前任务和仓库工具顺序完成;实际进度与证据记录在文末。 + +**Goal:** 修复 PR #2134 的默认分支缓存污染风险和自动构建 token 权限过大问题。 + +**Architecture:** 保留现有校验、构建、提交三个手动 jobs。通过 workflow 顶层 `permissions` 限制 GitHub API 权限,通过构建 job 的 `cache-mode` 独立限制缓存权限;这两类权限需要分别控制。 + +**Tech Stack:** GitHub Actions、Node.js 24、GitHub CodeQL、actionlint、GitHub CLI。 + +## Global Constraints + +- 审查基线:PR #2134,head `95514c67ed0c2bee3b1a45641596c168b48a05b2`。 +- 在现有 `codex/bugserver-workflow-dispatch` 分支追加修复,不重建功能或重写已有提交。 +- 保留输入 `pr_number`、`head_sha`、默认分支限制和固定 SHA 构建。 +- 保留独立 runner 和可信提交脚本;`BUG_SERVER_TOKEN` 仍只注入提交 step。 +- 只收紧本 workflow 权限;不修改仓库全局权限设置、发布流程和 Bug Server API。 +- 不用关闭扫描规则、隐藏告警或移除权限限制来让检查变绿。 +- 这是权限配置修复,不添加仅断言 YAML 文本的单测,也不重跑渲染库全量测试。 + +## 已核实的事实与方案选择 + +1. 仓库当前 `default_workflow_permissions` 为 `write`。旧 `build` job 没有显式权限,三个手动 jobs 已有只读权限。 +2. `workflow_dispatch` 在默认分支运行时默认拥有该分支的缓存写权限;不配置 cache action 不会撤销该权限。 +3. GitHub 官方文档支持 job 级别 `cache-mode: none`,由缓存 token 的作用域实施限制;只设置同名环境变量不等价。 +4. 当前最新版 actionlint 1.7.12 对该字段报 `unexpected key "cache-mode"`。已检查 CodeQL 主线的 `CachePoisoningQuery.qll`:其缓存写权限判断仍只看触发事件,没有考虑 `cache-mode`。 + +首选原生权限配置:改动集中,维护者使用方式不变。把 PR 构建搬到 `pull_request` 工作流、手动入口只消费其 artifact 也能隔离缓存,但需要新增运行记录和产物身份校验,不作为本次首选。仅增加 `permissions: contents: read` 无法修复缓存问题。 + +**兼容性处理原则:** GitHub 服务端与 runner 的实际支持需要先验证;不能承诺添加字段后现有 CodeQL 告警必然自动消失。扫描工具的兼容性问题与安全机制是否生效分别记录。 + +## Task 1:收紧两类权限 + +**Files:** +- Modify: `.github/workflows/bug-server.yml` + +**Interfaces:** +- Consumes: 原有事件、输入、job outputs、artifact 名称和提交脚本。 +- Produces: 所有 job 默认只有 `contents: read`;外部 PR 构建 job 没有缓存读写权限。 + +- [x] **1. 复核执行时的 PR head 和工作区,防止覆盖后续改动。** + +```sh +git status --short +gh pr view 2134 --repo VisActor/VRender --json headRefOid,headRefName,baseRefName +``` + +- [x] **2. 在 `on` 与 `jobs` 之间增加顶层权限。** + +```yaml +permissions: + contents: read +``` + +保留 `resolve-manual-target` 的 `contents: read`、`pull-requests: read`,以及另外两个手动 jobs 现有的 `contents: read`。旧 `build` 自动继承顶层只读权限;未声明的其他 API 权限不授予。 + +- [x] **3. 在 `build-manual-bundle` 中增加 job 级缓存限制。** + +```yaml + cache-mode: none +``` + +将原来的缓存注释替换为: + +```yaml + # PR code runs without Bug Server secrets or cache access. + # cache-mode controls cache tokens independently of GITHUB_TOKEN permissions. +``` + +- [x] **4. 在该 job 的 checkout 之前增加运行时检查。** + +```yaml + - name: Verify cache isolation + uses: actions/github-script@v8 + with: + script: | + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('PR builds require cache-mode: none.'); + } else { + core.info('Cache access is disabled for this job.'); + } +``` + +如果 runner 没有报告 `none`,立即停止,不能继续执行 PR 代码。该检查用于确认平台应用配置,实际权限边界仍是 job 级 `cache-mode`,不是环境变量本身。 + +执行中修正了检查载体:runner 的 `NodeScriptActionHandler` 会注入 `ACTIONS_CACHE_MODE`,普通 shell step 不会。不能用原计划的 shell 检查把变量未注入误判为平台不支持。 + +## Task 2:验证平台支持、扫描结果和功能 + +**Files:** +- Test: `.github/scripts/bug-server-dispatch.test.cjs`(已有测试,不修改) +- Temporary: `.github/workflows/bug-server-cache-policy-check.yml`(验收后删除) +- Modify: 本计划的验收记录 + +**Interfaces:** +- Consumes: Task 1 的 workflow 配置。 +- Produces: GitHub 原生解析与运行证据、权限日志、两条扫描告警的处理结果。 + +- [x] **1. 运行现有校验,记录 actionlint 版本及完整诊断。** + +```sh +node --test .github/scripts/bug-server-dispatch.test.cjs +actionlint -version +actionlint .github/workflows/bug-server.yml +git diff --check +``` + +预期已有 15 项输入校验测试通过。若 actionlint 仍是 1.7.12,明确记录其对新字段的语法误报;不能把这次检查写成通过,也不能泛化忽略所有语法错误。其余诊断均需解决。 + +- [x] **2. 在 PR 分支运行不包含 PR 代码和 secret 的平台探针。** + +临时文件的完整内容: + +```yaml +name: Bug Server cache policy check +on: + push: + branches: [codex/bugserver-workflow-dispatch] +permissions: {} +jobs: + verify: + runs-on: ubuntu-latest + cache-mode: none + steps: + - name: Verify effective cache mode + uses: actions/github-script@v8 + with: + script: | + core.info(`Cache mode: ${process.env.ACTIONS_CACHE_MODE ?? 'unset'}`); + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('Expected cache-mode: none.'); + } +``` + +在修复实现进入正常提交、推送阶段时运行该探针。它不 checkout、不安装依赖、不调用 Bug Server、不读写缓存。验收要求 GitHub 接受 YAML,且日志输出 `cache mode: none`。保留 run URL,再删除临时 workflow。 + +如果 GitHub 拒绝字段或 runner 不报告 `none`,该方案不具备落地条件,应停止合并;不要删除隔离配置继续执行外部 PR。后续改用 `pull_request` 构建 artifact、手动入口校验其来源后上传的方案,并单独完成该架构的实现计划。 + +- [x] **3. 复查真实自动构建的权限和两条 CodeQL 告警。** + +```sh +gh pr checks 2134 --repo VisActor/VRender +gh api repos/VisActor/VRender/code-scanning/alerts/45 +gh api repos/VisActor/VRender/code-scanning/alerts/46 +``` + +在最新提交的 `build` job 的 Set up job 日志中,确认 `GITHUB_TOKEN Permissions` 没有写权限。确认缺失权限告警已修复;缓存告警若仍存在,核对该次分析使用的规则及提交 SHA。 + +对于尚未识别 `cache-mode` 的 CodeQL,记录官方权限语义、探针 run URL、实际 workflow 配置和规则源码证据。扫描仍失败时,不将 PR 描述成“全部检查通过”,不自动关闭告警;将残留扫描问题明确交付给维护者评审。若仓库合并规则要求该检查通过,解决工具识别问题或改用上述 PR artifact 方案后再合并,不绕过合并规则。 + +- [ ] **4. 合入默认分支后,用已完整审查的可信 PR head 做首次手动验收。** + +通过原有 Run workflow 表单输入该 PR 编号与完整 SHA。检查:校验通过、缓存隔离检查通过、固定 SHA 构建成功、artifact 上传/下载成功、可信脚本成功触发 Bug Server,summary 中 PR/SHA 正确。 + +这一步验证此前自动 PR CI 不会执行的三个手动 jobs。图片差异按 Bug Server 业务结果记录,与权限配置是否生效分别判断。验收失败时暂停手动入口的使用,修复后再为外部 PR 运行;不放宽权限作为回退。 + +## Task 3:同步维护说明并交付 + +**Files:** +- Modify: `tools/bugserver-trigger/README.md` +- Modify: `docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md` +- Append correction: `docs/superpowers/plans/2026-09-17-bug-server-dispatch.md` +- Update after verification: [VRender 日常维护文档](https://bytedance.larkoffice.com/wiki/RNbpwz9HZizi1WkQYcZcqnj6n92) + +**Interfaces:** +- Consumes: Task 2 的真实验证结果。 +- Produces: 与实现一致的权限说明和未完成项记录。 + +- [x] **1. 用具体权限说明替换含糊的“无共享缓存”。** + +README 的构建边界使用以下说明: + +> Build the reviewed PR with read-only repository access and `cache-mode: none`, which denies cache reads and writes independently of `GITHUB_TOKEN`. Verify the runner reports this mode before checking out PR code. Keep checkout credentials disabled and the Bug Server token on the separate submission runner. + +设计文档和飞书文档说明: + +> 手动构建 job 显式禁止缓存读写,执行 PR 代码前确认该设置生效。GitHub API 权限和缓存权限分别控制;不配置缓存步骤并不等于没有缓存权限。 + +在原实现计划的验证结果后补记:此前单测、mock 和 actionlint 通过仅覆盖功能及旧语法检查,未验证缓存权限隔离;此次修复补齐这项边界。 + +- [x] **2. 交付时列出改动和真实状态。** + +至少记录最新 commit、两条告警结果、自动构建权限日志、缓存模式探针 URL、首次真实手动运行结果。区分“合并前已验证”和“合并后待验证”,不把计划写成已完成结果。 + +## 参考依据 + +- [GitHub job 级 cache-mode](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idcache-mode) +- [GitHub 缓存权限与事件默认值](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#controlling-cache-access-with-cache-mode) +- [CodeQL 缓存写权限判断源码](https://github.com/github/codeql/blob/main/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll) +- [缓存污染告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878435) +- [缺失权限告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878451) + +## 执行记录(2026-09-17) + +- 权限修复提交:`a1d30899c`;JavaScript action 检查修正:`f8fe1a07a`。 +- [首轮探针](https://github.com/VisActor/VRender/actions/runs/35179960262):GitHub 接受配置,初始化日志为 `Cache mode: none`,但 shell 没有该变量。通过官方 [NodeScriptActionHandler 源码](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs) 确认注入边界,改用 JavaScript action 检查。 +- [修正后的平台探针](https://github.com/VisActor/VRender/actions/runs/35180071512):**通过**。runner `2.337.0`;初始化日志及 JavaScript action 均报告 `Cache mode: none`。探针没有执行 PR 代码、接触 Bug Server secret 或读写缓存;验证后删除临时 workflow。 +- Node 输入校验:15/15 通过。直接运行 workflow 中的隔离检查脚本,确认 `none` 放行,`read`、`write`、未注入变量均拒绝,共 4 个场景通过。 +- 推送钩子要求的 `rush test --only tag:package` 已通过;没有以跳过钩子的方式推送。 +- actionlint 1.7.12:仅有 `cache-mode` 未识别诊断,**不记为通过**。GitHub 原生解析和 runner 验证通过。 +- CodeQL 权限告警 #46:实例状态为 **fixed**。缓存告警 #45 在 `a3d5f2e6e` 上仍为 **open**;其规则未考虑 `cache-mode`。没有忽略规则或关闭告警。 +- [自动构建启动日志](https://github.com/VisActor/VRender/actions/runs/35179962932/job/105069769361):`GITHUB_TOKEN Permissions` 仅有 `Contents: read`、`Metadata: read`,没有写权限。该 run 的构建步骤通过;后因新提交替代而取消,与另一旧提交的重复 CI 一同清理,最新提交的 CI 继续运行。 +- 已查询 develop 的传统 required status checks 和适用 rulesets:前者未启用,后者为空。未修改合并规则,也未合并 PR。 +- 首次真实手动链路仍需在修复合入默认分支后执行;平台探针不等于端到端 Bug Server 验收。 +- README、设计文档、原实现验证记录和飞书维护文档均已同步;飞书文档 revision 17 已回读确认。 diff --git a/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md new file mode 100644 index 000000000..603c95698 --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md @@ -0,0 +1,25 @@ +# Bug Server 手动触发设计 + +## 目标 + +维护者输入 PR 编号和已 review 的完整 head SHA,即可测试外部 PR。PR 构建在 `pull_request` 上下文中完成,默认分支的手动入口只校验和上传产物。 + +## 数据流与权限边界 + +1. `bug-server-pr-bundle.yml` 仅由 `pull_request` 触发。在只读仓库权限、无持久化 checkout 凭据、无 Bug Server token 的 runner 上检出准确 head SHA,执行 Rush 构建。缓存写入作用域属于该 PR,不属于默认分支。产物名为 `bug-server-pr--`,保留 7 天。 +2. `bug-server.yml` 的手动入口只允许默认分支,保留 `pr_number`、`head_sha`。可信脚本校验输入、base 仓库及 PR 当前 head,再从指定 PR bundle workflow 查找成功运行。 +3. 来源校验绑定 workflow ID/路径、事件、运行状态、base/head 仓库 ID、源分支及 run head SHA。fork 的运行记录可能没有 PR 列表,不能因此拒绝所有外部 PR;如列表存在则还需匹配 PR 编号。 +4. 选择最新匹配运行中唯一且未过期的命名产物,复核 artifact API 的 run ID、仓库 ID 与 SHA。失败时要求先成功运行 PR bundle 工作流,不回退到其他提交或较旧运行。 +5. 提交 job 只检出 `github.workflow_sha` 对应的可信脚本。通过 artifact ID 下载 ZIP,只接受一个名为 `index.js` 的普通文件,最大 64 MiB。可信 Python 脚本只把文件字节写入固定位置,不按 ZIP 路径解压,不执行产物。 +6. 可信 TypeScript 客户端的依赖独立安装且禁用 lifecycle scripts。仅最后的 API 调用 step 注入 `BUG_SERVER_TOKEN`;PR 元数据与产物来源由可信校验 job 提供。summary 记录 PR、SHA 和来源构建。 +7. 两个 workflow 默认 `contents: read`;查询 PR 需要 `pull-requests: read`,查询/下载 artifact 需要 `actions: read`。原有 push / pull_request 自动 Bug Server 步骤保持原来的构建和测试行为。 + +## 维护者操作变化 + +先等 `Bug Server PR Bundle` 对该 SHA 构建成功,再运行手动入口。产物缺失或过期时重跑 bundle 工作流。新增工作流之前的旧 PR 需要更新或重新打开以触发新 PR 事件;重跑旧定义不能生成新工作流。fork Actions 首次运行可能需要维护者批准。 + +## 验证 + +Node 测试覆盖输入与产物来源校验。Python 测试覆盖正常字节、可执行文本仅作为数据、路径穿越、额外文件、重复文件、链接/特殊文件、体积限制和禁止覆盖目标文件。actionlint 与 CodeQL 必须通过,不以关闭告警作为修复。真实 PR bundle 的查找、下载、读取及 mock 客户端上传在合并前验证;默认分支完整手动测试在合并后验收。 + +此前的 `cache-mode: none` 已在平台验证,但未完成扫描验收;最终方案移除默认分支内的 PR 构建,不再依赖该配置。 diff --git a/packages/react-vrender-utils/CHANGELOG.json b/packages/react-vrender-utils/CHANGELOG.json index 713c7ddcc..5770acb6f 100644 --- a/packages/react-vrender-utils/CHANGELOG.json +++ b/packages/react-vrender-utils/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/react-vrender-utils", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/react-vrender-utils_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/react-vrender-utils_v1.1.10", diff --git a/packages/react-vrender-utils/CHANGELOG.md b/packages/react-vrender-utils/CHANGELOG.md index 4ff1d721e..6b2f3c14a 100644 --- a/packages/react-vrender-utils/CHANGELOG.md +++ b/packages/react-vrender-utils/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/react-vrender-utils -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/react-vrender-utils/jest.config.js b/packages/react-vrender-utils/jest.config.js index 7723b5d3a..c3519ed9c 100644 --- a/packages/react-vrender-utils/jest.config.js +++ b/packages/react-vrender-utils/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -22,7 +21,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '@visactor/react-vrender': path.resolve(__dirname, '../react-vrender/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/react-vrender-utils/package.json b/packages/react-vrender-utils/package.json index 2a4454144..e92c5a256 100644 --- a/packages/react-vrender-utils/package.json +++ b/packages/react-vrender-utils/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vrender-utils", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -27,8 +27,8 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.10", - "@visactor/react-vrender": "workspace:1.1.10", + "@visactor/vrender": "workspace:1.1.11", + "@visactor/react-vrender": "workspace:1.1.11", "@visactor/vutils": "~1.0.12", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" diff --git a/packages/react-vrender/CHANGELOG.json b/packages/react-vrender/CHANGELOG.json index 407788ac6..72105fcdf 100644 --- a/packages/react-vrender/CHANGELOG.json +++ b/packages/react-vrender/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/react-vrender", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/react-vrender_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/react-vrender_v1.1.10", diff --git a/packages/react-vrender/CHANGELOG.md b/packages/react-vrender/CHANGELOG.md index b7081c5b0..702541820 100644 --- a/packages/react-vrender/CHANGELOG.md +++ b/packages/react-vrender/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/react-vrender -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/react-vrender/jest.config.js b/packages/react-vrender/jest.config.js index f7cea21c7..6f6e565cf 100644 --- a/packages/react-vrender/jest.config.js +++ b/packages/react-vrender/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -22,11 +21,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '@visactor/vrender': path.resolve(__dirname, '../vrender/src/index.ts'), - '@visactor/vrender-core': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '@visactor/vrender-kits': path.resolve(__dirname, '../vrender-kits/src/index.ts'), - '@visactor/vrender-animate': path.resolve(__dirname, '../vrender-animate/src/index.ts'), - '@visactor/vrender-components': path.resolve(__dirname, '../vrender-components/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/react-vrender/package.json b/packages/react-vrender/package.json index 7ce44971c..23e0d0404 100644 --- a/packages/react-vrender/package.json +++ b/packages/react-vrender/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vrender", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -26,7 +26,7 @@ "react": "^18.2.0" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.10", + "@visactor/vrender": "workspace:1.1.11", "@visactor/vutils": "~1.0.12", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" diff --git a/packages/vrender-animate/CHANGELOG.json b/packages/vrender-animate/CHANGELOG.json index c5eff74e5..ed7224b9a 100644 --- a/packages/vrender-animate/CHANGELOG.json +++ b/packages/vrender-animate/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender-animate", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-animate_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-animate_v1.1.10", diff --git a/packages/vrender-animate/CHANGELOG.md b/packages/vrender-animate/CHANGELOG.md index ae554386c..36b9167a7 100644 --- a/packages/vrender-animate/CHANGELOG.md +++ b/packages/vrender-animate/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender-animate -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts b/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts index 12fa7853f..8fd5a8739 100644 --- a/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts +++ b/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts @@ -2,6 +2,7 @@ import { application, AttributeUpdateType, createGroup, + createGlyph, createLine, createRect, createSymbol, @@ -124,6 +125,42 @@ describe('D3 pre-handoff animation runtime', () => { jest.restoreAllMocks(); }); + test('Glyph children follow actual animation frames and interrupted state restoration', () => { + const { group, ticker, graphicService } = createStageHarness('glyph-state-runtime'); + const glyph = createGlyph({ width: 20, fill: 'blue' }); + const child = createRect({ height: 10 }); + bindGraphicService(glyph, graphicService); + bindGraphicService(child, graphicService); + glyph.setSubGraphic([child]); + glyph.setSubGraphicEncoder((g, context) => + g.commitSubGraphicAttributes(child, { width: g.attribute.width }, undefined, context) + ); + group.appendChild(glyph); + glyph.states = { selected: { width: 60 } }; + glyph.stateAnimateConfig = { duration: 100, easing: 'linear' }; + glyph.useStates(['selected'], true); + expect(child.attribute.width).toBe(20); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(40); + expect(glyph.baseAttributes.width).toBe(20); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(60); + glyph.clearStates(true); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(40); + tick(ticker, 50); + expect(child.attribute.width).toBe(20); + glyph.useStates(['selected'], true); + tick(ticker, 25); + expect(child.attribute.width).toBeCloseTo(30); + glyph.clearStates(false); + expect({ host: glyph.attribute.width, base: glyph.baseAttributes.width }).toEqual({ host: 20, base: 20 }); + expect(child.attribute.width).toBe(20); + tick(ticker, 100); + expect(child.attribute.width).toBe(20); + expect(glyph.baseAttributes.width).toBe(20); + }); + test('state animation updates graphic.attribute over time without polluting baseAttributes', () => { const { group, ticker, graphicService } = createStageHarness('state-runtime'); const rect = createAnimatedRect(graphicService); diff --git a/packages/vrender-animate/jest.config.js b/packages/vrender-animate/jest.config.js index 8f3c7183f..2a0aaf36e 100644 --- a/packages/vrender-animate/jest.config.js +++ b/packages/vrender-animate/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -21,17 +20,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-animate/package.json b/packages/vrender-animate/package.json index 0158eb1a7..67f2c5cbb 100644 --- a/packages/vrender-animate/package.json +++ b/packages/vrender-animate/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-animate", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -37,7 +37,7 @@ }, "dependencies": { "@visactor/vutils": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.10" + "@visactor/vrender-core": "workspace:1.1.11" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vrender-animate/src/ticker/default-ticker.ts b/packages/vrender-animate/src/ticker/default-ticker.ts index c4d7d1fbc..3d57decae 100644 --- a/packages/vrender-animate/src/ticker/default-ticker.ts +++ b/packages/vrender-animate/src/ticker/default-ticker.ts @@ -7,6 +7,9 @@ import '@visactor/vrender-core/global'; const performanceRAF = new PerformanceRAF(); +// Avoid unbounded growth when long-running animations keep a ticker alive. +const MAX_FRAME_TIME_HISTORY = 600; + class RAFTickHandler implements ITickHandler { protected released: boolean = false; @@ -47,6 +50,14 @@ export class DefaultTicker extends EventEmitter implements ITicker { declare _lastTickTime: number; protected frameTimeHistory: number[] = []; + protected pushFrameTime(time: number): void { + this.frameTimeHistory.push(time); + const overflow = this.frameTimeHistory.length - MAX_FRAME_TIME_HISTORY; + if (overflow > 0) { + this.frameTimeHistory.splice(0, overflow); + } + } + constructor(stage?: IStage) { super(); this.init(); @@ -219,6 +230,7 @@ export class DefaultTicker extends EventEmitter implements ITicker { release(): void { this.stop(); this.timelines = []; + this.frameTimeHistory.length = 0; this.tickerHandler?.release(); this.tickerHandler = null; this.lastFrameTime = -1; @@ -249,7 +261,7 @@ export class DefaultTicker extends EventEmitter implements ITicker { if (this.lastFrameTime < 0) { this.lastFrameTime = currentTime - this.interval + this.timeOffset; - this.frameTimeHistory.push(this.lastFrameTime); + this.pushFrameTime(this.lastFrameTime); } const delta = currentTime - this.lastFrameTime; @@ -259,7 +271,7 @@ export class DefaultTicker extends EventEmitter implements ITicker { if (!skip) { this._handlerTick(delta); this.lastFrameTime = currentTime; - this.frameTimeHistory.push(this.lastFrameTime); + this.pushFrameTime(this.lastFrameTime); } if (!once) { diff --git a/packages/vrender-components/CHANGELOG.json b/packages/vrender-components/CHANGELOG.json index 75d73dd19..5f6345c1c 100644 --- a/packages/vrender-components/CHANGELOG.json +++ b/packages/vrender-components/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender-components", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-components_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-components_v1.1.10", diff --git a/packages/vrender-components/CHANGELOG.md b/packages/vrender-components/CHANGELOG.md index 12bf2f7a3..2e2859b60 100644 --- a/packages/vrender-components/CHANGELOG.md +++ b/packages/vrender-components/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender-components -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-components/__tests__/unit/brush-initial-mask.test.ts b/packages/vrender-components/__tests__/unit/brush-initial-mask.test.ts new file mode 100644 index 000000000..72361b9ed --- /dev/null +++ b/packages/vrender-components/__tests__/unit/brush-initial-mask.test.ts @@ -0,0 +1,193 @@ +import { + EventManager, + FederatedPointerEvent, + type CustomEvent, + type IEventTarget, + type IPolygon, + type Stage +} from '@visactor/vrender-core'; +import { Brush } from '../../src'; +import type { BrushAttributes } from '../../src/brush/type'; +import { createCanvas, removeDom } from '../util/dom'; +import { createTestStage } from '../util/vrender'; + +describe('Brush initial mask', () => { + let canvas: HTMLCanvasElement; + let stage: Stage; + let manager: EventManager; + + beforeEach(() => { + canvas = createCanvas(document.body, 'brush-initial-mask'); + stage = createTestStage('brush-initial-mask'); + manager = new EventManager(stage as unknown as IEventTarget, {}); + // 隔离 jsdom 的 DOM 尺寸,不 mock Brush 几何或 pointer 事件处理。 + jest.spyOn(stage, 'eventPointTransform').mockImplementation(event => { + const pointer = event as PointerEvent; + return { x: pointer.clientX, y: pointer.clientY }; + }); + }); + + afterEach(() => { + manager.release(); + stage.release(); + removeDom(canvas); + jest.restoreAllMocks(); + }); + + function pointer(type: string, x: number, y: number) { + const event = new FederatedPointerEvent(manager); + event.type = type; + event.pointerId = 1; + event.pointerType = 'mouse'; + event.button = 0; + event.buttons = type === 'pointerup' ? 0 : 1; + event.client.x = x; + event.client.y = y; + stage.dispatchEvent(event); + } + + function setup(attributes: Partial = {}) { + const brush = new Brush({ + brushType: 'rect', + delayTime: 0, + interactiveRange: { minX: 0, maxX: 200, minY: 0, maxY: 200 }, + xRange: [10, 150], + yRange: [15, 140], + ...attributes + }); + let mask: IPolygon; + const events: string[] = []; + brush.addEventListener('drawStart', (event: CustomEvent) => { + mask = event.detail.operateMask; + }); + ['drawStart', 'brushActive', 'drawing', 'drawEnd'].forEach(type => { + brush.addEventListener(type, () => events.push(type)); + }); + stage.defaultLayer.add(brush as any); + stage.render(); + return { getMask: () => mask, events }; + } + + test.each([ + [ + 'rect', + [20, 30], + [80, 100], + [ + [20, 30], + [80, 30], + [80, 100], + [20, 100] + ] + ], + [ + 'rect', + [80, 100], + [20, 30], + [ + [80, 100], + [20, 100], + [20, 30], + [80, 30] + ] + ], + [ + 'x', + [20, 30], + [80, 100], + [ + [20, 15], + [80, 15], + [80, 140], + [20, 140] + ] + ], + [ + 'x', + [80, 100], + [20, 30], + [ + [80, 15], + [20, 15], + [20, 140], + [80, 140] + ] + ], + [ + 'y', + [20, 30], + [80, 100], + [ + [10, 30], + [10, 100], + [150, 100], + [150, 30] + ] + ], + [ + 'y', + [80, 100], + [20, 30], + [ + [10, 100], + [10, 30], + [150, 30], + [150, 100] + ] + ] + ] as [NonNullable, number[], number[], number[][]][])( + '%s has the correct mask after exactly one pointermove (%j -> %j)', + (brushType, start, end, expected) => { + const { getMask, events } = setup({ brushType }); + pointer('pointerdown', start[0], start[1]); + pointer('pointermove', end[0], end[1]); + stage.render(); + expect(getMask().attribute.points).toEqual(expected.map(([x, y]) => ({ x, y }))); + expect(events).toEqual(['drawStart']); + // 没有第二次移动就松手:结束时不能遗留两点遮罩。 + pointer('pointerup', end[0], end[1]); + expect(getMask().attribute.points).toEqual(expected.map(([x, y]) => ({ x, y }))); + expect(events).toEqual(['drawStart', 'drawEnd']); + } + ); + + test('keeps the polygon trajectory and its previous points snapshot', () => { + const { getMask } = setup({ brushType: 'polygon' }); + pointer('pointerdown', 20, 30); + pointer('pointermove', 80, 100); + const initialPoints = getMask().attribute.points; + expect(initialPoints).toEqual([ + { x: 20, y: 30 }, + { x: 80, y: 100 } + ]); + pointer('pointermove', 110, 60); + expect(initialPoints).toEqual([ + { x: 20, y: 30 }, + { x: 80, y: 100 } + ]); + expect(getMask().attribute.points).toEqual([ + { x: 20, y: 30 }, + { x: 80, y: 100 }, + { x: 110, y: 60 } + ]); + pointer('pointerup', 110, 60); + }); + + test('preserves the draw event sequence and subsequent rectangle updates', () => { + const { getMask, events } = setup(); + pointer('pointerdown', 20, 30); + pointer('pointermove', 80, 100); + expect(events).toEqual(['drawStart']); + pointer('pointermove', 100, 110); + expect(events).toEqual(['drawStart', 'brushActive']); + pointer('pointermove', 120, 130); + pointer('pointerup', 120, 130); + expect(events).toEqual(['drawStart', 'brushActive', 'drawing', 'drawEnd']); + expect(getMask().attribute.points).toEqual([ + { x: 20, y: 30 }, + { x: 120, y: 30 }, + { x: 120, y: 130 }, + { x: 20, y: 130 } + ]); + }); +}); diff --git a/packages/vrender-components/jest.config.js b/packages/vrender-components/jest.config.js index 77d273fc3..f771948d3 100644 --- a/packages/vrender-components/jest.config.js +++ b/packages/vrender-components/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -10,24 +9,5 @@ module.exports = createStablePackageJestConfig({ tsconfig: './tsconfig.test.json', collectCoverageFrom: ['src/**/*.ts', '!**/type/**'], coveragePathIgnorePatterns: ['node_modules', '__tests__', 'interface.ts', '.d.ts', 'typings', 'type.ts'], - moduleNameMapper: { - '^@visactor/vrender-kits/(.*)$': path.resolve(__dirname, '../vrender-kits/src/$1'), - '^@visactor/vrender-kits$': path.resolve(__dirname, '../vrender-kits/src/index.ts'), - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '^@visactor/vrender/es/core$': path.resolve(__dirname, '../vrender/src/index.ts'), - '^@visactor/vrender/es/register$': path.resolve(__dirname, '../vrender/src/register.ts'), - '^@visactor/vrender/es/kits$': path.resolve(__dirname, '../vrender/src/kits.ts'), - '^@visactor/vrender-animate/(.*)$': path.resolve(__dirname, '../vrender-animate/src/$1'), - '^@visactor/vrender-animate$': path.resolve(__dirname, '../vrender-animate/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-components/package.json b/packages/vrender-components/package.json index 8c52226af..f07e58d9c 100644 --- a/packages/vrender-components/package.json +++ b/packages/vrender-components/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-components", - "version": "1.1.10", + "version": "1.1.11", "description": "components library for dp visualization", "sideEffects": false, "main": "cjs/index.js", @@ -69,9 +69,9 @@ "dependencies": { "@visactor/vutils": "~1.0.12", "@visactor/vscale": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.10", - "@visactor/vrender-kits": "workspace:1.1.10", - "@visactor/vrender-animate": "workspace:1.1.10" + "@visactor/vrender-core": "workspace:1.1.11", + "@visactor/vrender-kits": "workspace:1.1.11", + "@visactor/vrender-animate": "workspace:1.1.11" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vrender-components/src/brush/brush.ts b/packages/vrender-components/src/brush/brush.ts index 07631322c..f675a78f7 100644 --- a/packages/vrender-components/src/brush/brush.ts +++ b/packages/vrender-components/src/brush/brush.ts @@ -464,7 +464,7 @@ export class Brush extends AbstractComponent> { private _addBrushMask() { const { brushStyle, hasMask } = this.attribute as BrushAttributes; const brushMask = graphicCreator.polygon({ - points: cloneDeep(this._cacheDrawPoints), // _cacheDrawPoints在不断更新,所以这里需要cloneDeep + points: this._computeMaskPoints(), cursor: 'move', pickable: false, ...brushStyle, diff --git a/packages/vrender-core/CHANGELOG.json b/packages/vrender-core/CHANGELOG.json index 2285cc0bb..4c91b2a79 100644 --- a/packages/vrender-core/CHANGELOG.json +++ b/packages/vrender-core/CHANGELOG.json @@ -1,6 +1,23 @@ { "name": "@visactor/vrender-core", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-core_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "patch": [ + { + "comment": "统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。" + } + ], + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-core_v1.1.10", diff --git a/packages/vrender-core/CHANGELOG.md b/packages/vrender-core/CHANGELOG.md index c7915876d..ea619f642 100644 --- a/packages/vrender-core/CHANGELOG.md +++ b/packages/vrender-core/CHANGELOG.md @@ -1,6 +1,17 @@ # Change Log - @visactor/vrender-core -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Patches + +- 统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。 + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-core/__tests__/artifacts/container-compatibility.test.ts b/packages/vrender-core/__tests__/artifacts/container-compatibility.test.ts new file mode 100644 index 000000000..0a81e3d1c --- /dev/null +++ b/packages/vrender-core/__tests__/artifacts/container-compatibility.test.ts @@ -0,0 +1,26 @@ +declare const require: any; +export {}; + +const fs = require('fs'); +const path = require('path'); +const process = require('process'); +const packageRoot = process.cwd(); + +function readArtifact(relativePath: string) { + return fs.readFileSync(path.join(packageRoot, relativePath), 'utf8'); +} + +describe('vrender-core published container compatibility', () => { + test('es artifacts should expose legacy container compatibility surface', () => { + expect(readArtifact('es/modules.js')).toContain('export const container'); + expect(readArtifact('es/modules.d.ts')).toContain('container'); + expect(readArtifact('es/index.d.ts')).toContain("from './modules'"); + expect(readArtifact('es/index.d.ts')).toContain('container'); + }); + + test('cjs root export should expose container as the legacy binding context', () => { + const vrenderCore = require(path.join(packageRoot, 'cjs/index.js')); + + expect(vrenderCore.container).toBe(vrenderCore.getLegacyBindingContext()); + }); +}); diff --git a/packages/vrender-core/__tests__/artifacts/root-esm-exports.test.ts b/packages/vrender-core/__tests__/artifacts/root-esm-exports.test.ts new file mode 100644 index 000000000..a15422675 --- /dev/null +++ b/packages/vrender-core/__tests__/artifacts/root-esm-exports.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment node + */ + +declare const __dirname: string; +declare const require: any; +export {}; + +const fs = require('fs'); +const path = require('path'); +const packageRoot = path.resolve(__dirname, '../..'); + +describe('vrender-core published root exports', () => { + test('keeps BytePack-sensitive root runtime exports explicit in the ESM artifact', () => { + const artifact = fs.readFileSync(path.join(packageRoot, 'es/index.js'), 'utf8'); + const explicitExports = new Set(); + const exportPattern = /export\s+\{([^}]+)\}/g; + let match: RegExpExecArray | null; + + while ((match = exportPattern.exec(artifact))) { + match[1] + .split(',') + .map((item: string) => item.trim()) + .filter(Boolean) + .forEach((specifier: string) => { + const exportedName = specifier.match(/\s+as\s+([A-Za-z0-9_$]+)$/)?.[1] ?? specifier; + explicitExports.add(exportedName.trim()); + }); + } + + const expectedExplicitRuntimeExports = [ + 'CustomEvent', + 'CustomPath2D', + 'GradientParser', + 'IContainPointMode', + 'Symbol', + 'builtInSymbolStrMap', + 'builtinSymbols', + 'builtinSymbolsMap', + 'container', + 'createArc', + 'createArc3d', + 'createArea', + 'createGlyph', + 'createGroup', + 'createImage', + 'createLine', + 'createPath', + 'createPolygon', + 'createPyramid3d', + 'createRect', + 'createRect3d', + 'createRichText', + 'createSymbol', + 'createText', + 'getRichTextBounds', + 'getTextBounds', + 'graphicCreator', + 'isBrowserEnv', + 'mapToCanvasPointForCanvas', + 'matrixAllocate', + 'registerDirectionalLight', + 'registerGlobalEventTransformer', + 'registerHtmlAttributePlugin', + 'registerOrthoCamera', + 'registerReactAttributePlugin', + 'registerViewTransform3dPlugin', + 'registerWindowEventTransformer', + 'transformPointForCanvas', + 'vglobal', + 'waitForAllSubLayers' + ]; + + expect(expectedExplicitRuntimeExports.filter(name => !explicitExports.has(name))).toEqual([]); + + expect(artifact).toContain('export { Symbol, createSymbol } from "./graphic/symbol"'); + expect(artifact).toContain( + 'export { builtInSymbolStrMap, builtinSymbols, builtinSymbolsMap } from "./graphic/builtin-symbol"' + ); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/xml-parser-bundle-safe.test.ts b/packages/vrender-core/__tests__/artifacts/xml-parser-bundle-safe.test.ts similarity index 100% rename from packages/vrender-core/__tests__/unit/xml-parser-bundle-safe.test.ts rename to packages/vrender-core/__tests__/artifacts/xml-parser-bundle-safe.test.ts diff --git a/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts b/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts new file mode 100644 index 000000000..91cf5a384 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts @@ -0,0 +1,105 @@ +import type { IAreaSegment, IDrawContext } from '../../src/interface'; +import { Area } from '../../src/graphic/area'; +import { DefaultIncrementalCanvasAreaRender } from '../../src/render/contributions/render/incremental-area-render'; +import { basisPoints, createAreaContext, renderArea } from './area-test-utils'; + +const renderer = new DefaultIncrementalCanvasAreaRender({ getContributions: () => [] }); + +function drawBatch(area: Area, record: ReturnType, startAtIdx: number, length: number) { + area.incremental = 1; + renderer.drawShape(area, record.context, 0, 0, { + context: record.context, + multiGraphicOptions: { startAtIdx, length } + } as IDrawContext); +} + +function pixels(record: ReturnType) { + return Array.from(record.nativeContext.getImageData(0, 0, 120, 30).data); +} + +describe('incremental area missing-data continuity', () => { + test.each(['none', 'connect'] as const)( + '%s matches ordinary linear area across missing segments and batches', + connectedType => { + const segments: IAreaSegment[] = [ + { points: [basisPoints[2]] }, + { points: basisPoints.slice(0, 2) }, + { points: [] }, + { points: [basisPoints[2]] }, + { points: [basisPoints[2]] }, + { points: basisPoints.slice(3) } + ]; + const area = new Area({ fill: 'red', connectedType, segments }); + const record = createAreaContext(); + for (let i = 0; i < segments.length; i++) { + drawBatch(area, record, i, 1); + } + expect(pixels(record)).toEqual(pixels(renderArea({ segments, connectedType }))); + } + ); + + test.each(['none', 'connect'] as const)( + '%s selects the same upper and lower points within a segment', + connectedType => { + const segments = [{ points: basisPoints }]; + const area = new Area({ fill: 'red', connectedType, segments }); + const record = createAreaContext(); + drawBatch(area, record, 0, 1); + expect(pixels(record)).toEqual(pixels(renderArea({ segments, connectedType }))); + } + ); + + test('interleaved graphics and replaced segments use their current data', () => { + const segments = [ + { points: basisPoints.slice(0, 2) }, + { points: [basisPoints[2]] }, + { points: basisPoints.slice(3) } + ]; + const first = new Area({ fill: 'red', connectedType: 'connect', segments }); + const other = new Area({ fill: 'red', connectedType: 'none', segments }); + const a = createAreaContext(); + const b = createAreaContext(); + for (let i = 0; i < segments.length; i++) { + drawBatch(first, a, i, 1); + drawBatch(other, b, i, 1); + } + expect(pixels(a)).toEqual(pixels(renderArea({ segments, connectedType: 'connect' }))); + expect(pixels(b)).toEqual(pixels(renderArea({ segments, connectedType: 'none' }))); + + const replacement = [{ points: basisPoints.slice(3) }]; + first.setAttributes({ segments: replacement, connectedType: 'none' }); + const restarted = createAreaContext(); + drawBatch(first, restarted, 0, 1); + expect(pixels(restarted)).toEqual(pixels(renderArea({ segments: replacement }))); + }); + + test('missing-only append batches do not repeatedly scan the prefix', () => { + let reads = 0; + const firstPoints = basisPoints.slice(0, 2); + Object.defineProperty(firstPoints, 1, { + get: () => { + reads++; + return basisPoints[1]; + } + }); + const segments = [{ points: firstPoints }]; + const area = new Area({ fill: 'red', connectedType: 'connect', segments }); + const record = createAreaContext(); + drawBatch(area, record, 0, 1); + const initialReads = reads; + for (let i = 0; i < 30; i++) { + segments.push({ points: [basisPoints[2]] }); + drawBatch(area, record, segments.length - 1, 1); + } + expect(reads).toBe(initialReads); + segments.push({ points: basisPoints.slice(3) }); + drawBatch(area, record, segments.length - 1, 1); + expect(reads).toBe(initialReads + 1); + expect(record.nativeContext.isPointInPath(15, 2)).toBe(true); + + area.setAttribute('connectedType', 'none'); + const changed = createAreaContext(); + drawBatch(area, changed, segments.length - 1, 1); + expect(changed.nativeContext.isPointInPath(15, 2)).toBe(false); + }); +}); diff --git a/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts b/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts new file mode 100644 index 000000000..d3ca5bfa5 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts @@ -0,0 +1,190 @@ +import type { IArea, ICurveType } from '../../src/interface'; +import type { IPointLike } from '@visactor/vutils'; +import { Area } from '../../src/graphic/area'; +import { calcLineCache } from '../../src/common/segment'; +import { drawAreaSegments } from '../../src/common/render-area'; +import { basisPoints, createAreaContext, renderArea } from './area-test-utils'; + +const curveTypes: ICurveType[] = [ + 'linear', + 'basis', + 'monotoneX', + 'monotoneY', + 'step', + 'stepBefore', + 'stepAfter', + 'stepClosed', + 'linearClosed', + 'catmullRom', + 'catmullRomClosed' +]; +const left = [ + { x: 0, y: 4, y1: 0 }, + { x: 4, y: 8, y1: 0 }, + { x: 8, y: 6, y1: 0 }, + { x: 12, y: 10, y1: 0 } +]; +const right = left.map(p => ({ ...p, x: p.x + 20 })); + +describe('area paths with undefined points', () => { + test('basis interpolation restarts at a gap before calculating either boundary', () => { + const { nativeContext, area } = renderArea({ points: basisPoints, curveType: 'basis', connectedType: 'none' }); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + expect(nativeContext.isPointInPath(5, 2)).toBe(true); + expect(nativeContext.isPointInPath(25, 2)).toBe(true); + expect(nativeContext.isPointInPath(15, 2)).toBe(false); + expect(area.AABBBounds.x2).toBe(30); + expect(area.AABBBounds.y2).toBe(10); + }); + + test('a leading undefined singleton cannot seed the next styled segment', () => { + const { nativeContext, area, fills } = renderArea({ + connectedType: 'connect', + segments: [ + { fill: 'blue', points: [basisPoints[2]] }, + { fill: 'green', points: basisPoints.slice(0, 2) } + ] + }); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + expect(nativeContext.isPointInPath(5, 2)).toBe(true); + expect(fills.map(attrs => attrs.fill)).toEqual(['green']); + expect(area.AABBBounds.x2).toBe(10); + expect(area.AABBBounds.y2).toBe(10); + }); + + test.each(curveTypes)('%s uses the same geometry as independently selected valid points', curveType => { + const points = [...left, basisPoints[2], ...right]; + const actual = renderArea({ points, curveType, connectedType: 'none' }); + const first = renderArea({ points: left, curveType }); + const second = renderArea({ points: right, curveType }); + for (let x = 0.5; x < 34; x += 1) { + for (let y = 0.5; y < 12; y += 1) { + expect(actual.nativeContext.isPointInPath(x, y)).toBe( + first.nativeContext.isPointInPath(x, y) || second.nativeContext.isPointInPath(x, y) + ); + } + } + expect(renderArea({ points, curveType, connectedType: 'connect' }).commands).toEqual( + renderArea({ points: [...left, ...right], curveType, connectedType: 'connect' }).commands + ); + }); + + test.each(curveTypes)('%s ignores missing coordinates for clipping and both stroke boundaries', curveType => { + for (const connectedType of ['none', 'connect'] as const) { + for (const clipRange of [0, 0.5, 1]) { + for (const vertical of [false, true]) { + const valid = vertical ? [...left, ...right].map(p => ({ x: p.y, y: p.x, x1: 0 })) : [...left, ...right]; + const points: IPointLike[] = [ + basisPoints[2], + ...valid.slice(0, 4), + basisPoints[2], + ...valid.slice(4), + basisPoints[2] + ]; + const attrs = { points, curveType, connectedType, clipRange, stroke: [true, false, false] }; + const actual = renderArea(attrs); + const displaced = renderArea({ + ...attrs, + points: points.map(p => (p.defined === false ? { x: NaN, y: NaN, x1: NaN, y1: NaN, defined: false } : p)) + }); + expect(actual.commands).toEqual(displaced.commands); + // Zero-projection clipping behavior is outside this missing-data fix. + if (clipRange === 1) { + expect(actual.commands.every(([, ...args]) => args.every(Number.isFinite))).toBe(true); + } + expect(renderArea({ ...attrs, stroke: [false, false, true] }).commands).toEqual( + renderArea({ ...attrs, points: displaced.area.attribute.points, stroke: [false, false, true] }).commands + ); + } + } + } + }); + + test.each(curveTypes)('%s preserves the existing all-defined styled segment contract', curveType => { + const actual = renderArea({ segments: [{ points: left }, { points: right }], curveType }); + const previous = calcLineCache(left, curveType); + const top = calcLineCache(right, curveType, { startPoint: { x: previous.endX, y: previous.endY } }); + const bottomPoints = [left[left.length - 1], ...right].reverse().map(p => ({ x: p.x, y: p.y1 })); + const bottomType = curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType; + const bottom = calcLineCache(bottomPoints, bottomType); + const expected = createAreaContext(); + expected.context.beginPath(); + drawAreaSegments(expected.context, { top, bottom }, 1); + const lastBegin = actual.commands.map(command => command[0]).lastIndexOf('beginPath'); + expect(actual.commands.slice(lastBegin)).toEqual(expected.commands); + }); + + test.each(['none', 'connect'] as const)( + 'empty and missing segments keep styles aligned in %s mode', + connectedType => { + const { fills, nativeContext } = renderArea({ + connectedType, + segments: [ + { fill: 'empty', points: [] }, + { fill: 'invalid', points: [basisPoints[2]] }, + { fill: 'seed', points: [left[0]] }, + { fill: 'green', points: left.slice(1) }, + { fill: 'empty', points: [] }, + { fill: 'invalid', points: [basisPoints[2], basisPoints[2]] }, + { fill: 'blue', points: right } + ] + }); + expect(fills.map(attrs => attrs.fill)).toEqual(['green', 'blue']); + expect(nativeContext.isPointInPath(25, 2)).toBe(true); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + } + ); + + test('a trailing missing point clears continuity only in none mode', () => { + const segments = [{ points: [...left, basisPoints[2]] }, { points: right }]; + expect(renderArea({ segments, connectedType: 'none' }).nativeContext.isPointInPath(16, 2)).toBe(false); + expect(renderArea({ segments, connectedType: 'connect' }).nativeContext.isPointInPath(16, 2)).toBe(true); + }); + + test('all missing points and singleton runs clear previously rendered geometry', () => { + const area = new Area({ fill: 'red', points: basisPoints, curveType: 'basis' }); + renderArea(area); + for (const points of [[], [basisPoints[2]], [left[0], basisPoints[2], right[0]]]) { + area.setAttribute('points', points); + const result = renderArea(area); + expect(result.fills).toHaveLength(0); + expect(result.nativeContext.isPointInPath(5, 2)).toBe(false); + } + }); + + test('connection mode changes rebuild the cache without replacing points', () => { + const area = new Area({ fill: 'red', points: basisPoints, connectedType: 'none' }); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(false); + area.setAttribute('connectedType', 'connect'); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(true); + area.setAttribute('connectedType', 'none'); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(false); + }); + + test('geometry attributes rebuild caches while repeated draws and clip updates reuse them', () => { + const area = new Area({ fill: 'red', points: basisPoints, curveType: 'basis' }); + renderArea(area); + let cache = (area as IArea).cacheArea; + renderArea(area); + area.setAttribute('clipRange', 0.5); + renderArea(area); + expect((area as IArea).cacheArea).toBe(cache); + for (const attrs of [ + { curveType: 'linear' as const }, + { curveTension: 0.7 }, + { points: [...basisPoints] }, + { segments: [{ points: basisPoints }] } + ]) { + area.setAttributes(attrs); + renderArea(area); + expect((area as IArea).cacheArea).not.toBe(cache); + cache = (area as IArea).cacheArea; + } + }); + + test('valid input arrays and points remain owned by the caller', () => { + const points = [...left, basisPoints[2], ...right].map(p => Object.freeze({ ...p })); + Object.freeze(points); + expect(() => renderArea({ points, curveType: 'basis' })).not.toThrow(); + }); +}); diff --git a/packages/vrender-core/__tests__/graphic/area-test-utils.ts b/packages/vrender-core/__tests__/graphic/area-test-utils.ts new file mode 100644 index 000000000..cc4a0d2d7 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-test-utils.ts @@ -0,0 +1,74 @@ +// Pixel and hit-test assertions require a real Canvas instead of the default mock. +import '../../../../share/jest-config/setup-jsdom-canvas'; +import '../../src/modules'; +import type { IAreaGraphicAttribute, IContext2d, IDrawContext } from '../../src/interface'; +import { Area } from '../../src/graphic/area'; +import { DefaultCanvasAreaRender } from '../../src/render/contributions/render/area-render'; + +export const areaRenderer = new DefaultCanvasAreaRender({ getContributions: () => [] }); + +export function createAreaContext() { + const canvas = document.createElement('canvas'); + canvas.width = 160; + canvas.height = 80; + const nativeContext = canvas.getContext('2d'); + const commands: Array<[string, ...number[]]> = []; + const fills: IAreaGraphicAttribute[] = []; + let attribute: IAreaGraphicAttribute; + const context = { + nativeContext, + beginPath() { + commands.push(['beginPath']); + nativeContext.beginPath(); + }, + moveTo(x: number, y: number) { + commands.push(['moveTo', x, y]); + nativeContext.moveTo(x, y); + }, + lineTo(x: number, y: number) { + commands.push(['lineTo', x, y]); + nativeContext.lineTo(x, y); + }, + bezierCurveTo(...args: [number, number, number, number, number, number]) { + const coordinates = args.slice(0, 6) as typeof args; + commands.push(['bezierCurveTo', ...coordinates]); + nativeContext.bezierCurveTo(...coordinates); + }, + closePath() { + commands.push(['closePath']); + nativeContext.closePath(); + }, + setShadowBlendStyle() { + // Geometry assertions use the native context's default shadow and blend settings. + }, + setCommonStyle(_area: Area, attrs: IAreaGraphicAttribute) { + attribute = attrs; + }, + setStrokeStyle() { + // The harness records stroke geometry without applying attribute styles. + }, + fill() { + fills.push(attribute); + nativeContext.fill(); + }, + stroke() { + nativeContext.stroke(); + } + }; + return { context: context as unknown as IContext2d, nativeContext, commands, fills }; +} + +export function renderArea(attribute: IAreaGraphicAttribute | Area, x = 0, y = 0) { + const area = attribute instanceof Area ? attribute : new Area({ fill: 'red', ...attribute }); + const record = createAreaContext(); + areaRenderer.drawShape(area, record.context, x, y, { context: record.context } as IDrawContext); + return { ...record, area }; +} + +export const basisPoints = [ + { x: 0, y: 0, y1: 0 }, + { x: 10, y: 10, y1: 0 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 20, y: 10, y1: 0 }, + { x: 30, y: 0, y1: 0 } +]; diff --git a/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts b/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts new file mode 100644 index 000000000..40e451d47 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts @@ -0,0 +1,54 @@ +import { AABBBounds } from '@visactor/vutils'; +import { Area } from '../../src/graphic/area'; +import { Line } from '../../src/graphic/line'; + +function expectBounds(bounds: AABBBounds) { + expect(bounds.x1).toBe(0); + expect(bounds.y1).toBe(0); + expect(bounds.x2).toBe(10); + expect(bounds.y2).toBe(10); +} + +describe('invalid defined points', () => { + test('line bounds exclude invalid points when connecting the remaining points', () => { + const points = [ + { x: 0, y: 0 }, + { x: 500, y: 500, defined: false }, + { x: 10, y: 10 } + ]; + const line = new Line({ points, connectedType: 'connect' }); + + const pointBounds = new AABBBounds(); + (line as any).updateLineAABBBoundsByPoints(line.attribute, { points }, pointBounds); + expectBounds(pointBounds); + + const segmentBounds = new AABBBounds(); + (line as any).updateLineAABBBoundsBySegments( + { segments: [{ points }], connectedType: 'connect' }, + { segments: [{ points }] }, + segmentBounds + ); + expectBounds(segmentBounds); + }); + + test('area bounds exclude both coordinates of invalid points', () => { + const points = [ + { x: 0, y: 0, y1: 2 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 10, y: 10, y1: 4 } + ]; + const area = new Area({ points, connectedType: 'connect' }); + + const pointBounds = new AABBBounds(); + (area as any).updateAreaAABBBoundsByPoints(area.attribute, { points }, pointBounds); + expectBounds(pointBounds); + + const segmentBounds = new AABBBounds(); + (area as any).updateAreaAABBBoundsBySegments( + { segments: [{ points }], connectedType: 'connect' }, + { segments: [{ points }] }, + segmentBounds + ); + expectBounds(segmentBounds); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts index 137dd434c..cbdd6b827 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts @@ -1,5 +1,7 @@ +import { StateDefinitionCompiler } from '../../../src/graphic/state/state-definition-compiler'; import { createGlyph } from '../../../src/graphic/glyph'; import { createRect } from '../../../src/graphic/rect'; +import { createGroup } from '../../../src/graphic/group'; describe('Glyph state', () => { const createTestGlyph = () => { @@ -115,7 +117,7 @@ describe('Glyph state', () => { expect(glyph.normalAttrs).toEqual((glyph as any).baseAttributes); }); - test('should differ from normal graphic states by reading glyphStates instead of states', () => { + test('explicit glyphStates take precedence over standard local definitions', () => { const { glyph } = createTestGlyph(); (glyph as any).states = { hover: { @@ -135,4 +137,94 @@ describe('Glyph state', () => { expect(glyph.attribute.stroke).toBe('glyph-state'); }); + + test('removes state-only keys and restores the latest base attributes', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStates = { + selected: { attributes: { fillOpacity: 0.25, stroke: 'red' }, subAttributes: [] } + }; + glyph.setStates(['selected'], false); + expect(glyph.attribute.fillOpacity).toBe(0.25); + expect(glyph.baseAttributes.fillOpacity).toBeUndefined(); + glyph.setAttribute('stroke', 'orange'); + expect(glyph.attribute.stroke).toBe('red'); + glyph.setStates([], false); + expect(glyph.attribute.stroke).toBe('orange'); + expect(glyph.attribute.fillOpacity).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(glyph.attribute, 'fillOpacity')).toBe(false); + }); + + test('refreshes a proxy-only state without clearing it first', () => { + const { glyph } = createTestGlyph(); + let opacity = 0.2; + glyph.glyphStateProxy = () => ({ attributes: { fillOpacity: opacity }, subAttributes: [] }); + glyph.setStates(['selected'], { animate: false }); + opacity = 0.8; + glyph.setStates(['selected'], { animate: false }); + expect(glyph.currentStates).toEqual(['selected']); + expect(glyph.effectiveStates).toEqual(['selected']); + expect(glyph.resolvedStatePatch.fillOpacity).toBe(0.8); + expect(glyph.attribute.fillOpacity).toBe(0.8); + expect(glyph.baseAttributes.fillOpacity).toBeUndefined(); + }); + + test('preserves legacy input order and stateSort without mutating the input', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStates = { + a: { attributes: { stroke: 'red' }, subAttributes: [] }, + z: { attributes: { stroke: 'blue' }, subAttributes: [] } + }; + glyph.useStates(['z', 'a'], false); + expect(glyph.attribute.stroke).toBe('red'); + glyph.useStates(['a', 'z'], false); + expect(glyph.attribute.stroke).toBe('blue'); + (glyph as any).stateSort = (a: string, b: string) => b.localeCompare(a); + const states = ['a', 'z']; + const proxy = jest.fn((name: string) => glyph.glyphStates[name]); + glyph.glyphStateProxy = proxy; + glyph.setStates(states, { animate: false }); + expect(glyph.attribute.stroke).toBe('red'); + expect(proxy).toHaveBeenCalledWith('a', ['z', 'a']); + expect(states).toEqual(['a', 'z']); + }); + + test('uses Group definitions unless explicit legacy inputs own the glyph', () => { + const { glyph } = createTestGlyph(); + const group = createGroup({}); + group.sharedStateDefinitions = { + hover: { stroke: 'shared' }, + selected: { fillOpacity: 0.4 } + }; + group.add(glyph); + glyph.states = { hover: { stroke: 'local' } }; + glyph.setStates(['hover'], false); + expect(glyph.attribute.stroke).toBe('shared'); + glyph.glyphStates = { hover: { attributes: { stroke: 'legacy' }, subAttributes: [] } }; + glyph.setStates(['hover', 'selected'], { animate: false }); + expect(glyph.attribute.stroke).toBe('legacy'); + expect(glyph.attribute.fillOpacity).toBeUndefined(); + glyph.glyphStateProxy = () => undefined; + glyph.setStates(['hover'], { animate: false }); + expect(glyph.attribute.stroke).toBe('black'); + glyph.glyphStateProxy = undefined; + glyph.glyphStates = undefined; + glyph.setStates(['hover', 'selected'], { animate: false }); + expect(glyph.attribute.stroke).toBe('shared'); + expect(glyph.attribute.fillOpacity).toBe(0.4); + glyph.clearStates(false); + expect(glyph.registeredActiveScopes).toBeUndefined(); + }); + test('repeated legacy state switches reuse compiled definitions', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStateProxy = name => ({ attributes: { fill: name === 'hover' ? 'red' : 'blue' }, subAttributes: [] }); + glyph.useStates(['hover', 'selected'], false); + const compile = jest.spyOn(StateDefinitionCompiler.prototype, 'compile'); + for (let i = 0; i < 20; i++) { + glyph.useStates(['selected', 'hover'], false); + glyph.useStates(['hover', 'selected'], false); + glyph.clearStates(false); + } + expect(compile).not.toHaveBeenCalled(); + compile.mockRestore(); + }); }); diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts new file mode 100644 index 000000000..cc19ea773 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts @@ -0,0 +1,271 @@ +import { createGlyph } from '../../../src/graphic/glyph'; +import { createRect } from '../../../src/graphic/rect'; +import { UpdateTag } from '../../../src/common/enums'; +import { application } from '../../../src/application'; +import { DefaultGraphicService } from '../../../src/graphic/graphic-service/graphic-service'; +import { createPath } from '../../../src/graphic/path'; +import { createCircle } from '../../../src/graphic/circle'; + +const createFixture = () => { + const glyph = createGlyph({ fill: 'red', width: 20 }); + const child = createRect({ height: 10 }); + const service = { onAttributeUpdate: jest.fn(), onSetStage: jest.fn() }; + [glyph, child].forEach(g => jest.spyOn(g as any, 'getGraphicService').mockReturnValue(service)); + glyph.setSubGraphic([child]); + return { glyph, child, service }; +}; + +describe('Glyph derived attributes', () => { + test('encodes the initial, state, base update and restored values before observers', () => { + const { glyph, child } = createFixture(); + const encoder = jest.fn(g => child.setAttribute('width', g.attribute.width)); + glyph.setSubGraphicEncoder(encoder); + expect(child.attribute.width).toBe(20); + const seen: number[] = []; + glyph.onUpdate(() => seen.push(child.attribute.width)); + glyph.states = { selected: { width: 40 } }; + glyph.setStates(['selected'], false); + glyph.setAttribute('width', 30); + glyph.clearStates(false); + expect(seen).toEqual([40, 40, 30]); + expect(glyph.baseAttributes.width).toBe(30); + }); + + test('silent writes still encode but do not notify observers or services', () => { + const { glyph, child, service } = createFixture(); + glyph.setSubGraphicEncoder((g, context) => { + g.commitSubGraphicAttributes(child, { width: g.attribute.width }, undefined, context); + }); + service.onAttributeUpdate.mockClear(); + const observer = jest.fn(); + glyph.onUpdate(observer); + glyph.addEventListener('afterAttributeUpdate', observer); + child.addEventListener('afterAttributeUpdate', observer); + glyph.setAttributes({ width: 50 }, false, { skipUpdateCallback: true }); + expect(child.attribute.width).toBe(50); + expect(observer).not.toHaveBeenCalled(); + expect(service.onAttributeUpdate).not.toHaveBeenCalled(); + }); + + test('keeps inheritance through host and child state surfaces and detach', () => { + const { glyph, child } = createFixture(); + glyph.states = { hover: { fill: 'blue' } }; + glyph.useStates(['hover'], false); + expect(child.attribute.fill).toBe('blue'); + child.states = { selected: { lineWidth: 4 } }; + child.useStates(['selected'], false); + expect(child.attribute.fill).toBe('blue'); + child.setAttribute('height', 15); + expect(child.attribute.fill).toBe('blue'); + glyph.clearStates(false); + expect(child.attribute.fill).toBe('red'); + child.clearStates(false); + expect(child.attribute.fill).toBe('red'); + glyph.setSubGraphic([]); + expect(child.glyphHost).toBeNull(); + expect(child.attribute.fill).toBeUndefined(); + expect(child.baseAttributes.fill).toBeUndefined(); + }); + + test('removes only owned keys atomically and keeps child state and base truth', () => { + const { glyph, child, service } = createFixture(); + child.setAttributes({ fill: 'orange', lineWidth: 2 }); + child.states = { selected: { fill: 'green' } }; + child.setStates(['selected'], false); + glyph.setAttribute('fill', 'blue'); + service.onAttributeUpdate.mockClear(); + glyph.commitSubGraphicAttributes(child, { width: 9 }, ['fill']); + expect(child.attribute.fill).toBe('green'); + expect(child.attribute.width).toBe(9); + expect(child.attribute.lineWidth).toBe(2); + expect(service.onAttributeUpdate).toHaveBeenCalledTimes(1); + child.clearStates(false); + expect(child.attribute.fill).toBe('blue'); + expect(Object.prototype.hasOwnProperty.call(child.baseAttributes, 'fill')).toBe(false); + }); + + test('inherited paint state changes do not invalidate child geometry', () => { + const { glyph, child } = createFixture(); + glyph.states = { hover: { fill: 'blue', fillOpacity: 0.5 } }; + (glyph as any)._updateTag = 0; + (child as any)._updateTag = 0; + glyph.setStates(['hover'], false); + expect(child.attribute.fill).toBe('blue'); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + glyph.clearStates(false); + (glyph as any)._updateTag = 0; + (child as any)._updateTag = 0; + glyph.setAttribute('fill', 'purple'); + expect(child.attribute.fill).toBe('purple'); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + }); + + test('derived paint patches do not invalidate child geometry', () => { + const { glyph, child } = createFixture(); + (child as any)._updateTag = 0; + glyph.commitSubGraphicAttributes(child, { fill: 'gray', fillOpacity: 0.5 }); + expect(child.attribute.fill).toBe('gray'); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + glyph.commitSubGraphicAttributes(child, { width: 30 }); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).not.toBe(0); + }); + + test('clone callbacks are independent and initAttributes resynchronizes children', () => { + const { glyph, child, service } = createFixture(); + const encode = jest.fn((g, context) => { + g.commitSubGraphicAttributes(g.getSubGraphic()[0], { width: g.attribute.width }, undefined, context); + }); + glyph.setSubGraphicEncoder(encode); + const clone = glyph.clone() as typeof glyph; + [clone, ...clone.getSubGraphic()].forEach(g => jest.spyOn(g as any, 'getGraphicService').mockReturnValue(service)); + clone.setAttributes({ x: 10 }, false, { skipUpdateCallback: true }); + expect(encode).toHaveBeenCalledTimes(1); + clone.setSubGraphicEncoder(encode); + clone.initAttributes({ width: 60 }); + expect(clone.getSubGraphic()[0].attribute).toMatchObject({ width: 60 }); + expect(child.attribute.width).toBe(20); + }); + + test('release clears encoder references and detaches children', () => { + const { glyph, child } = createFixture(); + glyph.setSubGraphicEncoder(jest.fn()); + glyph.release(); + expect(glyph.getSubGraphic()).toEqual([]); + expect(child.glyphHost).toBeNull(); + expect(child.releaseStatus).toBe('released'); + expect((glyph as any).subGraphicEncoder).toBeUndefined(); + }); +}); + +describe('Glyph cached geometry', () => { + let previousService: typeof application.graphicService; + + beforeEach(() => { + previousService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + }); + + afterEach(() => { + application.graphicService = previousService; + }); + + describe.each(['attributes', 'derived', 'host'])('%s offset updates', writer => { + test.each(['dx', 'dy'])('refreshes cached matrices and bounds for %s', key => { + const glyph = createGlyph({}); + const child = createRect({ width: 10, height: 10 }); + glyph.setSubGraphic([child]); + const readPosition = () => ({ + matrix: key === 'dx' ? child.transMatrix.e : child.transMatrix.f, + child: key === 'dx' ? child.AABBBounds.x1 : child.AABBBounds.y1, + glyph: key === 'dx' ? glyph.AABBBounds.x1 : glyph.AABBBounds.y1 + }); + expect(readPosition()).toEqual({ matrix: 0, child: 0, glyph: 0 }); + + if (writer === 'derived') { + glyph.commitSubGraphicAttributes(child, { [key]: 20 }); + } else if (writer === 'host') { + glyph.setAttributes({ [key]: 20 }); + } else { + child.setAttributes({ [key]: 20 }); + } + + expect(child.attribute[key]).toBe(20); + expect(readPosition()).toEqual({ matrix: 20, child: 20, glyph: 20 }); + }); + }); + + describe.each(['single', 'batch', 'state'])('%s inherited geometry updates', writer => { + [ + { + key: 'path', + initial: 'M0 0H10V10H0Z', + next: 'M0 0H30V10H0Z', + initialWidth: 10, + nextWidth: 30, + createChild: () => createPath({}) + }, + { + key: 'radius', + initial: 10, + next: 30, + initialWidth: 20, + nextWidth: 60, + createChild: () => createCircle({}) + } + ].forEach(({ key, initial, next, initialWidth, nextWidth, createChild }) => { + test(`refreshes child and host geometry for ${key}`, () => { + const glyph = createGlyph({ [key]: initial }); + const child = createChild(); + glyph.setSubGraphic([child]); + // Revalidate after inheritance is bound, including Path's required path attribute. + child.setAttribute('fill', 'red'); + const expectWidths = (width: number) => { + expect(child.AABBBounds.width()).toBe(width); + expect(glyph.AABBBounds.width()).toBe(width); + if ('getParsedPathShape' in child) { + expect(child.getParsedPathShape().getBounds().width()).toBe(width); + } + }; + expectWidths(initialWidth); + + if (writer === 'single') { + glyph.setAttribute(key, next); + } else if (writer === 'batch') { + glyph.setAttributes({ [key]: next }); + } else { + glyph.states = { expanded: { [key]: next } }; + glyph.setStates(['expanded'], false); + } + + expect(child.attribute[key]).toBe(next); + expectWidths(nextWidth); + if (writer === 'state') { + expect(glyph.baseAttributes[key]).toBe(initial); + glyph.clearStates(false); + expect(child.attribute[key]).toBe(initial); + expectWidths(initialWidth); + } + }); + }); + }); + + test.each(['host', 'derived', 'state'])('%s paint updates preserve warmed geometry caches', writer => { + const glyph = createGlyph({ fill: 'red', fillOpacity: 1 }); + const child = createRect({ width: 10, height: 10 }); + glyph.setSubGraphic([child]); + const graphics = [glyph, child]; + const readGeometry = () => + graphics.map(graphic => ({ + width: graphic.AABBBounds.width(), + x: graphic.transMatrix.e, + y: graphic.transMatrix.f, + boundsUpdates: (graphic as any).updateAABBBoundsStamp + })); + const initialGeometry = readGeometry(); + graphics.forEach(graphic => ((graphic as any)._updateTag = UpdateTag.NONE)); + const paint = { fill: 'blue', fillOpacity: 0.5 }; + + if (writer === 'host') { + glyph.setAttributes(paint); + } else if (writer === 'derived') { + glyph.commitSubGraphicAttributes(child, paint); + } else { + glyph.states = { hover: paint }; + glyph.setStates(['hover'], false); + } + + expect(child.attribute.fill).toBe('blue'); + expect(child.attribute.fillOpacity).toBe(0.5); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + graphics.forEach(graphic => { + expect( + (graphic as any)._updateTag & (UpdateTag.UPDATE_SHAPE_AND_BOUNDS | UpdateTag.UPDATE_GLOBAL_LOCAL_MATRIX) + ).toBe(0); + }); + expect(readGeometry()).toEqual(initialGeometry); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts index 5997c0a72..6384c08b6 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts @@ -217,7 +217,7 @@ describe('Graphic state animation integration', () => { graphic.useStates(['hover'], false); - expect((graphic as any).stopAnimationState).toHaveBeenCalledWith('state', 'end'); + expect((graphic as any).stopAnimationState).toHaveBeenCalledWith('state', undefined); }); test('should allow partial animation config overrides', () => { diff --git a/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts index 8950ed0e5..52434be94 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts @@ -44,6 +44,24 @@ describe('Graphic state update categories', () => { expect(((graphic as any)._updateTag & UpdateTag.UPDATE_PAINT) === UpdateTag.UPDATE_PAINT).toBe(true); }); + test.each(['dx', 'dy'])('should refresh the cached %s transform on state entry and clear', key => { + const graphic = createGraphic(); + const base = { ...graphic.baseAttributes }; + const readOffset = () => (key === 'dx' ? graphic.transMatrix.e : graphic.transMatrix.f); + expect(readOffset()).toBe(0); + graphic.states = { shifted: { [key]: 20 } }; + + graphic.setStates(['shifted'], false); + expect(graphic.attribute[key]).toBe(20); + expect(readOffset()).toBe(20); + expect((graphic as any)._updateTag & UpdateTag.UPDATE_BOUNDS).not.toBe(0); + expect(graphic.baseAttributes).toEqual(base); + + graphic.clearStates(false); + expect(readOffset()).toBe(0); + expect(graphic.baseAttributes).toEqual(base); + }); + test('should dirty cached global bounds for paint-only updates without upgrading to bounds', () => { const graphic = createGraphic(); const graphicServiceHooks = { diff --git a/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts b/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts index 84364003c..16c5fa832 100644 --- a/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts +++ b/packages/vrender-core/__tests__/unit/modules/container-compatibility.test.ts @@ -1,16 +1,11 @@ declare const require: any; export {}; -const fs = require('fs'); const path = require('path'); const process = require('process'); const packageRoot = process.cwd(); -function readArtifact(relativePath: string) { - return fs.readFileSync(path.join(packageRoot, relativePath), 'utf8'); -} - describe('vrender-core container compatibility', () => { test('application should use realm-level shared state for duplicated ESM entry evaluation', () => { const { application } = require(path.join(packageRoot, 'src/application')); @@ -19,17 +14,4 @@ describe('vrender-core container compatibility', () => { expect(state).toBeDefined(); expect(state.application).toBe(application); }); - - test('es artifacts should expose legacy container compatibility surface', () => { - expect(readArtifact('es/modules.js')).toContain('export const container'); - expect(readArtifact('es/modules.d.ts')).toContain('container'); - expect(readArtifact('es/index.d.ts')).toContain("from './modules'"); - expect(readArtifact('es/index.d.ts')).toContain('container'); - }); - - test('cjs root export should expose container as the legacy binding context', () => { - const vrenderCore = require(path.join(packageRoot, 'cjs/index.js')); - - expect(vrenderCore.container).toBe(vrenderCore.getLegacyBindingContext()); - }); }); diff --git a/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts b/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts index e9e698d6c..96cf86347 100644 --- a/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts +++ b/packages/vrender-core/__tests__/unit/public-subpath-exports.test.ts @@ -77,72 +77,4 @@ describe('vrender-core public subpath exports', () => { expect(packageJson.typesVersions?.['*']).toEqual(expectedTypesVersions); }); - - test('keeps BytePack-sensitive root runtime exports explicit in the ESM artifact', () => { - const artifact = fs.readFileSync(path.join(packageRoot, 'es/index.js'), 'utf8'); - const explicitExports = new Set(); - const exportPattern = /export\s+\{([^}]+)\}/g; - let match: RegExpExecArray | null; - - while ((match = exportPattern.exec(artifact))) { - match[1] - .split(',') - .map((item: string) => item.trim()) - .filter(Boolean) - .forEach((specifier: string) => { - const exportedName = specifier.match(/\s+as\s+([A-Za-z0-9_$]+)$/)?.[1] ?? specifier; - explicitExports.add(exportedName.trim()); - }); - } - - const expectedExplicitRuntimeExports = [ - 'CustomEvent', - 'CustomPath2D', - 'GradientParser', - 'IContainPointMode', - 'Symbol', - 'builtInSymbolStrMap', - 'builtinSymbols', - 'builtinSymbolsMap', - 'container', - 'createArc', - 'createArc3d', - 'createArea', - 'createGlyph', - 'createGroup', - 'createImage', - 'createLine', - 'createPath', - 'createPolygon', - 'createPyramid3d', - 'createRect', - 'createRect3d', - 'createRichText', - 'createSymbol', - 'createText', - 'getRichTextBounds', - 'getTextBounds', - 'graphicCreator', - 'isBrowserEnv', - 'mapToCanvasPointForCanvas', - 'matrixAllocate', - 'registerDirectionalLight', - 'registerGlobalEventTransformer', - 'registerHtmlAttributePlugin', - 'registerOrthoCamera', - 'registerReactAttributePlugin', - 'registerViewTransform3dPlugin', - 'registerWindowEventTransformer', - 'transformPointForCanvas', - 'vglobal', - 'waitForAllSubLayers' - ]; - - expect(expectedExplicitRuntimeExports.filter(name => !explicitExports.has(name))).toEqual([]); - - expect(artifact).toContain('export { Symbol, createSymbol } from "./graphic/symbol"'); - expect(artifact).toContain( - 'export { builtInSymbolStrMap, builtinSymbols, builtinSymbolsMap } from "./graphic/builtin-symbol"' - ); - }); }); diff --git a/packages/vrender-core/jest.artifacts.config.js b/packages/vrender-core/jest.artifacts.config.js new file mode 100644 index 000000000..5b0698ca3 --- /dev/null +++ b/packages/vrender-core/jest.artifacts.config.js @@ -0,0 +1,3 @@ +const { createArtifactPackageJestConfig } = require('../../share/jest-config/create-package-config'); + +module.exports = createArtifactPackageJestConfig(); diff --git a/packages/vrender-core/jest.config.js b/packages/vrender-core/jest.config.js index 216f18390..ce06cacae 100644 --- a/packages/vrender-core/jest.config.js +++ b/packages/vrender-core/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -29,17 +28,5 @@ module.exports = createStablePackageJestConfig({ statements: 80 } }, - moduleNameMapper: { - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, './src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - './src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - './src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, './src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, './src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-core/package.json b/packages/vrender-core/package.json index ebba386fe..e48d30767 100644 --- a/packages/vrender-core/package.json +++ b/packages/vrender-core/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-core", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": [ "./src/modules.ts", @@ -59,6 +59,7 @@ "dev": "cross-env DEBUG='Bundler*' bundle --clean -f es -w", "start": "vite ./__tests__/browser", "test": "jest -c jest.config.js", + "test:artifacts": "jest -c jest.artifacts.config.js", "test:electron": "jest -c jest.electron.config.js --runInBand --passWithNoTests --testPathPattern='__tests__/(browser|electron)/'", "test-live": "npm run test-watch __tests__/unit/theme/line.test.ts", "test-watch": "cross-env DEBUG_MODE=1 jest --watch -c jest.config.js", diff --git a/packages/vrender-core/src/common/area-cache.ts b/packages/vrender-core/src/common/area-cache.ts new file mode 100644 index 000000000..b429b1c8a --- /dev/null +++ b/packages/vrender-core/src/common/area-cache.ts @@ -0,0 +1,172 @@ +import { abs, type IPointLike } from '@visactor/vutils'; +import type { IAreaCacheItem, IAreaSegment, ICurveType, IDirection, ISegPath2D } from '../interface'; +import { Direction } from './enums'; +import { calcLineCache } from './segment'; +import type { SegContext } from './seg-context'; +import { LineCurve } from './segment/curve/line'; + +export interface AreaRenderCacheItem extends IAreaCacheItem { + sourceSegmentIndex: number; + direction: IDirection; +} + +/** Select both boundaries together, before interpolation can read undefined coordinates. */ +export function getAreaPointRuns(points: IPointLike[], connectedType: 'none' | 'connect', startPoint?: IPointLike) { + const runs: IPointLike[][] = []; + if (!points.some(p => p.defined === false)) { + const run = startPoint ? [startPoint, ...points] : points; + if (run.length) { + runs.push(run); + } + return { runs, tail: run[run.length - 1] }; + } + + let run: IPointLike[] = startPoint ? [startPoint] : []; + for (let i = 0; i < points.length; i++) { + const point = points[i]; + if (point.defined !== false) { + run.push(point); + } else if (connectedType !== 'connect') { + if (run.length) { + runs.push(run); + } + run = []; + } + } + if (run.length) { + runs.push(run); + } + return { runs, tail: run[run.length - 1] }; +} + +/** Join completed caches without restarting interpolation across a missing-data gap. */ +function joinAreaPaths(paths: ISegPath2D[]): ISegPath2D { + if (paths.length === 1) { + return paths[0]; + } + const curves: ISegPath2D['curves'] = []; + for (let i = 0; i < paths.length; i++) { + const next = paths[i].curves; + if (curves.length) { + const previous = curves[curves.length - 1]; + const gap = new LineCurve(previous.p3 ?? previous.p1, next[0].p0); + gap.defined = false; + gap.originP1 = previous.originP2; + gap.originP2 = next[0].originP1; + curves.push(gap); + } + for (let j = 0; j < next.length; j++) { + curves.push(next[j]); + } + } + // These are completed, read-only drawing caches. Reuse the final context so its + // endX/endY still describe the final curve, without copying curve objects. + const path = paths[paths.length - 1] as SegContext; + path.curves = curves; + path.length = NaN; + return path; +} + +function compileAreaRuns( + runs: IPointLike[][], + curveType: ICurveType, + curveTension: number, + sourceSegmentIndex: number, + topStart?: IPointLike, + bottomStart?: IPointLike +): AreaRenderCacheItem | null { + const tops: ISegPath2D[] = []; + const bottoms: ISegPath2D[] = []; + const bottomType = curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType; + for (let i = 0; i < runs.length; i++) { + const points = runs[i]; + const startPoint = i === 0 ? topStart : undefined; + if (points.length < 2 - Number(!!startPoint)) { + continue; + } + const bottomPoints: IPointLike[] = []; + for (let j = points.length - 1; j >= 0; j--) { + const p = points[j]; + bottomPoints.push({ x: p.x1 ?? p.x, y: p.y1 ?? p.y }); + } + if (i === 0 && bottomStart) { + bottomPoints.push({ x: bottomStart.x1 ?? bottomStart.x, y: bottomStart.y1 ?? bottomStart.y }); + } + // Preserve the curve generators' existing startPoint/closure semantics. In + // particular, don't prepend a styled segment's startPoint to its input array. + const top = calcLineCache(points, curveType, { startPoint, curveTension }); + const bottom = calcLineCache(bottomPoints, bottomType, { curveTension }); + if (top?.curves.length && bottom?.curves.length) { + tops.push(top); + bottoms.push(bottom); + } + } + if (!tops.length) { + return null; + } + return { + top: joinAreaPaths(tops), + bottom: joinAreaPaths(bottoms.reverse()), + sourceSegmentIndex, + direction: Direction.ROW + }; +} + +export function calcAreaCache( + points: IPointLike[] | undefined, + segments: IAreaSegment[] | undefined, + curveType: ICurveType, + connectedType: 'none' | 'connect', + curveTension: number +): AreaRenderCacheItem | AreaRenderCacheItem[] | null { + const caches: AreaRenderCacheItem[] = []; + let tail: IPointLike; + let topTail: IPointLike; + let first: IPointLike; + let last: IPointLike; + const count = segments ? segments.length : 1; + for (let i = 0; i < count; i++) { + const segmentPoints = segments ? segments[i].points : points ?? []; + const result = getAreaPointRuns(segmentPoints, connectedType); + const canContinue = connectedType === 'connect' || segmentPoints[0]?.defined !== false; + if (result.runs.length) { + first = first ?? result.runs[0][0]; + const lastRun = result.runs[result.runs.length - 1]; + last = lastRun[lastRun.length - 1]; + } + const cache = compileAreaRuns( + result.runs, + curveType, + curveTension, + i, + canContinue ? topTail : undefined, + canContinue ? tail : undefined + ); + if (cache) { + caches.push(cache); + } + if (result.tail) { + tail = result.tail; + const lastRun = result.runs[result.runs.length - 1]; + topTail = + cache && (result.runs.length === 1 || lastRun.length > 1) ? { x: cache.top.endX, y: cache.top.endY } : tail; + } else if (connectedType !== 'connect' && segmentPoints.length) { + tail = topTail = undefined; + } + } + if (!caches.length) { + return null; + } + let direction = Direction.ROW; + if (last.x1 != null) { + const dx = abs(last.x - first.x); + const dy = abs(last.y - first.y); + if (last.y1 == null || (Number.isFinite(dx + dy) && dy >= dx)) { + direction = Direction.COLUMN; + } + } + for (let i = 0; i < caches.length; i++) { + caches[i].direction = direction; + } + return segments ? caches : caches[0]; +} diff --git a/packages/vrender-core/src/common/render-curve.ts b/packages/vrender-core/src/common/render-curve.ts index 6c870ab9a..c83f7e4fa 100644 --- a/packages/vrender-core/src/common/render-curve.ts +++ b/packages/vrender-core/src/common/render-curve.ts @@ -11,6 +11,7 @@ import type { } from '../interface'; import { Direction } from './enums'; import { drawSegItem } from './render-utils'; +import { getAreaPointRuns } from './area-cache'; function drawEachCurve( path: IPath2D, @@ -212,40 +213,29 @@ export function drawIncrementalAreaSegments( params?: { offsetX?: number; offsetY?: number; + connectedType?: 'none' | 'connect'; + startPoint?: IPointLike; } ) { - const { offsetX = 0, offsetY = 0 } = params || {}; - const { points } = segments; - // 分段 - const definedPointsList: IPointLike[][] = []; - let lastIdx = 0; - for (let i = 0; i < points.length; i++) { - if (points[i].defined === false) { - if (lastIdx + 1 !== i) { - definedPointsList.slice(lastIdx, i); - } - lastIdx = i; + const { offsetX = 0, offsetY = 0, connectedType = 'none' } = params || {}; + const startPoint = + params && 'startPoint' in params + ? params.startPoint + : lastSeg && getAreaPointRuns(lastSeg.points, connectedType).tail; + const { runs } = getAreaPointRuns(segments.points, connectedType, startPoint); + for (let i = 0; i < runs.length; i++) { + const points = runs[i]; + if (points.length < 2) { + continue; } - } - definedPointsList.length === 0; - definedPointsList.push(points); - definedPointsList.forEach((points, i) => { - const startP = lastSeg && i === 0 ? lastSeg.points[lastSeg.points.length - 1] : points[0]; - path.moveTo(startP.x + offsetX, startP.y + offsetY); - // 绘制上层 - points.forEach(p => { - if (p.defined === false) { - path.moveTo(p.x + offsetX, p.y + offsetY); - return; - } - path.lineTo(p.x + offsetX, p.y + offsetY); - }); - // 绘制下层 - for (let i = points.length - 1; i >= 0; i--) { - const p = points[i]; + path.moveTo(points[0].x + offsetX, points[0].y + offsetY); + for (let j = 1; j < points.length; j++) { + path.lineTo(points[j].x + offsetX, points[j].y + offsetY); + } + for (let j = points.length - 1; j >= 0; j--) { + const p = points[j]; path.lineTo(p.x1 ?? p.x, p.y1 ?? p.y); } - path.lineTo(startP.x1 ?? startP.x, startP.y1 ?? startP.y); path.closePath(); - }); + } } diff --git a/packages/vrender-core/src/graphic/area.ts b/packages/vrender-core/src/graphic/area.ts index 391da50be..cb6894257 100644 --- a/packages/vrender-core/src/graphic/area.ts +++ b/packages/vrender-core/src/graphic/area.ts @@ -7,7 +7,14 @@ import { getTheme } from './theme'; import { application } from '../application'; import { AREA_NUMBER_TYPE } from './constants'; -const AREA_UPDATE_TAG_KEY = ['segments', 'points', 'curveType', 'curveTension', ...GRAPHIC_UPDATE_TAG_KEY]; +const AREA_UPDATE_TAG_KEY = [ + 'segments', + 'points', + 'curveType', + 'curveTension', + 'connectedType', + ...GRAPHIC_UPDATE_TAG_KEY +]; export class Area extends Graphic implements IArea { type: 'area' = 'area'; @@ -88,6 +95,9 @@ export class Area extends Graphic implements IArea { const { points = areaTheme.points } = attribute; const b = aabbBounds; points.forEach(p => { + if (p.defined === false) { + return; + } b.add(p.x, p.y); b.add(p.x1 ?? p.x, p.y1 ?? p.y); //面积图特殊性:由三个值构成,横向面积图,x1会省略;纵向面积图,y1会省略 }); @@ -103,6 +113,9 @@ export class Area extends Graphic implements IArea { const b = aabbBounds; segments.forEach(s => { s.points.forEach(p => { + if (p.defined === false) { + return; + } b.add(p.x, p.y); b.add(p.x1 ?? p.x, p.y1 ?? p.y); //面积图特殊性:由三个值构成,横向面积图,x1会省略;纵向面积图,y1会省略 }); diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index 14a629996..a945f7536 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -1,4 +1,4 @@ -import type { AABBBounds, IAABBBounds, IPointLike } from '@visactor/vutils'; +import type { AABBBounds, IAABBBounds } from '@visactor/vutils'; import { Graphic, NOWORK_ANIMATE_ATTR } from './graphic'; import type { GraphicType, @@ -8,7 +8,11 @@ import type { IGraphicAttribute, ISetAttributeContext } from '../interface'; +import { StateDefinitionCompiler } from './state/state-definition-compiler'; +import type { CompiledStateDefinition, StateDefinition, StateDefinitionsInput } from './state/state-definition'; +import type { SharedStateScope } from './state/shared-state-scope'; import { getTheme } from './theme'; +import { ATTRIBUTE_CATEGORY, UpdateCategory } from './state/attribute-update-classifier'; import { GLYPH_NUMBER_TYPE } from './constants'; export class Glyph extends Graphic implements IGlyph { @@ -30,6 +34,11 @@ export class Glyph extends Graphic implements IGlyph { subAttributes: Partial[]; }; protected declare subGraphic: IGraphic[]; + private subGraphicEncoder?: (g: IGlyph, context?: ISetAttributeContext) => void; + private legacyDefinitionsSource?: Glyph['glyphStates']; + private legacyProxySource?: Glyph['glyphStateProxy']; + private legacyDefinitions?: StateDefinitionsInput; + private legacyCompiledDefinitions?: Map>; static NOWORK_ANIMATE_ATTR = NOWORK_ANIMATE_ATTR; @@ -46,16 +55,17 @@ export class Glyph extends Graphic implements IGlyph { this.subGraphic = subGraphic; subGraphic.forEach(g => { g.glyphHost = this; - Object.setPrototypeOf(g.attribute, this.attribute); + Graphic.bindGlyphAttributes(g as Graphic, this.attribute); }); this.valid = this.isValid(); this.addUpdateBoundTag(); + this.subGraphicEncoder?.(this); } protected detachSubGraphic() { this.subGraphic.forEach(g => { g.glyphHost = null; - Object.setPrototypeOf(g.attribute, {}); + Graphic.bindGlyphAttributes(g as Graphic, Object.prototype); }); } @@ -75,84 +85,71 @@ export class Glyph extends Graphic implements IGlyph { return true; } - setAttribute(key: string, value: any, forceUpdateTag?: boolean, context?: ISetAttributeContext) { - super.setAttribute(key, value, forceUpdateTag, context); - this.subGraphic.forEach(g => { - g.addUpdateShapeAndBoundsTag(); - g.addUpdatePositionTag(); - }); + setSubGraphicEncoder(encoder?: (g: IGlyph, context?: ISetAttributeContext) => void): void { + this.subGraphicEncoder = encoder; + encoder?.(this); } - setAttributes( - params: Partial, - forceUpdateTag: boolean = false, + commitSubGraphicAttributes( + subGraphic: IGraphic, + patch: Record, + removedKeys?: readonly string[], context?: ISetAttributeContext - ) { - super.setAttributes(params, forceUpdateTag, context); - this.subGraphic.forEach(g => { - g.addUpdateShapeAndBoundsTag(); - g.addUpdatePositionTag(); - }); - } - - translate(x: number, y: number) { - super.translate(x, y); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; - } - - translateTo(x: number, y: number) { - super.translateTo(x, y); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + ): void { + Graphic.commitDerivedAttributePatch(subGraphic as Graphic, patch, removedKeys, context); } - scale(scaleX: number, scaleY: number, scaleCenter?: IPointLike) { - super.scale(scaleX, scaleY, scaleCenter); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + onAttributeUpdate(context?: ISetAttributeContext): void { + if (this.glyphHost) { + Graphic.bindGlyphAttributes(this, this.glyphHost.attribute); + } + for (const child of this.subGraphic) { + Graphic.bindGlyphAttributes(child as Graphic, this.attribute); + } + this.subGraphicEncoder?.(this, context); + if (!context?.skipUpdateCallback) { + this._onUpdate?.(this); + } + super.onAttributeUpdate(context); } - scaleTo(scaleX: number, scaleY: number) { - super.scaleTo(scaleX, scaleY); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + protected submitUpdateByCategory(category: UpdateCategory, forceUpdateTag: boolean = false): void { + super.submitUpdateByCategory(category, forceUpdateTag); + for (const child of this.subGraphic) { + if (forceUpdateTag || category & UpdateCategory.SHAPE) { + child.addUpdateShapeAndBoundsTag(); + } else if (category & UpdateCategory.BOUNDS) { + child.addUpdateBoundTag(); + } + if (category & UpdateCategory.PAINT) { + child.addUpdatePaintTag(); + } + if (forceUpdateTag || category & UpdateCategory.TRANSFORM) { + child.addUpdatePositionTag(); + } + if (forceUpdateTag || category & UpdateCategory.LAYOUT) { + child.addUpdateLayoutTag(); + } + } } - rotate(angle: number) { - super.rotate(angle); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + // Glyph forwards inherited invalidation to children, so its base fast path must + // classify changed keys too. Ordinary Graphic setters keep their existing path. + protected commitBaseAttributesByTouchedKeys( + params: Partial, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + this.commitBaseAttributesByCategory(params, forceUpdateTag, context); } - rotateTo(angle: number) { - super.rotate(angle); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + protected commitBaseAttributeBySingleKey( + key: string, + value: any, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + this.commitBaseAttributesByTouchedKeys({ [key]: value }, forceUpdateTag, context); } getGraphicTheme(): Required { @@ -183,60 +180,94 @@ export class Glyph extends Graphic implements IGlyph { } protected needUpdateTags(keys: string[]): boolean { + for (const key of keys) { + if (this.needUpdateTag(key)) { + return true; + } + } return false; } protected needUpdateTag(key: string): boolean { + if (ATTRIBUTE_CATEGORY[key] === UpdateCategory.PAINT) { + return false; + } + for (const child of this.subGraphic) { + if (Graphic.needsShapeUpdate(child as Graphic, key)) { + return true; + } + } return false; } - useStates(states: string[], hasAnimation?: boolean): void { - if (!states.length) { - this.clearStates(hasAnimation); - return; + protected hasLegacyStateDefinitions(): boolean { + if (this.glyphStateProxy) { + return true; } - const previousStates = this.currentStates ? this.currentStates.slice() : []; - - const isChange = - this.currentStates?.length !== states.length || - states.some((stateName, index) => this.currentStates[index] !== stateName); - if (!isChange) { - return; + for (const name in this.glyphStates) { + if (Object.prototype.hasOwnProperty.call(this.glyphStates, name)) { + return true; + } } + return false; + } - this.stopStateAnimates(); + protected syncSharedStateScopeBindingFromTree( + markDirty: boolean = true, + inheritedSharedStateScope?: SharedStateScope> | null + ): boolean { + // Legacy Glyph definitions historically own the whole state surface. + return this.hasLegacyStateDefinitions() + ? this.syncSharedStateScopeBinding(undefined, markDirty) + : super.syncSharedStateScopeBindingFromTree(markDirty, inheritedSharedStateScope); + } - if (this.stateSort) { - states = states.sort(this.stateSort); + protected resolveEffectiveCompiledDefinitions(stateNames: readonly string[] = []) { + if (!this.hasLegacyStateDefinitions()) { + this.legacyDefinitions = undefined; + this.legacyCompiledDefinitions = undefined; + return super.resolveEffectiveCompiledDefinitions(stateNames); } - const stateAttrs = {}; - states.forEach(stateName => { - const attrs = this.glyphStateProxy ? this.glyphStateProxy(stateName, states) : this.glyphStates[stateName]; - - if (attrs) { - Object.assign(stateAttrs, attrs.attributes); + this.syncSharedStateScopeBindingFromTree(false); + let changed = false; + if ( + !this.legacyDefinitions || + this.legacyDefinitionsSource !== this.glyphStates || + this.legacyProxySource !== this.glyphStateProxy + ) { + this.legacyDefinitionsSource = this.glyphStates; + this.legacyProxySource = this.glyphStateProxy; + this.legacyDefinitions = {}; + for (const name of Object.keys(this.glyphStates ?? {})) { + this.legacyDefinitions[name] = this.createLegacyStateDefinition(name); } - }); - - if (!this.beforeStateUpdate(stateAttrs, previousStates, states, hasAnimation, false)) { - return; + changed = true; } - - this.currentStates = states; - this.applyStateAttrs(stateAttrs, states, hasAnimation); + if (this.glyphStateProxy) { + const addDefinition = (name: string) => { + if (!Object.prototype.hasOwnProperty.call(this.legacyDefinitions, name)) { + this.legacyDefinitions[name] = this.createLegacyStateDefinition(name); + changed = true; + } + }; + this.currentStates?.forEach(addDefinition); + stateNames.forEach(addDefinition); + } + if (changed) { + this.legacyCompiledDefinitions = new StateDefinitionCompiler().compile( + this.legacyDefinitions + ); + } + return { compiledDefinitions: this.legacyCompiledDefinitions, stateOrder: 'input' as const }; } - clearStates(hasAnimation?: boolean) { - this.stopStateAnimates(); - const previousStates = this.currentStates ? this.currentStates.slice() : []; - if (this.hasState() && this.normalAttrs) { - if (!this.beforeStateUpdate(this.normalAttrs, previousStates, [], hasAnimation, true)) { - return; - } - this.currentStates = []; - this.applyStateAttrs(this.normalAttrs, this.currentStates, hasAnimation, true); - } else { - this.currentStates = []; - } + private createLegacyStateDefinition(name: string): StateDefinition { + return this.glyphStateProxy + ? { + name, + resolver: ({ graphic, activeStates }) => + (graphic as Glyph).glyphStateProxy(name, activeStates as string[])?.attributes + } + : { name, patch: this.glyphStates[name].attributes }; } clone(): IGraphic> { @@ -245,6 +276,19 @@ export class Glyph extends Graphic implements IGlyph { return glyph; } + release(): void { + super.release(); + this.subGraphicEncoder = undefined; + this._onUpdate = undefined; + this.legacyDefinitions = undefined; + this.legacyCompiledDefinitions = undefined; + this.legacyDefinitionsSource = undefined; + this.legacyProxySource = undefined; + this.detachSubGraphic(); + this.subGraphic.forEach(child => child.release()); + this.subGraphic = []; + } + getNoWorkAnimateAttr(): Record { return Glyph.NOWORK_ANIMATE_ATTR; } diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index e6d184533..53679d98c 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -463,6 +463,7 @@ export abstract class Graphic = Partial; protected stateEngineCompiledDefinitions?: Map>; protected stateEngineStateSort?: (stateA: string, stateB: string) => number; + protected stateEngineStateOrder?: 'input'; protected stateEngineMergeMode?: StateMergeMode; protected stateTransitionOrchestrator?: StateTransitionOrchestrator; protected localStateDefinitionsSource?: StateDefinitionsInput; @@ -686,8 +687,9 @@ export abstract class Graphic = Partial>; + stateOrder?: 'input'; } { this.syncSharedStateScopeBindingFromTree(false); const boundScope = this.boundSharedStateScope; @@ -976,11 +978,47 @@ export abstract class Graphic = Partial, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + const base = this.getBaseAttributesStorage(); + let category = UpdateCategory.NONE; + let hasKeys = false; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) { + continue; + } + hasKeys = true; + const prev = (base as any)[key]; + const next = (params as any)[key]; + if (prev !== next) { + category = this.mergeAttributeDeltaCategory(category, key, prev, next); + } + (base as any)[key] = next; + } + if (!hasKeys) { + return; + } + this.attribute = base as T; + this._baseAttributes = undefined; + this.attributeMayContainTransientAttrs = false; + this.valid = this.isValid(); + this.submitUpdateByCategory(category, forceUpdateTag); + this.onAttributeUpdate(context); + } + protected commitBaseAttributesByTouchedKeys( params: Partial, forceUpdateTag: boolean = false, context?: ISetAttributeContext ): void { + if (this.glyphHost) { + this.commitBaseAttributesByCategory(params, forceUpdateTag, context); + return; + } const source = params as Record; const baseAttributes = this.getBaseAttributesStorage() as Record; let hasKeys = false; @@ -1819,6 +1857,11 @@ export abstract class Graphic = Partial = Partial, + removedKeys?: readonly string[], + context?: ISetAttributeContext + ): void { + if (!removedKeys?.length) { + graphic.setAttributes(patch, false, context); + return; + } + graphic.detachAttributeFromBaseAttributes(); + const base = graphic.getBaseAttributesStorage() as Record; + removedKeys.forEach(key => delete base[key]); + graphic.applyBaseAttributes(patch); + graphic.commitBaseAttributeMutation(false, context); + } + onAttributeUpdate(context?: ISetAttributeContext) { + if (this.glyphHost) { + Graphic.bindGlyphAttributes(this, this.glyphHost.attribute); + } if (context && context.skipUpdateCallback) { return; } @@ -2082,8 +2156,11 @@ export abstract class Graphic = Partial = this.getStateResolveBaseAttrs()) { - const { compiledDefinitions } = this.resolveEffectiveCompiledDefinitions(); + protected ensureStateEngine( + stateResolveBaseAttrs: Partial = this.getStateResolveBaseAttrs(), + stateNames: readonly string[] = this.currentStates ?? EMPTY_STATE_NAMES + ) { + const { compiledDefinitions, stateOrder } = this.resolveEffectiveCompiledDefinitions(stateNames); this.compiledStateDefinitions = compiledDefinitions; if (!compiledDefinitions) { @@ -2093,15 +2170,18 @@ export abstract class Graphic = Partial({ compiledDefinitions, stateSort: this.stateSort, + stateOrder, mergeMode: this.stateMergeMode }); this.stateEngineCompiledDefinitions = compiledDefinitions; this.stateEngineStateSort = this.stateSort; + this.stateEngineStateOrder = stateOrder; this.stateEngineMergeMode = this.stateMergeMode; } @@ -2157,7 +2237,7 @@ export abstract class Graphic = Partial = this.getStateResolveBaseAttrs() ): GraphicStateTransition { - const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs); + const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs, states); return stateEngine ? this.toGraphicStateTransition(stateEngine.applyStates(states)) : this.resolveLocalUseStatesTransition(states); @@ -2171,7 +2251,7 @@ export abstract class Graphic = Partial = Partial = Partial { const stateResolveBaseAttrs = this.getStateResolveBaseAttrs(); - const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs); + const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs, states); if (forceResolverRefresh) { stateEngine?.invalidateResolverCache(); } @@ -2432,7 +2512,9 @@ export abstract class Graphic = Partial implements ILine { aabbBounds: IAABBBounds, graphic?: ILine ): IAABBBounds { - const { points = lineTheme.points, connectedType } = attribute; + const { points = lineTheme.points } = attribute; const b = aabbBounds; points.forEach(p => { - if (p.defined !== false || connectedType === 'connect') { + if (p.defined !== false) { b.add(p.x, p.y); } }); @@ -97,11 +97,11 @@ export class Line extends Graphic implements ILine { aabbBounds: IAABBBounds, graphic?: ILine ): IAABBBounds { - const { segments = lineTheme.segments, connectedType } = attribute; + const { segments = lineTheme.segments } = attribute; const b = aabbBounds; segments.forEach(s => { s.points.forEach(p => { - if (p.defined !== false || connectedType === 'connect') { + if (p.defined !== false) { b.add(p.x, p.y); } }); diff --git a/packages/vrender-core/src/graphic/state/attribute-update-classifier.ts b/packages/vrender-core/src/graphic/state/attribute-update-classifier.ts index 33f854b47..a05275420 100644 --- a/packages/vrender-core/src/graphic/state/attribute-update-classifier.ts +++ b/packages/vrender-core/src/graphic/state/attribute-update-classifier.ts @@ -23,6 +23,8 @@ export const ATTRIBUTE_CATEGORY: Record = { shadowColor: UpdateCategory.PAINT, x: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, y: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, + dx: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, + dy: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, scaleX: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, scaleY: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, angle: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, diff --git a/packages/vrender-core/src/graphic/state/state-definition.ts b/packages/vrender-core/src/graphic/state/state-definition.ts index df70c2ded..3874bdd63 100644 --- a/packages/vrender-core/src/graphic/state/state-definition.ts +++ b/packages/vrender-core/src/graphic/state/state-definition.ts @@ -46,5 +46,7 @@ export type StateDefinitionsInput = Record = Record> { compiledDefinitions: Map>; stateSort?: (a: string, b: string) => number; + /** @internal Legacy Glyph inputs merge in requested order, after stateSort. */ + stateOrder?: 'input'; mergeMode?: StateMergeMode; } diff --git a/packages/vrender-core/src/graphic/state/state-engine.ts b/packages/vrender-core/src/graphic/state/state-engine.ts index 1046f428e..6434c8054 100644 --- a/packages/vrender-core/src/graphic/state/state-engine.ts +++ b/packages/vrender-core/src/graphic/state/state-engine.ts @@ -37,6 +37,7 @@ function deepMerge(base: Record, value: Record): Recor export class StateEngine = Record> { private readonly compiledDefinitions: Map>; private readonly stateSort?: (a: string, b: string) => number; + private readonly stateOrder?: 'input'; private readonly mergeMode: 'shallow' | 'deep'; private _activeStates: string[] = []; @@ -52,6 +53,7 @@ export class StateEngine = Record> { constructor(options: IStateEngineOptions) { this.compiledDefinitions = options.compiledDefinitions; this.stateSort = options.stateSort; + this.stateOrder = options.stateOrder; this.mergeMode = options.mergeMode ?? 'shallow'; } @@ -195,6 +197,9 @@ export class StateEngine = Record> { } private sortStates(states: string[]): string[] { + if (this.stateOrder === 'input') { + return this.stateSort ? states.sort(this.stateSort) : states; + } const withDefinition: string[] = []; const withoutDefinition: string[] = []; diff --git a/packages/vrender-core/src/interface/graphic/glyph.ts b/packages/vrender-core/src/interface/graphic/glyph.ts index d55f0b954..1ec22c454 100644 --- a/packages/vrender-core/src/interface/graphic/glyph.ts +++ b/packages/vrender-core/src/interface/graphic/glyph.ts @@ -1,4 +1,4 @@ -import type { IGraphicAttribute, IGraphic } from '../graphic'; +import type { IGraphicAttribute, IGraphic, ISetAttributeContext } from '../graphic'; import type { ICustomPath2D } from '../path'; // glyph是一种图元,这种图元组合了各种其他图元 @@ -39,5 +39,17 @@ export interface IGlyph = Partial void) => void; + /** Observes committed attributes after derived children are synchronized. Honors skipUpdateCallback. */ onUpdate: (cb: (g: this) => void) => void; + + /** Bind after children/context are ready. Runs once immediately and after each host attribute commit. */ + setSubGraphicEncoder: (encoder?: (g: IGlyph, context?: ISetAttributeContext) => void) => void; + + /** Atomically apply derived values and remove owned keys, preserving child state/base truth. */ + commitSubGraphicAttributes: ( + subGraphic: IGraphic, + patch: Record, + removedKeys?: readonly string[], + context?: ISetAttributeContext + ) => void; } diff --git a/packages/vrender-core/src/render/contributions/render/area-render.ts b/packages/vrender-core/src/render/contributions/render/area-render.ts index 9176d088a..6affdf48a 100644 --- a/packages/vrender-core/src/render/contributions/render/area-render.ts +++ b/packages/vrender-core/src/render/contributions/render/area-render.ts @@ -1,5 +1,4 @@ -import type { IPointLike } from '@visactor/vutils'; -import { abs, isArray, min } from '@visactor/vutils'; +import { isArray, min } from '@visactor/vutils'; import type { IArea, IAreaCacheItem, @@ -8,7 +7,6 @@ import type { IContext2d, IMarkAttribute, IThemeAttribute, - ISegPath2D, IAreaRenderContribution, IDrawContext, IRenderService, @@ -16,7 +14,7 @@ import type { IGraphicRenderDrawParams, IContributionProvider } from '../../../interface'; -import { calcLineCache } from '../../../common/segment'; +import { calcAreaCache, type AreaRenderCacheItem } from '../../../common/area-cache'; import { getTheme } from '../../../graphic/theme'; import { AreaRenderContribution } from './contributions/constants'; @@ -204,13 +202,6 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph curveType = 'linearClosed'; } - function parsePoint(points: IPointLike[], connectedType: 'none' | 'connect') { - if (connectedType !== 'connect') { - return points; - } - return points.filter(p => p.defined !== false); - } - if (clipRange === 1 && !segments && !points.some(p => p.defined === false) && curveType === 'linear') { return this.drawLinearAreaHighPerformance( area, @@ -229,105 +220,19 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph ); } - // 更新cache if (area.shouldUpdateShape()) { - if (segments && segments.length) { - let startPoint: IPointLike; - let lastTopSeg: { endX: number; endY: number }; - const topCaches = segments - .map((seg, index) => { - if (seg.points.length <= 1) { - // 第一个点的话,直接设置lastTopSeg - if (index === 0) { - seg.points[0] && (lastTopSeg = { endX: seg.points[0].x, endY: seg.points[0].y }); - return null; - } - } - // 添加上一个segment结束的点作为这个segment的起始点 - if (index === 1) { - startPoint = { x: lastTopSeg.endX, y: lastTopSeg.endY }; - } else if (index > 1) { - startPoint.x = lastTopSeg.endX; - startPoint.y = lastTopSeg.endY; - } - const data = calcLineCache(parsePoint(seg.points, connectedType), curveType, { - startPoint, - curveTension - }); - lastTopSeg = data; - return data; - }) - .filter(item => !!item); - let lastBottomSeg: ISegPath2D; - const bottomCaches = []; - for (let i = segments.length - 1; i >= 0; i--) { - const points = segments[i].points; - const bottomPoints: IPointLike[] = []; - for (let i = points.length - 1; i >= 0; i--) { - bottomPoints.push({ - x: points[i].x1 ?? points[i].x, - y: points[i].y1 ?? points[i].y - }); - } - // 处理一下bottom的segments,bottom的segments需要手动添加endPoints - if (i !== 0) { - const lastSegmentPoints = segments[i - 1].points; - const endPoint = lastSegmentPoints[lastSegmentPoints.length - 1]; - endPoint && - bottomPoints.push({ - x: endPoint.x1 ?? endPoint.x, - y: endPoint.y1 ?? endPoint.y - }); - } - if (bottomPoints.length > 1) { - lastBottomSeg = calcLineCache( - parsePoint(bottomPoints, connectedType), - curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType, - { curveTension } - ); - bottomCaches.unshift(lastBottomSeg); - } - } - area.cacheArea = bottomCaches.map((item, index) => ({ - top: topCaches[index], - bottom: item - })); - } else if (points && points.length) { - // 转换points - const topPoints = parsePoint(points, connectedType); - const bottomPoints: IPointLike[] = []; - for (let i = topPoints.length - 1; i >= 0; i--) { - bottomPoints.push({ - x: topPoints[i].x1 ?? topPoints[i].x, - y: topPoints[i].y1 ?? topPoints[i].y - }); - } - const topCache = calcLineCache(topPoints, curveType, { curveTension }); - const bottomCache = calcLineCache( - bottomPoints, - curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType, - { curveTension } - ); - - area.cacheArea = { top: topCache, bottom: bottomCache }; - } else { - area.cacheArea = null; - area.clearUpdateShapeTag(); - return; - } + area.cacheArea = calcAreaCache(points, segments, curveType, connectedType, curveTension); area.clearUpdateShapeTag(); } + if (!area.cacheArea) { + return; + } if (Array.isArray(area.cacheArea)) { - const segments = area.attribute.segments.filter(item => item.points.length); - // 如果第一个seg只有一个点,那么shift出去 - if (segments[0].points.length === 1) { - segments.shift(); - } if (clipRange === 1) { let skip = false; // 性能优化,不需要clip的线段不需要计算长度 - area.cacheArea.forEach((cache, index) => { + area.cacheArea.forEach(cache => { if (skip) { return; } @@ -338,7 +243,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph fillOpacity, doStroke, strokeOpacity, - segments[index], + segments[(cache as AreaRenderCacheItem).sourceSegmentIndex], [areaAttribute, area.attribute], clipRange, x, @@ -359,7 +264,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph // 直到上次绘制的长度 let drawedLengthUntilLast = 0; let skip = false; - area.cacheArea.forEach((cache, index) => { + area.cacheArea.forEach(cache => { if (skip) { return; } @@ -374,7 +279,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph fillOpacity, doStroke, strokeOpacity, - segments[index], + segments[(cache as AreaRenderCacheItem).sourceSegmentIndex], [areaAttribute, area.attribute], min(_cr, 1), x, @@ -519,30 +424,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph context.beginPath(); const ret: boolean = false; - const { points, segments } = area.attribute; - let direction = Direction.ROW; - let endP: IPointLike; - let startP: IPointLike; - if (segments) { - const endSeg = segments[segments.length - 1]; - const startSeg = segments[0]; - startP = startSeg.points[0]; - endP = endSeg.points[endSeg.points.length - 1]; - } else { - startP = points[0]; - endP = points[points.length - 1]; - } - const xTotalLength = abs(endP.x - startP.x); - const yTotalLength = abs(endP.y - startP.y); - if (endP.x1 == null) { - direction = Direction.ROW; - } else if (endP.y1 == null) { - direction = Direction.COLUMN; - } else if (!Number.isFinite(xTotalLength + yTotalLength)) { - direction = Direction.ROW; - } else { - direction = xTotalLength > yTotalLength ? Direction.ROW : Direction.COLUMN; - } + const direction = (cache as AreaRenderCacheItem).direction ?? cache.top.direction; drawAreaSegments(context, cache, clipRange, { offsetX, offsetY, diff --git a/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts b/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts index 17e713671..00e2be9c3 100644 --- a/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts +++ b/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts @@ -1,3 +1,4 @@ +import type { IPointLike } from '@visactor/vutils'; import type { IArea, IAreaGraphicAttribute, @@ -15,6 +16,23 @@ import { getTheme } from '../../../graphic/theme'; import { fillVisible, runFill } from './utils'; import { DefaultCanvasAreaRender } from './area-render'; import { drawIncrementalAreaSegments } from '../../../common/render-curve'; +import { getAreaPointRuns } from '../../../common/area-cache'; + +function previousPoint(segments: IAreaSegment[], index: number, connectedType: 'none' | 'connect') { + for (let i = index - 1; i >= 0; i--) { + const points = segments[i].points; + for (let j = points.length - 1; j >= 0; j--) { + const point = points[j]; + if (point.defined !== false) { + return point; + } + if (connectedType !== 'connect') { + return undefined; + } + } + } + return undefined; +} /** * 默认的基于canvas的line渲染器 @@ -48,7 +66,8 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender fill = areaAttribute.fill, fillOpacity = areaAttribute.fillOpacity, opacity = areaAttribute.opacity, - visible = areaAttribute.visible + visible = areaAttribute.visible, + connectedType = areaAttribute.connectedType } = area.attribute; // 不绘制或者透明 const fVisible = fillVisible(opacity, fillOpacity, fill); @@ -68,7 +87,12 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender } // 不支持clipRange,不支持pick,仅支持最基础的线段绘制 - for (let i = startAtIdx; i < startAtIdx + length; i++) { + const endIndex = Math.min(startAtIdx + length, segments.length); + for (let i = startAtIdx; i < endIndex; i++) { + // Empty batches draw nothing and must not repeatedly search the same prefix. + if (!segments[i].points.some(p => p.defined !== false)) { + continue; + } this.drawIncreaseSegment( area, context, @@ -77,7 +101,8 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender area.attribute.segments[i], [areaAttribute, area.attribute], x, - y + y, + { connectedType, startPoint: previousPoint(segments, i, connectedType) } ); } } else { @@ -93,16 +118,24 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender attribute: Partial, defaultAttribute: Required | Partial[], offsetX: number, - offsetY: number + offsetY: number, + continuity?: { connectedType: 'none' | 'connect'; startPoint?: IPointLike } ) { if (!seg) { return; } + const connectedType = + continuity?.connectedType ?? area.attribute.connectedType ?? getTheme(area).area.connectedType; + const startPoint = continuity + ? continuity.startPoint + : lastSeg && getAreaPointRuns(lastSeg.points, connectedType).tail; context.beginPath(); drawIncrementalAreaSegments(context.camera ? context : context.nativeContext, lastSeg, seg, { offsetX, - offsetY + offsetY, + connectedType, + startPoint }); // shadow diff --git a/packages/vrender-kits/CHANGELOG.json b/packages/vrender-kits/CHANGELOG.json index 6cffb6552..57a14a7fc 100644 --- a/packages/vrender-kits/CHANGELOG.json +++ b/packages/vrender-kits/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender-kits", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender-kits_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender-kits_v1.1.10", diff --git a/packages/vrender-kits/CHANGELOG.md b/packages/vrender-kits/CHANGELOG.md index 6e5a18dc1..25eca1dde 100644 --- a/packages/vrender-kits/CHANGELOG.md +++ b/packages/vrender-kits/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender-kits -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender-kits/__tests__/unit/build-artifact-imports.test.ts b/packages/vrender-kits/__tests__/artifacts/build-artifact-imports.test.ts similarity index 94% rename from packages/vrender-kits/__tests__/unit/build-artifact-imports.test.ts rename to packages/vrender-kits/__tests__/artifacts/build-artifact-imports.test.ts index 1b44b0ceb..6f8354ac0 100644 --- a/packages/vrender-kits/__tests__/unit/build-artifact-imports.test.ts +++ b/packages/vrender-kits/__tests__/artifacts/build-artifact-imports.test.ts @@ -51,10 +51,6 @@ const forbiddenCoreRootRuntimeImports = [ function collectArtifactFiles(relativeDir: string): string[] { const absoluteDir = path.join(packageRoot, relativeDir); - if (!fs.existsSync(absoluteDir)) { - return []; - } - const entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); return entries.flatMap((entry: { isDirectory: () => boolean; name: string }) => { const relativePath = path.join(relativeDir, entry.name); @@ -69,12 +65,14 @@ function collectArtifactFiles(relativeDir: string): string[] { describe('vrender-kits published artifacts', () => { test('should not reference workspace source directories', () => { - const offenders = buildRoots.flatMap(buildRoot => - collectArtifactFiles(buildRoot).flatMap(relativePath => { + const offenders = buildRoots.flatMap(buildRoot => { + const artifactFiles = collectArtifactFiles(buildRoot); + expect(artifactFiles.length).toBeGreaterThan(0); + return artifactFiles.flatMap(relativePath => { const artifact = fs.readFileSync(path.join(packageRoot, relativePath), 'utf8'); return forbiddenWorkspaceSourcePatterns.some(pattern => pattern.test(artifact)) ? [relativePath] : []; - }) - ); + }); + }); expect(offenders).toEqual([]); }); diff --git a/packages/vrender-kits/__tests__/unit/root-installer-exports.test.ts b/packages/vrender-kits/__tests__/artifacts/root-installer-exports.test.ts similarity index 100% rename from packages/vrender-kits/__tests__/unit/root-installer-exports.test.ts rename to packages/vrender-kits/__tests__/artifacts/root-installer-exports.test.ts diff --git a/packages/vrender-kits/jest.artifacts.config.js b/packages/vrender-kits/jest.artifacts.config.js new file mode 100644 index 000000000..5b0698ca3 --- /dev/null +++ b/packages/vrender-kits/jest.artifacts.config.js @@ -0,0 +1,3 @@ +const { createArtifactPackageJestConfig } = require('../../share/jest-config/create-package-config'); + +module.exports = createArtifactPackageJestConfig(); diff --git a/packages/vrender-kits/jest.config.js b/packages/vrender-kits/jest.config.js index 261684d3b..2a0aaf36e 100644 --- a/packages/vrender-kits/jest.config.js +++ b/packages/vrender-kits/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -21,18 +20,5 @@ module.exports = createStablePackageJestConfig({ '!**/interface.ts', '!**/**.d.ts' ], - moduleNameMapper: { - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '@visactor/vrender-animate': path.resolve(__dirname, '../vrender-animate/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender-kits/package.json b/packages/vrender-kits/package.json index 555c288c2..463e0bf28 100644 --- a/packages/vrender-kits/package.json +++ b/packages/vrender-kits/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-kits", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": false, "main": "cjs/index-node.js", @@ -31,13 +31,14 @@ "dev": "cross-env DEBUG='Bundler*' bundle --clean -f es -w", "start": "vite ./vite", "test": "jest -c jest.config.js", + "test:artifacts": "jest -c jest.artifacts.config.js", "test:electron": "jest -c jest.electron.config.js --runInBand --passWithNoTests --testPathPattern='__tests__/(browser|electron)/'", "test-cov": "jest -c jest.config.js --coverage", "test-watch": "cross-env DEBUG_MODE=1 jest --watch -c jest.config.js" }, "dependencies": { "@visactor/vutils": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.10", + "@visactor/vrender-core": "workspace:1.1.11", "@resvg/resvg-js": "2.4.1", "roughjs": "4.6.6", "gifuct-js": "2.1.2", diff --git a/packages/vrender/CHANGELOG.json b/packages/vrender/CHANGELOG.json index 974622960..6e610ed51 100644 --- a/packages/vrender/CHANGELOG.json +++ b/packages/vrender/CHANGELOG.json @@ -1,6 +1,18 @@ { "name": "@visactor/vrender", "entries": [ + { + "version": "1.1.11", + "tag": "@visactor/vrender_v1.1.11", + "date": "Thu, 17 Sep 2026 07:31:09 GMT", + "comments": { + "none": [ + { + "comment": "performance: memo leak problem of ticker. fix#2075" + } + ] + } + }, { "version": "1.1.10", "tag": "@visactor/vrender_v1.1.10", diff --git a/packages/vrender/CHANGELOG.md b/packages/vrender/CHANGELOG.md index c00ebcef5..d4c98a15f 100644 --- a/packages/vrender/CHANGELOG.md +++ b/packages/vrender/CHANGELOG.md @@ -1,6 +1,13 @@ # Change Log - @visactor/vrender -This log was last generated on Sat, 29 Aug 2026 08:21:58 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2026 07:31:09 GMT and should not be manually modified. + +## 1.1.11 +Thu, 17 Sep 2026 07:31:09 GMT + +### Updates + +- performance: memo leak problem of ticker. fix#2075 ## 1.1.10 Sat, 29 Aug 2026 08:21:58 GMT diff --git a/packages/vrender/__tests__/unit/build-artifact-consistency.test.ts b/packages/vrender/__tests__/artifacts/build-artifact-consistency.test.ts similarity index 100% rename from packages/vrender/__tests__/unit/build-artifact-consistency.test.ts rename to packages/vrender/__tests__/artifacts/build-artifact-consistency.test.ts diff --git a/packages/vrender/__tests__/unit/build-artifact-root-imports.test.ts b/packages/vrender/__tests__/artifacts/build-artifact-root-imports.test.ts similarity index 81% rename from packages/vrender/__tests__/unit/build-artifact-root-imports.test.ts rename to packages/vrender/__tests__/artifacts/build-artifact-root-imports.test.ts index a4e6e3396..bf5f49a72 100644 --- a/packages/vrender/__tests__/unit/build-artifact-root-imports.test.ts +++ b/packages/vrender/__tests__/artifacts/build-artifact-root-imports.test.ts @@ -28,10 +28,6 @@ function collectPackageDirs(): string[] { function collectArtifactFiles(packageRoot: string, relativeDir: string): string[] { const absoluteDir = path.join(packageRoot, relativeDir); - if (!fs.existsSync(absoluteDir)) { - return []; - } - return fs .readdirSync(absoluteDir, { withFileTypes: true }) .flatMap((entry: { isDirectory: () => boolean; name: string }) => { @@ -85,27 +81,21 @@ function collectRootNamedImports(artifact: string, packageName: string): string[ describe('published root bundle imports', () => { test('root named imports between VRender package artifacts should exist in target root bundle exports', () => { - const packages = collectPackageDirs() - .map((dir: string) => { - const packageRoot = path.join(packagesRoot, dir); - const bundlePath = path.join(packageRoot, rootBundlePath); - - if (!fs.existsSync(bundlePath)) { - return null; - } - - const packageJson = JSON.parse(readText(path.join(packageRoot, 'package.json'))); - const artifact = readText(bundlePath); - - return { - name: packageJson.name, - dir, - packageRoot, - artifactFiles: scannedBuildRoots.flatMap(relativeDir => collectArtifactFiles(packageRoot, relativeDir)), - exports: new Set(collectBundleNamedExports(artifact)) - }; - }) - .filter(Boolean); + const packages = collectPackageDirs().map((dir: string) => { + const packageRoot = path.join(packagesRoot, dir); + const bundlePath = path.join(packageRoot, rootBundlePath); + + const packageJson = JSON.parse(readText(path.join(packageRoot, 'package.json'))); + const artifact = readText(bundlePath); + + return { + name: packageJson.name, + dir, + packageRoot, + artifactFiles: scannedBuildRoots.flatMap(relativeDir => collectArtifactFiles(packageRoot, relativeDir)), + exports: new Set(collectBundleNamedExports(artifact)) + }; + }); const failures: string[] = []; diff --git a/packages/vrender/__tests__/graphic/area-invalid-point.test.ts b/packages/vrender/__tests__/graphic/area-invalid-point.test.ts new file mode 100644 index 000000000..61be20431 --- /dev/null +++ b/packages/vrender/__tests__/graphic/area-invalid-point.test.ts @@ -0,0 +1,94 @@ +// Pixel and hit-test assertions require a real Canvas instead of the default mock. +import '../../../../share/jest-config/setup-jsdom-canvas'; +import { createArea, CustomPath2D, type IAreaGraphicAttribute } from '../../src/index'; +import { RoughCanvasAreaRender } from '../../../vrender-kits/src/render/contributions/rough/rough-area'; +import { createBrowserStage } from '../util'; + +const points = [ + { x: 0, y: 0, y1: 0 }, + { x: 10, y: 10, y1: 0 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 20, y: 10, y1: 0 }, + { x: 30, y: 0, y1: 0 } +]; + +function fixture(attrs: IAreaGraphicAttribute, dirty = false) { + const canvas = document.createElement('canvas'); + const stage = createBrowserStage({ canvas, width: 140, height: 80, dpr: 1, disableDirtyBounds: !dirty }); + const area = createArea({ x: 10, y: 20, fill: 'red', ...attrs }); + stage.defaultLayer.add(area); + stage.render(); + return { stage, area, context: canvas.getContext('2d') }; +} + +describe('area invalid points through public rendering and picking', () => { + test('basis gaps remain empty while valid translated regions can be picked', () => { + const { stage, area } = fixture({ points, curveType: 'basis', connectedType: 'none' }); + try { + const first = stage.pick(15, 22); + const second = stage.pick(35, 22); + const gap = stage.pick(25, 22); + const outside = stage.pick(105, 40); + expect(first && first.graphic).toBe(area); + expect(second && second.graphic).toBe(area); + expect(gap && gap.graphic).not.toBe(area); + expect(outside && outside.graphic).not.toBe(area); + area.setAttribute('connectedType', 'connect'); + stage.render(); + const connected = stage.pick(25, 22); + expect(connected && connected.graphic).toBe(area); + } finally { + stage.release(); + } + }); + + test('an invalid singleton segment cannot create an unpickable filled region', () => { + const { stage, area, context } = fixture({ + connectedType: 'connect', + segments: [{ points: [points[2]] }, { points: points.slice(0, 2) }] + }); + try { + const hit = stage.pick(15, 22); + expect(hit && hit.graphic).toBe(area); + const background = context.getImageData(130, 70, 1, 1).data; + expect(Array.from(context.getImageData(105, 40, 1, 1).data)).toEqual(Array.from(background)); + } finally { + stage.release(); + } + }); + + test('dirty rendering matches full rendering after valid and undefined coordinates change', () => { + const dirty = fixture({ points, curveType: 'basis' }, true); + const full = fixture({ points, curveType: 'basis' }); + try { + const updated = points.map(p => (p.defined === false ? { ...p, x: -800, y: -800 } : { ...p, x: p.x + 15 })); + dirty.area.setAttribute('points', updated); + full.area.setAttribute('points', updated); + dirty.stage.render(); + full.stage.render(); + const actual = dirty.context.getImageData(0, 0, 140, 80).data; + const expected = full.context.getImageData(0, 0, 140, 80).data; + expect(actual.every((value, i) => value === expected[i])).toBe(true); + } finally { + dirty.stage.release(); + full.stage.release(); + } + }); + + test('rough renderer receives disconnected valid subpaths through the existing cache contract', () => { + const renderer = new RoughCanvasAreaRender({ getContributions: () => [] }); + const { stage, area } = fixture({ points, curveType: 'basis' }); + const spy = jest.spyOn(CustomPath2D.prototype, 'toString'); + try { + const context = stage.window.getContext(); + renderer.drawShape(area, context, 0, 0, { context } as any); + const paths = spy.mock.results.map(result => result.value as string); + expect(paths).toHaveLength(1); + expect(paths[0].match(/M/g)).toHaveLength(2); + expect(paths[0]).not.toMatch(/500|NaN/); + } finally { + spy.mockRestore(); + stage.release(); + } + }); +}); diff --git a/packages/vrender/__tests__/unit/source-module-resolution.test.ts b/packages/vrender/__tests__/unit/source-module-resolution.test.ts new file mode 100644 index 000000000..30b8d524c --- /dev/null +++ b/packages/vrender/__tests__/unit/source-module-resolution.test.ts @@ -0,0 +1,37 @@ +/** + * @jest-environment node + */ + +declare const require: any; +declare const __dirname: string; +export {}; + +const path = require('path'); +const packagesRoot = path.resolve(__dirname, '../../..'); + +describe('workspace source module resolution', () => { + test.each([ + 'vrender', + 'vrender-core', + 'vrender-kits', + 'vrender-animate', + 'vrender-components', + 'react-vrender', + 'react-vrender-utils' + ])('%s resolves to its own source entry without a build', packageName => { + expect(require.resolve(`@visactor/${packageName}`)).toBe(path.join(packagesRoot, packageName, 'src/index.ts')); + }); + + test.each([ + ['vrender/entries/node', 'vrender/src/entries/node.ts'], + ['vrender-core/event/constant', 'vrender-core/src/event/public-constant.ts'], + ['vrender-core/render/draw-interceptor', 'vrender-core/src/render/contributions/render/draw-interceptor.ts'], + ['vrender-core/render/symbol', 'vrender-core/src/render/contributions/render/symbol.ts'], + ['vrender-kits/register/register-line', 'vrender-kits/src/register/register-line.ts'], + ['vrender-animate/register', 'vrender-animate/src/register.ts'], + ['vrender-components/brush', 'vrender-components/src/brush/index.ts'], + ['react-vrender/processProps', 'react-vrender/src/processProps.ts'] + ])('%s resolves to its source subpath', (specifier, source) => { + expect(require.resolve(`@visactor/${specifier}`)).toBe(path.join(packagesRoot, source)); + }); +}); diff --git a/packages/vrender/jest.artifacts.config.js b/packages/vrender/jest.artifacts.config.js new file mode 100644 index 000000000..5b0698ca3 --- /dev/null +++ b/packages/vrender/jest.artifacts.config.js @@ -0,0 +1,3 @@ +const { createArtifactPackageJestConfig } = require('../../share/jest-config/create-package-config'); + +module.exports = createArtifactPackageJestConfig(); diff --git a/packages/vrender/jest.config.js b/packages/vrender/jest.config.js index 8ea2959d0..ce06cacae 100644 --- a/packages/vrender/jest.config.js +++ b/packages/vrender/jest.config.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createStablePackageJestConfig } = require('../../share/jest-config/create-package-config'); module.exports = createStablePackageJestConfig({ @@ -29,22 +28,5 @@ module.exports = createStablePackageJestConfig({ statements: 80 } }, - moduleNameMapper: { - '^@visactor/vrender-kits/(.*)$': path.resolve(__dirname, '../vrender-kits/src/$1'), - '^@visactor/vrender-kits$': path.resolve(__dirname, '../vrender-kits/src/index.ts'), - '^@visactor/vrender-animate/(.*)$': path.resolve(__dirname, '../vrender-animate/src/$1'), - '^@visactor/vrender-core/event/constant$': path.resolve(__dirname, '../vrender-core/src/event/public-constant.ts'), - '^@visactor/vrender-core/render/draw-interceptor$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/draw-interceptor.ts' - ), - '^@visactor/vrender-core/render/symbol$': path.resolve( - __dirname, - '../vrender-core/src/render/contributions/render/symbol.ts' - ), - '^@visactor/vrender-core/(.*)$': path.resolve(__dirname, '../vrender-core/src/$1'), - '^@visactor/vrender-core$': path.resolve(__dirname, '../vrender-core/src/index.ts'), - '^@visactor/vrender-animate$': path.resolve(__dirname, '../vrender-animate/src/index.ts'), - '^@visactor/vrender-components$': path.resolve(__dirname, '../vrender-components/src/index.ts') - } + moduleNameMapper: require('../../share/jest-config/source-module-name-mapper') }); diff --git a/packages/vrender/package.json b/packages/vrender/package.json index c5f6ce1b7..3c0c7563d 100644 --- a/packages/vrender/package.json +++ b/packages/vrender/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender", - "version": "1.1.10", + "version": "1.1.11", "description": "", "sideEffects": true, "main": "cjs/index.js", @@ -25,16 +25,17 @@ "dev": "cross-env DEBUG='Bundler*' bundle --clean -f es -w", "start": "vite ./__tests__/browser --host", "test": "jest -c jest.config.js", + "test:artifacts": "jest -c jest.artifacts.config.js", "test:electron": "jest -c jest.electron.config.js --runInBand --passWithNoTests --testPathPattern='__tests__/(browser|electron)/'", "test-cov": "jest -c jest.config.js -w 16 --coverage", "test-live": "npm run test-watch __tests__/unit/theme/line.test.ts", "test-watch": "cross-env DEBUG_MODE=1 jest --watch -c jest.config.js" }, "dependencies": { - "@visactor/vrender-core": "workspace:1.1.10", - "@visactor/vrender-kits": "workspace:1.1.10", - "@visactor/vrender-animate": "workspace:1.1.10", - "@visactor/vrender-components": "workspace:1.1.10" + "@visactor/vrender-core": "workspace:1.1.11", + "@visactor/vrender-kits": "workspace:1.1.11", + "@visactor/vrender-animate": "workspace:1.1.11", + "@visactor/vrender-components": "workspace:1.1.11" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/share/jest-config/create-package-config.js b/share/jest-config/create-package-config.js index 57af303a1..76b2dfca3 100644 --- a/share/jest-config/create-package-config.js +++ b/share/jest-config/create-package-config.js @@ -62,7 +62,7 @@ function createStablePackageJestConfig(options = {}) { verbose, coverageReporters, coveragePathIgnorePatterns, - testPathIgnorePatterns, + testPathIgnorePatterns: ['/node_modules/', '/__tests__/artifacts/', ...testPathIgnorePatterns], collectCoverageFrom }; @@ -77,6 +77,17 @@ function createStablePackageJestConfig(options = {}) { return config; } +function createArtifactPackageJestConfig() { + return { + ...createStablePackageJestConfig({ + environment: 'node', + testRegex: '/__tests__/artifacts/.*\\.test\\.ts$' + }), + // Published package imports must resolve normally, without source aliases. + testPathIgnorePatterns: ['/node_modules/'] + }; +} + function createElectronPackageJestConfig(options = {}) { const { rootDir, ...rest } = options; @@ -91,5 +102,6 @@ function createElectronPackageJestConfig(options = {}) { module.exports = { createStablePackageJestConfig, + createArtifactPackageJestConfig, createElectronPackageJestConfig }; diff --git a/share/jest-config/source-module-name-mapper.js b/share/jest-config/source-module-name-mapper.js new file mode 100644 index 000000000..be5700ce5 --- /dev/null +++ b/share/jest-config/source-module-name-mapper.js @@ -0,0 +1,31 @@ +const path = require('path'); + +const packagesRoot = path.resolve(__dirname, '../../packages'); + +// Public subpaths whose source filenames differ from their import paths. +const mapper = { + '^@visactor/vrender-core/event/constant$': path.join(packagesRoot, 'vrender-core/src/event/public-constant.ts'), + '^@visactor/vrender-core/render/draw-interceptor$': path.join( + packagesRoot, + 'vrender-core/src/render/contributions/render/draw-interceptor.ts' + ), + '^@visactor/vrender-core/render/symbol$': path.join( + packagesRoot, + 'vrender-core/src/render/contributions/render/symbol.ts' + ) +}; + +for (const packageName of [ + 'vrender', + 'vrender-core', + 'vrender-kits', + 'vrender-animate', + 'vrender-components', + 'react-vrender', + 'react-vrender-utils' +]) { + mapper[`^@visactor/${packageName}$`] = path.join(packagesRoot, packageName, 'src/index.ts'); + mapper[`^@visactor/${packageName}/(.*)$`] = path.join(packagesRoot, packageName, 'src/$1'); +} + +module.exports = mapper; diff --git a/tools/bugserver-trigger/README.md b/tools/bugserver-trigger/README.md new file mode 100644 index 000000000..6eafaa8f0 --- /dev/null +++ b/tools/bugserver-trigger/README.md @@ -0,0 +1,46 @@ +# Bug Server CI + +`scripts/trigger-test.ts` uploads `dist/index.js`, waits for an SCM build, starts the Bug Server photo tests, and waits for their results. It requires `BUG_SERVER_TOKEN`. + +## Manually test a PR + +After the workflows are merged into the repository's default branch (`develop`), wait for **Bug Server PR Bundle** to succeed for the reviewed PR head. Fork runs may need a maintainer's approval. Then maintainers with repository write access can open **Actions → Bug Server CI → Run workflow**. Select **develop**, then enter: + +- `pr_number`: the PR number, including PRs from external forks. +- `head_sha`: the full 40-character SHA of the PR head that you reviewed. + +The equivalent CLI command is: + +```sh +gh workflow run bug-server.yml \ + --repo VisActor/VRender \ + --ref develop \ + -f pr_number=2128 \ + -f head_sha=40be3619d1608aa1d5827f0a465704eeb036a7d3 +``` + +Use the currently reviewed PR head; the example SHA becomes invalid if that PR changes. The workflow rejects non-default workflow branches, malformed inputs and a SHA that differs from the PR's current head. It builds the exact requested **head commit**, not GitHub's generated merge commit. Updates after validation cannot change the commit being built. + +The run appears under Actions; this manual run does not automatically attach a check or comment to the external PR. Its summary records the PR URL, tested head and source build run. The **Trigger Bug Server for reviewed PR** step prints `scmVersion`, `bundleId`, and the result counts, which identify the run in Bug Server. A missing token, failed SCM build or failed photo test makes the job fail. + +The manual entry consumes an existing PR bundle; it does not build PR code. Artifacts are retained for 7 days. If the build or artifact is missing, failed or expired, approve/wait for/re-run **Bug Server PR Bundle** before dispatching again. For a PR opened before this workflow was introduced, update or reopen the PR to trigger a new PR event; re-running an old workflow definition does not create the new bundle workflow. + +## Execution boundaries + +1. **Bug Server PR Bundle** runs only on `pull_request`, builds the exact head with read-only repository permissions, disabled persisted checkout credentials and no Bug Server token. Any cache writes are confined to the PR scope. It uploads `bug-server-pr--`. +2. The manual **resolve-manual-target** job validates the current PR head and source workflow ID/path, PR event, successful run, repository IDs, source branch and run SHA. It requires one non-expired artifact with matching GitHub API provenance. Fork runs can omit PR associations; the repository/branch/SHA checks still bind the source. +3. **submit-manual-bundle** uses scripts from the immutable default-branch workflow commit. It downloads the selected artifact ID and accepts only a single regular `index.js` entry, up to 64 MiB. The trusted extractor writes bytes to a fixed path without extracting archive paths. The client only uploads those bytes; it never executes the bundle or PR package scripts. + +Both workflows default to `contents: read`. Manual lookup and download jobs also need `actions: read`, and target validation needs `pull-requests: read`. The Bug Server token is injected only into the final API client step. Existing push and pull-request automatic runs retain their build and test behavior with read-only repository permissions. A fork PR's automatic Bug Server run still cannot obtain repository secrets; use the manual entry for Bug Server validation. + +The default-branch manual workflow does not check out or build PR code. This replaces the earlier `cache-mode` approach and does not require scanner exceptions. + +## Local validation + +From the repository root: + +```sh +node --test .github/scripts/bug-server-dispatch.test.cjs +python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py' +actionlint .github/workflows/bug-server.yml .github/workflows/bug-server-pr-bundle.yml +``` diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index 350b6e127..f8efe9134 100644 --- a/tools/bugserver-trigger/package.json +++ b/tools/bugserver-trigger/package.json @@ -8,11 +8,11 @@ "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.10", - "@visactor/vrender-core": "workspace:1.1.10", - "@visactor/vrender-kits": "workspace:1.1.10", - "@visactor/vrender-components": "workspace:1.1.10", - "@visactor/vrender-animate": "workspace:1.1.10" + "@visactor/vrender": "workspace:1.1.11", + "@visactor/vrender-core": "workspace:1.1.11", + "@visactor/vrender-kits": "workspace:1.1.11", + "@visactor/vrender-components": "workspace:1.1.11", + "@visactor/vrender-animate": "workspace:1.1.11" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4",