mirror of https://github.com/sveltejs/svelte
commit
1e5aa2fe1f
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: leave stale promises to wait for a later resolution, instead of rejecting
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: cancel deferred event listeners during cleanup
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: reapply context after transforming error during SSR
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
perf: speed up parser interactions with Acorn or avoid them where possible
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: don't rebase just-created batches
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
chore: allow `null` for `pending` in typings
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: flush eager effects in production
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: rethrow error of failed iterable after calling `return()`
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: account for proxified instance when updating `bind:this`
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: serialize input default values during server rendering
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: ensure scheduled batch is flushed if not obsolete
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: resolve stale deriveds with latest value
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: throw `set_context_after_init` when `setContext` is called after an `await` during SSR
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
chore: remove unnecessary `increment_pending` calls
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: correctly compile component member expressions for SSR
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: reset `source.updated` stack traces after `flush`
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"svelte": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: replacing async 'blocking' strategy with 'merging'
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: allow `@debug` tags to reference awaited variables
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: re-run fallback props if dependencies update
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: abort running obsolete async branches
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
perf: avoid regex matching in parser where possible
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: ignore comments when reading CSS values
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: wrap `Promise.all` in `save` during SSR
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: in non-async mode, only push variable to current_sources when active_reaction is updating
|
||||||
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
'svelte': patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: ignore false-positive errors of `$inspect` dependencies
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: recognise `aria-braillelabel` and `aria-brailleroledescription` as known ARIA attributes
|
||||||
@ -1,229 +0,0 @@
|
|||||||
name: pkg.pr.new
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, synchronize]
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
sha:
|
|
||||||
description: 'Commit SHA to build'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
pr:
|
|
||||||
description: 'PR number to comment on'
|
|
||||||
required: true
|
|
||||||
type: number
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
# Skip pull_request_target events from forks — maintainers can use workflow_dispatch instead
|
|
||||||
if: >
|
|
||||||
github.event_name != 'pull_request_target' ||
|
|
||||||
github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# No permissions — this job runs user-controlled code
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
# For pull_request_target, check out the PR head.
|
|
||||||
# For workflow_dispatch, check out the manually specified SHA.
|
|
||||||
# For push, fall back to the push SHA.
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || inputs.sha || github.sha }}
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- run: pnpx pkg-pr-new publish --comment=off --json output.json --compact --no-template './packages/svelte'
|
|
||||||
|
|
||||||
- name: Upload output
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: output
|
|
||||||
path: ./output.json
|
|
||||||
|
|
||||||
# Sanitizes the untrusted output from the build job before it's consumed by
|
|
||||||
# jobs with elevated permissions. This ensures that only known package names
|
|
||||||
# and valid SHA prefixes make it through.
|
|
||||||
sanitize:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download artifact
|
|
||||||
uses: actions/download-artifact@v7
|
|
||||||
with:
|
|
||||||
name: output
|
|
||||||
|
|
||||||
- name: Sanitize output
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const raw = JSON.parse(fs.readFileSync('output.json', 'utf8'));
|
|
||||||
|
|
||||||
const ALLOWED_PACKAGES = new Set(['svelte']);
|
|
||||||
const SHA_PATTERN = /^[0-9a-f]{7}$/;
|
|
||||||
|
|
||||||
const packages = (raw.packages || [])
|
|
||||||
.filter(p => {
|
|
||||||
if (!ALLOWED_PACKAGES.has(p.name)) {
|
|
||||||
console.log(`Skipping unexpected package: ${JSON.stringify(p.name)}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const sha = p.url?.replace(/^.+@([^@]+)$/, '$1');
|
|
||||||
if (!sha || !SHA_PATTERN.test(sha)) {
|
|
||||||
console.log(`Skipping package with invalid SHA: ${JSON.stringify(p.url)}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(p => ({
|
|
||||||
name: p.name,
|
|
||||||
sha: p.url.replace(/^.+@([^@]+)$/, '$1'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
fs.writeFileSync('sanitized-output.json', JSON.stringify({ packages }), 'utf8');
|
|
||||||
|
|
||||||
- name: Upload sanitized output
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: sanitized-output
|
|
||||||
path: ./sanitized-output.json
|
|
||||||
|
|
||||||
comment:
|
|
||||||
needs: sanitize
|
|
||||||
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download sanitized artifact
|
|
||||||
uses: actions/download-artifact@v7
|
|
||||||
with:
|
|
||||||
name: sanitized-output
|
|
||||||
|
|
||||||
- name: Resolve PR number
|
|
||||||
id: pr
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
if (context.eventName === 'pull_request_target') {
|
|
||||||
core.setOutput('number', context.issue.number);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For workflow_dispatch, use the explicitly provided PR number.
|
|
||||||
// We can't use listPullRequestsAssociatedWithCommit because fork
|
|
||||||
// commits don't exist in the base repo, so the API returns nothing.
|
|
||||||
const pr = Number('${{ inputs.pr }}');
|
|
||||||
if (!pr || isNaN(pr)) {
|
|
||||||
core.setFailed('workflow_dispatch requires a valid pr input');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('number', pr);
|
|
||||||
|
|
||||||
- name: Post or update comment
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8'));
|
|
||||||
|
|
||||||
if (packages.length === 0) {
|
|
||||||
console.log('No valid packages found. Skipping comment.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const issue_number = parseInt('${{ steps.pr.outputs.number }}', 10);
|
|
||||||
|
|
||||||
const bot_comment_identifier = `<!-- pkg.pr.new comment -->`;
|
|
||||||
|
|
||||||
const body = `${bot_comment_identifier}
|
|
||||||
|
|
||||||
[Playground](https://svelte.dev/playground?version=pr-${issue_number})
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
${packages.map(p => `pnpm add https://pkg.pr.new/${p.name}@${issue_number}`).join('\n')}
|
|
||||||
\`\`\`
|
|
||||||
`;
|
|
||||||
|
|
||||||
const comments = await github.rest.issues.listComments({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number,
|
|
||||||
});
|
|
||||||
const existing = comments.data.find(c => c.body.includes(bot_comment_identifier));
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await github.rest.issues.updateComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
comment_id: existing.id,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log:
|
|
||||||
needs: sanitize
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download sanitized artifact
|
|
||||||
uses: actions/download-artifact@v7
|
|
||||||
with:
|
|
||||||
name: sanitized-output
|
|
||||||
|
|
||||||
- name: Log publish info
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8'));
|
|
||||||
|
|
||||||
if (packages.length === 0) {
|
|
||||||
console.log('No valid packages found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n' + '='.repeat(50));
|
|
||||||
console.log('Publish Information');
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
for (const p of packages) {
|
|
||||||
console.log(`${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.sha}`);
|
|
||||||
}
|
|
||||||
const svelte = packages.find(p => p.name === 'svelte');
|
|
||||||
if (svelte) {
|
|
||||||
console.log(`\nPlayground: https://svelte.dev/playground?version=commit-${svelte.sha}`);
|
|
||||||
}
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
@ -0,0 +1,346 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(fileURLToPath(import.meta.url), '../..');
|
||||||
|
const compiler_path = 'packages/svelte/src/compiler/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge V8 profiles from a Vitest run into a compiler-only flame graph and hotspot summary.
|
||||||
|
* Samples retain callees outside the compiler once execution has entered compiler code.
|
||||||
|
* @param {string} run_dir
|
||||||
|
*/
|
||||||
|
export function analyze_compiler_profiles(run_dir) {
|
||||||
|
const raw_dir = path.join(run_dir, 'raw');
|
||||||
|
const profile_files = fs
|
||||||
|
.readdirSync(raw_dir)
|
||||||
|
.filter((file) => file.endsWith('.cpuprofile'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (profile_files.length === 0) {
|
||||||
|
throw new Error(`No CPU profiles found in ${raw_dir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Array<{ name: string, file?: string, line?: number, col?: number }>} */
|
||||||
|
const frames = [];
|
||||||
|
/** @type {Map<string, number>} */
|
||||||
|
const frame_indices = new Map();
|
||||||
|
/** @type {Map<string, { stack: number[], weight: number }>} */
|
||||||
|
const stacks = new Map();
|
||||||
|
/** @type {Map<string, Hotspot>} */
|
||||||
|
const hotspots = new Map();
|
||||||
|
/** @type {Map<string, Hotspot>} */
|
||||||
|
const files = new Map();
|
||||||
|
let total_time = 0;
|
||||||
|
let compiler_time = 0;
|
||||||
|
let garbage_collection_time = 0;
|
||||||
|
let total_samples = 0;
|
||||||
|
let compiler_samples = 0;
|
||||||
|
|
||||||
|
for (const profile_file of profile_files) {
|
||||||
|
let profile;
|
||||||
|
try {
|
||||||
|
profile = JSON.parse(fs.readFileSync(path.join(raw_dir, profile_file), 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Could not parse CPU profile ${profile_file}`, { cause: error });
|
||||||
|
}
|
||||||
|
const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
|
||||||
|
const samples = Array.isArray(profile.samples) ? profile.samples : [];
|
||||||
|
const time_deltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : [];
|
||||||
|
const nodes_by_id = new Map(nodes.map((node) => [node.id, node]));
|
||||||
|
const parents = new Map();
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
parents.set(child, node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
const weight = typeof time_deltas[i] === 'number' ? time_deltas[i] : 1;
|
||||||
|
const stack = get_stack(samples[i], nodes_by_id, parents);
|
||||||
|
total_time += weight;
|
||||||
|
total_samples += 1;
|
||||||
|
|
||||||
|
if (stack.at(-1)?.function_name === '(garbage collector)') {
|
||||||
|
garbage_collection_time += weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compiler_index = stack.findIndex((frame) => frame.url.includes(compiler_path));
|
||||||
|
if (compiler_index === -1) continue;
|
||||||
|
|
||||||
|
const compiler_stack = stack.slice(compiler_index);
|
||||||
|
const stack_indices = compiler_stack.map(get_frame_index);
|
||||||
|
const stack_key = stack_indices.join(',');
|
||||||
|
const existing_stack = stacks.get(stack_key);
|
||||||
|
|
||||||
|
if (existing_stack) {
|
||||||
|
existing_stack.weight += weight;
|
||||||
|
} else {
|
||||||
|
stacks.set(stack_key, { stack: stack_indices, weight });
|
||||||
|
}
|
||||||
|
|
||||||
|
compiler_time += weight;
|
||||||
|
compiler_samples += 1;
|
||||||
|
|
||||||
|
const leaf = compiler_stack.at(-1);
|
||||||
|
if (leaf) {
|
||||||
|
get_hotspot(hotspots, frame_key(leaf), leaf).self += weight;
|
||||||
|
get_hotspot(files, leaf.url || '(native)', {
|
||||||
|
...leaf,
|
||||||
|
function_name: leaf.url || '(native)'
|
||||||
|
}).self += weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen_frames = new Set();
|
||||||
|
const seen_files = new Set();
|
||||||
|
|
||||||
|
for (const frame of compiler_stack) {
|
||||||
|
const key = frame_key(frame);
|
||||||
|
if (!seen_frames.has(key)) {
|
||||||
|
get_hotspot(hotspots, key, frame).inclusive += weight;
|
||||||
|
seen_frames.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = frame.url || '(native)';
|
||||||
|
if (!seen_files.has(file)) {
|
||||||
|
get_hotspot(files, file, { ...frame, function_name: file }).inclusive += weight;
|
||||||
|
seen_files.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiler_time === 0) {
|
||||||
|
throw new Error('The CPU profiles contain no samples from packages/svelte/src/compiler');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotspot_rows = rank(hotspots, compiler_time);
|
||||||
|
const file_rows = rank(files, compiler_time);
|
||||||
|
const compact_stacks = [...stacks.values()];
|
||||||
|
const summary = {
|
||||||
|
profile_files: profile_files.length,
|
||||||
|
total_samples,
|
||||||
|
compiler_samples,
|
||||||
|
total_time_microseconds: total_time,
|
||||||
|
compiler_time_microseconds: compiler_time,
|
||||||
|
garbage_collection_time_microseconds: garbage_collection_time,
|
||||||
|
compiler_share_percent: (compiler_time * 100) / total_time,
|
||||||
|
hotspots: hotspot_rows,
|
||||||
|
files: file_rows
|
||||||
|
};
|
||||||
|
const speedscope = {
|
||||||
|
$schema: 'https://www.speedscope.app/file-format-schema.json',
|
||||||
|
name: `Svelte compiler: ${path.basename(run_dir)}`,
|
||||||
|
exporter: 'Svelte compiler test profiler',
|
||||||
|
activeProfileIndex: 0,
|
||||||
|
shared: { frames },
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
type: 'sampled',
|
||||||
|
name: 'Compiler-active samples from pnpm test',
|
||||||
|
unit: 'microseconds',
|
||||||
|
startValue: 0,
|
||||||
|
endValue: compiler_time,
|
||||||
|
samples: compact_stacks.map((entry) => entry.stack),
|
||||||
|
weights: compact_stacks.map((entry) => entry.weight)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(run_dir, 'flamegraph.speedscope.json'),
|
||||||
|
`${JSON.stringify(speedscope)}\n`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(run_dir, 'summary.json'), `${JSON.stringify(summary, null, '\t')}\n`);
|
||||||
|
fs.writeFileSync(path.join(run_dir, 'summary.md'), render_markdown(summary));
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
|
||||||
|
/** @param {Frame} frame */
|
||||||
|
function get_frame_index(frame) {
|
||||||
|
const key = frame_key(frame);
|
||||||
|
const existing = frame_indices.get(key);
|
||||||
|
if (existing !== undefined) return existing;
|
||||||
|
|
||||||
|
const index = frames.length;
|
||||||
|
const location = frame.url ? ` (${frame.url}:${frame.line})` : '';
|
||||||
|
frames.push({
|
||||||
|
name: `${frame.function_name}${location}`,
|
||||||
|
...(frame.url ? { file: frame.url, line: frame.line, col: frame.column } : {})
|
||||||
|
});
|
||||||
|
frame_indices.set(key, index);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} leaf_id
|
||||||
|
* @param {Map<number, any>} nodes_by_id
|
||||||
|
* @param {Map<number, number>} parents
|
||||||
|
* @returns {Frame[]}
|
||||||
|
*/
|
||||||
|
function get_stack(leaf_id, nodes_by_id, parents) {
|
||||||
|
/** @type {Frame[]} */
|
||||||
|
const stack = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let id = leaf_id;
|
||||||
|
|
||||||
|
while (typeof id === 'number' && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
const node = nodes_by_id.get(id);
|
||||||
|
if (!node) break;
|
||||||
|
|
||||||
|
const call_frame = node.callFrame || {};
|
||||||
|
stack.push({
|
||||||
|
function_name: call_frame.functionName || '(anonymous)',
|
||||||
|
url: normalize_url(call_frame.url || ''),
|
||||||
|
line: typeof call_frame.lineNumber === 'number' ? call_frame.lineNumber + 1 : 0,
|
||||||
|
column: typeof call_frame.columnNumber === 'number' ? call_frame.columnNumber + 1 : 0
|
||||||
|
});
|
||||||
|
id = parents.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stack.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} url */
|
||||||
|
function normalize_url(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
|
||||||
|
let pathname = url.replace(/^\/\@fs\//, '/').replace(/[?#].*$/, '');
|
||||||
|
if (pathname.startsWith('file://')) {
|
||||||
|
try {
|
||||||
|
pathname = fileURLToPath(pathname);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.isAbsolute(pathname)) {
|
||||||
|
const relative = path.relative(root, pathname);
|
||||||
|
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
||||||
|
pathname = relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname.replaceAll(path.sep, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Frame} frame */
|
||||||
|
function frame_key(frame) {
|
||||||
|
return `${frame.function_name}\0${frame.url}\0${frame.line}\0${frame.column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Map<string, Hotspot>} collection
|
||||||
|
* @param {string} key
|
||||||
|
* @param {Frame} frame
|
||||||
|
*/
|
||||||
|
function get_hotspot(collection, key, frame) {
|
||||||
|
let hotspot = collection.get(key);
|
||||||
|
if (!hotspot) {
|
||||||
|
hotspot = { ...frame, self: 0, inclusive: 0 };
|
||||||
|
collection.set(key, hotspot);
|
||||||
|
}
|
||||||
|
return hotspot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Map<string, Hotspot>} collection
|
||||||
|
* @param {number} total
|
||||||
|
*/
|
||||||
|
function rank(collection, total) {
|
||||||
|
return [...collection.values()]
|
||||||
|
.map((hotspot) => ({
|
||||||
|
function: hotspot.function_name,
|
||||||
|
url: hotspot.url,
|
||||||
|
line: hotspot.line,
|
||||||
|
column: hotspot.column,
|
||||||
|
self_microseconds: hotspot.self,
|
||||||
|
self_percent: (hotspot.self * 100) / total,
|
||||||
|
inclusive_microseconds: hotspot.inclusive,
|
||||||
|
inclusive_percent: (hotspot.inclusive * 100) / total
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.self_microseconds - a.self_microseconds ||
|
||||||
|
b.inclusive_microseconds - a.inclusive_microseconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ReturnType<typeof analyze_compiler_profiles>} summary */
|
||||||
|
function render_markdown(summary) {
|
||||||
|
const lines = [
|
||||||
|
'# Svelte compiler CPU profile',
|
||||||
|
'',
|
||||||
|
'Open `flamegraph.speedscope.json` in [Speedscope](https://www.speedscope.app/) for the interactive flame graph.',
|
||||||
|
'',
|
||||||
|
'## Coverage',
|
||||||
|
'',
|
||||||
|
'| Metric | Value |',
|
||||||
|
'| --- | ---: |',
|
||||||
|
`| Raw profiles | ${summary.profile_files} |`,
|
||||||
|
`| All profiled CPU time | ${format_time(summary.total_time_microseconds)} |`,
|
||||||
|
`| Compiler-active CPU time | ${format_time(summary.compiler_time_microseconds)} |`,
|
||||||
|
`| Compiler share | ${summary.compiler_share_percent.toFixed(2)}% |`,
|
||||||
|
`| All-process garbage collection | ${format_time(summary.garbage_collection_time_microseconds)} |`,
|
||||||
|
'',
|
||||||
|
'Compiler-active time includes external callees while a compiler frame is on the stack. Garbage collection is reported for context but cannot be attributed to compiler stacks by V8.',
|
||||||
|
'',
|
||||||
|
'## Top Self Hotspots',
|
||||||
|
'',
|
||||||
|
'| Rank | Function | Location | Self | Self % | Inclusive | Inclusive % |',
|
||||||
|
'| ---: | --- | --- | ---: | ---: | ---: | ---: |'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, row] of summary.hotspots.slice(0, 50).entries()) {
|
||||||
|
lines.push(render_row(index, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'## Top Files',
|
||||||
|
'',
|
||||||
|
'| Rank | File | Self | Self % | Inclusive | Inclusive % |',
|
||||||
|
'| ---: | --- | ---: | ---: | ---: | ---: |'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [index, row] of summary.files.slice(0, 50).entries()) {
|
||||||
|
lines.push(
|
||||||
|
`| ${index + 1} | ${escape_cell(row.function)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} index @param {ReturnType<typeof rank>[number]} row */
|
||||||
|
function render_row(index, row) {
|
||||||
|
const location = row.url ? `${row.url}:${row.line}:${row.column}` : '(native)';
|
||||||
|
return `| ${index + 1} | ${escape_cell(row.function)} | ${escape_cell(location)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} microseconds */
|
||||||
|
function format_time(microseconds) {
|
||||||
|
return `${(microseconds / 1000).toFixed(1)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} value */
|
||||||
|
function escape_cell(value) {
|
||||||
|
return value.replaceAll('|', '\\|');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const run_dir = process.argv[2];
|
||||||
|
if (!run_dir) {
|
||||||
|
console.error('Usage: node benchmarking/analyze-compiler-profile.js <profile-run-directory>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
analyze_compiler_profiles(path.resolve(run_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ function_name: string, url: string, line: number, column: number }} Frame
|
||||||
|
* @typedef {Frame & { self: number, inclusive: number }} Hotspot
|
||||||
|
*/
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import { parser_benchmarks } from './parser.bench.js';
|
||||||
|
|
||||||
|
export const compiler_benchmarks = parser_benchmarks;
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import { parse } from '../../../packages/svelte/src/compiler/phases/1-parse/index.js';
|
||||||
|
import { fastest_test } from '../../utils.js';
|
||||||
|
|
||||||
|
const elements_source = Array.from(
|
||||||
|
{ length: 1000 },
|
||||||
|
(_, i) =>
|
||||||
|
`<!-- item ${i} --><section data-index="${i}" class="card card-${i}" title="plain text ${i}"><h2>Item ${i} has a reasonably long entity-free text value</h2><input disabled value="prefix {value} suffix"><textarea>plain {value} text</textarea></section>`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const script_style_source = `<script>
|
||||||
|
// line comment
|
||||||
|
/* block comment */
|
||||||
|
const value = 42;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
${Array.from(
|
||||||
|
{ length: 1000 },
|
||||||
|
(_, i) => `.item-${i} { /* property ${i} */ color: red; margin-inline: ${i}px; }`
|
||||||
|
).join('\n')}
|
||||||
|
</style>`;
|
||||||
|
|
||||||
|
const typescript_source = `${Array.from(
|
||||||
|
{ length: 1000 },
|
||||||
|
(_, i) => `<div data-index="${i}">Item ${i}</div>`
|
||||||
|
).join('\n')}
|
||||||
|
{value as number}
|
||||||
|
<script lang="ts">
|
||||||
|
let value: number = 42;
|
||||||
|
</script>${' \n'.repeat(1000)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} label
|
||||||
|
* @param {string} source
|
||||||
|
* @param {number} iterations
|
||||||
|
*/
|
||||||
|
function create_parser_benchmark(label, source, iterations) {
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
fn: async () => {
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
parse(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fastest_test(10, () => {
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
parse(source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parser_benchmarks = [
|
||||||
|
create_parser_benchmark('parser_elements', elements_source, 5),
|
||||||
|
create_parser_benchmark('parser_script_style', script_style_source, 50),
|
||||||
|
create_parser_benchmark('parser_typescript', typescript_source, 25)
|
||||||
|
];
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert';
|
||||||
|
import * as $ from 'svelte/internal/client';
|
||||||
|
import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js';
|
||||||
|
|
||||||
|
// Like `kairo_broad`, but each derived is also read by a block effect, as
|
||||||
|
// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better.
|
||||||
|
export default () => {
|
||||||
|
let head = $.state(0);
|
||||||
|
let last = head;
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
const destroy = $.effect_root(() => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
let current = $.derived(() => {
|
||||||
|
return $.get(head) + i;
|
||||||
|
});
|
||||||
|
let current2 = $.derived(() => {
|
||||||
|
return $.get(current) + 1;
|
||||||
|
});
|
||||||
|
block(() => {
|
||||||
|
$.get(current2);
|
||||||
|
});
|
||||||
|
$.render_effect(() => {
|
||||||
|
$.get(current2);
|
||||||
|
counter++;
|
||||||
|
});
|
||||||
|
last = current2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy,
|
||||||
|
run() {
|
||||||
|
$.flush(() => {
|
||||||
|
$.set(head, 1);
|
||||||
|
});
|
||||||
|
counter = 0;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
$.flush(() => {
|
||||||
|
$.set(head, i);
|
||||||
|
});
|
||||||
|
assert.equal($.get(last), i + 50);
|
||||||
|
}
|
||||||
|
assert.equal(counter, 50 * 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import assert from 'node:assert';
|
||||||
|
import * as $ from 'svelte/internal/client';
|
||||||
|
import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js';
|
||||||
|
|
||||||
|
let len = 50;
|
||||||
|
const iter = 50;
|
||||||
|
|
||||||
|
// Like `kairo_deep`, but the derived chain is also read by a block effect, as
|
||||||
|
// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better.
|
||||||
|
export default () => {
|
||||||
|
let head = $.state(0);
|
||||||
|
let current = head;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
let c = current;
|
||||||
|
current = $.derived(() => {
|
||||||
|
return $.get(c) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
const destroy = $.effect_root(() => {
|
||||||
|
block(() => {
|
||||||
|
$.get(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
$.render_effect(() => {
|
||||||
|
$.get(current);
|
||||||
|
counter++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy,
|
||||||
|
run() {
|
||||||
|
$.flush(() => {
|
||||||
|
$.set(head, 1);
|
||||||
|
});
|
||||||
|
counter = 0;
|
||||||
|
for (let i = 0; i < iter; i++) {
|
||||||
|
$.flush(() => {
|
||||||
|
$.set(head, i);
|
||||||
|
});
|
||||||
|
assert.equal($.get(current), len + i);
|
||||||
|
}
|
||||||
|
assert.equal(counter, iter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,741 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Benchmark comparison</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: #f6f8fa;
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(1600px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px clamp(16px, 3vw, 48px) 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: clamp(28px, 4vw, 48px);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
max-width: 780px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: #59636e;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 8px 24px rgb(140 149 159 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #59636e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-detail {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: #59636e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid #d8dee4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2,
|
||||||
|
.panel-heading p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
max-width: 720px;
|
||||||
|
color: #59636e;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid #d8dee4;
|
||||||
|
background: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #59636e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
max-width: 250px;
|
||||||
|
padding: 7px 30px 7px 9px;
|
||||||
|
border: 1px solid #afb8c1;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2328;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-right: 1px solid #d8dee4;
|
||||||
|
border-bottom: 1px solid #d8dee4;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:last-child,
|
||||||
|
td:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f6f8fa;
|
||||||
|
color: #59636e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
th button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-align: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
th button:hover,
|
||||||
|
th button:focus-visible {
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standings td:nth-child(n + 2),
|
||||||
|
.standings th:nth-child(n + 2) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 10px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eaeef2;
|
||||||
|
color: #424a53;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-name,
|
||||||
|
.benchmark-name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benchmark-table {
|
||||||
|
min-width: max(900px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.benchmark-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benchmark-table th:first-child,
|
||||||
|
.benchmark-table td:first-child {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
min-width: 220px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benchmark-table th:first-child {
|
||||||
|
z-index: 4;
|
||||||
|
background: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benchmark-table th:nth-child(2),
|
||||||
|
.benchmark-table td:nth-child(2) {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benchmark-table th:not(:first-child),
|
||||||
|
.benchmark-table td:not(:first-child) {
|
||||||
|
min-width: 175px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background: hsl(var(--heat, 210) 74% 94%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
inset: auto 0 0;
|
||||||
|
height: 3px;
|
||||||
|
background: hsl(var(--heat, 210) 65% 43%);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta {
|
||||||
|
margin-left: 7px;
|
||||||
|
color: #424a53;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #59636e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner {
|
||||||
|
color: #1a7f37;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing {
|
||||||
|
background: #f6f8fa;
|
||||||
|
color: #6e7781;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
color: #6e7781;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
main {
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls,
|
||||||
|
label {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Benchmark comparison</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Runtime results across branches. Green cells are fastest for an entry and red cells expose
|
||||||
|
the largest regressions. Overall runtime normalizes every benchmark to its fastest result
|
||||||
|
before averaging, so long-running entries do not outweigh short ones.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="summary" id="summary" aria-label="Comparison summary"></section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Branch standings</h2>
|
||||||
|
<p class="help">
|
||||||
|
Wins count the fastest branch for each comparable entry. Normalized runtime is the
|
||||||
|
average slowdown against each entry's fastest result; lower is better. Click a heading
|
||||||
|
to sort.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="standings">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><button type="button" data-standing-sort="name">Branch</button></th>
|
||||||
|
<th><button type="button" data-standing-sort="wins">Wins</button></th>
|
||||||
|
<th>
|
||||||
|
<button type="button" data-standing-sort="score">Normalized runtime</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="standings"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Results by benchmark</h2>
|
||||||
|
<p class="help">
|
||||||
|
Each cell shows runtime, difference from the winner, and GC time. Missing entries are
|
||||||
|
excluded from both standings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<label
|
||||||
|
>Sort benchmarks
|
||||||
|
<select id="benchmark-sort"></select
|
||||||
|
></label>
|
||||||
|
<label
|
||||||
|
>Order branches
|
||||||
|
<select id="branch-order">
|
||||||
|
<option value="name">Branch name</option>
|
||||||
|
<option value="wins">Overall winner by wins</option>
|
||||||
|
<option value="score" selected>Overall winner by normalized runtime</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="benchmark-table" id="benchmark-table"></table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer id="generated"></footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="application/json" id="report-data">
|
||||||
|
%%REPORT_DATA%%
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
const report = JSON.parse(document.querySelector('#report-data').textContent);
|
||||||
|
const number = new Intl.NumberFormat('en', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
});
|
||||||
|
const score_number = new Intl.NumberFormat('en', {
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
minimumFractionDigits: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
function analyze(benchmark) {
|
||||||
|
const complete =
|
||||||
|
benchmark.values.length === report.branches.length &&
|
||||||
|
benchmark.values.every(function (value) {
|
||||||
|
return value !== null && Number.isFinite(value.time) && value.time > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!complete) return { comparable: false, min: 0, max: 0, winners: [], ratios: [] };
|
||||||
|
|
||||||
|
const times = benchmark.values.map(function (value) {
|
||||||
|
return value.time;
|
||||||
|
});
|
||||||
|
const min = Math.min.apply(null, times);
|
||||||
|
const max = Math.max.apply(null, times);
|
||||||
|
const winners = [];
|
||||||
|
|
||||||
|
times.forEach(function (time, index) {
|
||||||
|
if (time === min) winners.push(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
comparable: true,
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
winners: winners,
|
||||||
|
ratios: times.map(function (time) {
|
||||||
|
return time / min;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyzed = report.benchmarks.map(function (benchmark, index) {
|
||||||
|
return { benchmark: benchmark, stats: analyze(benchmark), index: index };
|
||||||
|
});
|
||||||
|
const comparable = analyzed.filter(function (entry) {
|
||||||
|
return entry.stats.comparable;
|
||||||
|
});
|
||||||
|
const standings = report.branches.map(function (name, index) {
|
||||||
|
let wins = 0;
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
comparable.forEach(function (entry) {
|
||||||
|
if (entry.stats.winners.includes(index)) wins += 1;
|
||||||
|
score += entry.stats.ratios[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
index: index,
|
||||||
|
wins: wins,
|
||||||
|
score: comparable.length === 0 ? Infinity : score / comparable.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function compare_name(a, b) {
|
||||||
|
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function order_standings(key, direction) {
|
||||||
|
return standings.slice().sort(function (a, b) {
|
||||||
|
let result;
|
||||||
|
if (key === 'name') result = compare_name(a, b);
|
||||||
|
else result = a[key] - b[key];
|
||||||
|
return result === 0 ? compare_name(a, b) : result * direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function best(key, direction) {
|
||||||
|
const ordered = order_standings(key, direction);
|
||||||
|
if (ordered.length === 0) return [];
|
||||||
|
return ordered.filter(function (entry) {
|
||||||
|
return entry[key] === ordered[0][key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function names(entries) {
|
||||||
|
return entries
|
||||||
|
.map(function (entry) {
|
||||||
|
return entry.name;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function make(tag, class_name, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (class_name) node.className = class_name;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wins_best = best('wins', -1);
|
||||||
|
const score_best = best('score', 1);
|
||||||
|
const summary = document.querySelector('#summary');
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Most benchmark wins',
|
||||||
|
value: comparable.length === 0 ? 'No comparable results' : names(wins_best),
|
||||||
|
detail:
|
||||||
|
comparable.length === 0
|
||||||
|
? ''
|
||||||
|
: wins_best[0].wins + ' of ' + comparable.length + ' entries'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Best normalized runtime',
|
||||||
|
value:
|
||||||
|
score_best.length === 0 || !Number.isFinite(score_best[0].score)
|
||||||
|
? 'No comparable results'
|
||||||
|
: names(score_best),
|
||||||
|
detail:
|
||||||
|
score_best.length === 0 || !Number.isFinite(score_best[0].score)
|
||||||
|
? ''
|
||||||
|
: score_number.format(score_best[0].score) + 'x average runtime'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Coverage',
|
||||||
|
value: comparable.length + ' comparable entries',
|
||||||
|
detail:
|
||||||
|
report.branches.length +
|
||||||
|
' branches, ' +
|
||||||
|
(report.benchmarks.length - comparable.length) +
|
||||||
|
' incomplete entries'
|
||||||
|
}
|
||||||
|
].forEach(function (item) {
|
||||||
|
const card = make('article', 'card');
|
||||||
|
card.append(
|
||||||
|
make('div', 'card-label', item.label),
|
||||||
|
make('div', 'card-value', item.value),
|
||||||
|
make('div', 'card-detail', item.detail)
|
||||||
|
);
|
||||||
|
summary.append(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
let standing_sort = { key: 'score', direction: 1 };
|
||||||
|
|
||||||
|
function render_standings() {
|
||||||
|
const body = document.querySelector('#standings');
|
||||||
|
body.replaceChildren();
|
||||||
|
const score_order = order_standings('score', 1);
|
||||||
|
|
||||||
|
order_standings(standing_sort.key, standing_sort.direction).forEach(function (entry) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const branch = make('td');
|
||||||
|
branch.append(
|
||||||
|
make('span', 'rank', String(score_order.indexOf(entry) + 1)),
|
||||||
|
make('span', 'branch-name', entry.name)
|
||||||
|
);
|
||||||
|
row.append(
|
||||||
|
branch,
|
||||||
|
make('td', '', String(entry.wins)),
|
||||||
|
make(
|
||||||
|
'td',
|
||||||
|
'',
|
||||||
|
Number.isFinite(entry.score) ? score_number.format(entry.score) + 'x' : 'n/a'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
body.append(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-standing-sort]').forEach(function (button) {
|
||||||
|
const active = button.dataset.standingSort === standing_sort.key;
|
||||||
|
button
|
||||||
|
.closest('th')
|
||||||
|
.setAttribute(
|
||||||
|
'aria-sort',
|
||||||
|
active ? (standing_sort.direction === 1 ? 'ascending' : 'descending') : 'none'
|
||||||
|
);
|
||||||
|
button.textContent =
|
||||||
|
button.dataset.standingSort === 'name'
|
||||||
|
? 'Branch'
|
||||||
|
: button.dataset.standingSort === 'wins'
|
||||||
|
? 'Wins'
|
||||||
|
: 'Normalized runtime';
|
||||||
|
if (active) button.textContent += standing_sort.direction === 1 ? ' ↑' : ' ↓';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-standing-sort]').forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const key = button.dataset.standingSort;
|
||||||
|
if (standing_sort.key === key) standing_sort.direction *= -1;
|
||||||
|
else standing_sort = { key: key, direction: key === 'wins' ? -1 : 1 };
|
||||||
|
render_standings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const benchmark_sort = document.querySelector('#benchmark-sort');
|
||||||
|
[
|
||||||
|
['original', 'Original run order'],
|
||||||
|
['name', 'Benchmark name'],
|
||||||
|
['winner', 'Winner for entry'],
|
||||||
|
['spread', 'Largest spread']
|
||||||
|
]
|
||||||
|
.concat(
|
||||||
|
report.branches.map(function (branch, index) {
|
||||||
|
return ['branch:' + index, branch + ': slowest relative result'];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.forEach(function (option) {
|
||||||
|
const node = make('option', '', option[1]);
|
||||||
|
node.value = option[0];
|
||||||
|
benchmark_sort.append(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
function branch_order() {
|
||||||
|
const key = document.querySelector('#branch-order').value;
|
||||||
|
if (key === 'name') return order_standings('name', 1);
|
||||||
|
if (key === 'wins') return order_standings('wins', -1);
|
||||||
|
return order_standings('score', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function benchmark_order() {
|
||||||
|
const key = benchmark_sort.value;
|
||||||
|
return analyzed.slice().sort(function (a, b) {
|
||||||
|
if (key === 'original') return a.index - b.index;
|
||||||
|
if (key === 'name') return compare_name(a.benchmark, b.benchmark);
|
||||||
|
if (key === 'winner') {
|
||||||
|
const a_name = a.stats.comparable ? report.branches[a.stats.winners[0]] : '\uffff';
|
||||||
|
const b_name = b.stats.comparable ? report.branches[b.stats.winners[0]] : '\uffff';
|
||||||
|
return a_name.localeCompare(b_name) || compare_name(a.benchmark, b.benchmark);
|
||||||
|
}
|
||||||
|
if (key === 'spread') {
|
||||||
|
const a_spread = a.stats.comparable ? a.stats.max / a.stats.min : -1;
|
||||||
|
const b_spread = b.stats.comparable ? b.stats.max / b.stats.min : -1;
|
||||||
|
return b_spread - a_spread || compare_name(a.benchmark, b.benchmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = Number(key.slice('branch:'.length));
|
||||||
|
const a_ratio = a.stats.comparable ? a.stats.ratios[branch] : -1;
|
||||||
|
const b_ratio = b.stats.comparable ? b.stats.ratios[branch] : -1;
|
||||||
|
return b_ratio - a_ratio || compare_name(a.benchmark, b.benchmark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_benchmarks() {
|
||||||
|
const table = document.querySelector('#benchmark-table');
|
||||||
|
const branches = branch_order();
|
||||||
|
const head = document.createElement('thead');
|
||||||
|
const head_row = document.createElement('tr');
|
||||||
|
head_row.append(make('th', '', 'Benchmark'), make('th', '', 'Winner'));
|
||||||
|
branches.forEach(function (branch) {
|
||||||
|
head_row.append(make('th', '', branch.name));
|
||||||
|
});
|
||||||
|
head.append(head_row);
|
||||||
|
|
||||||
|
const body = document.createElement('tbody');
|
||||||
|
benchmark_order().forEach(function (entry) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.append(make('td', 'benchmark-name', entry.benchmark.name));
|
||||||
|
|
||||||
|
const winner_names = entry.stats.winners.map(function (index) {
|
||||||
|
return report.branches[index];
|
||||||
|
});
|
||||||
|
row.append(
|
||||||
|
make(
|
||||||
|
'td',
|
||||||
|
winner_names.length === 0 ? 'missing' : 'winner',
|
||||||
|
winner_names.length === 0 ? 'Incomplete' : winner_names.join(', ')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
branches.forEach(function (branch) {
|
||||||
|
const value = entry.benchmark.values[branch.index];
|
||||||
|
if (value === null || !Number.isFinite(value.time)) {
|
||||||
|
row.append(make('td', 'missing', 'Not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = make('td', entry.stats.comparable ? 'metric' : 'missing');
|
||||||
|
const time = make('span', 'time', number.format(value.time) + ' ms');
|
||||||
|
cell.append(time);
|
||||||
|
|
||||||
|
if (entry.stats.comparable) {
|
||||||
|
const ratio = entry.stats.ratios[branch.index];
|
||||||
|
const heat =
|
||||||
|
entry.stats.max === entry.stats.min
|
||||||
|
? 0
|
||||||
|
: (value.time - entry.stats.min) / (entry.stats.max - entry.stats.min);
|
||||||
|
cell.style.setProperty('--heat', String(Math.round(142 - heat * 137)));
|
||||||
|
cell.append(
|
||||||
|
make(
|
||||||
|
'span',
|
||||||
|
'delta',
|
||||||
|
ratio === 1 ? 'fastest' : '+' + number.format((ratio - 1) * 100) + '%'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gc = Number.isFinite(value.gc_time)
|
||||||
|
? number.format(value.gc_time) + ' ms'
|
||||||
|
: 'n/a';
|
||||||
|
cell.append(make('div', 'secondary', 'GC ' + gc));
|
||||||
|
row.append(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
body.append(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.replaceChildren(head, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
benchmark_sort.addEventListener('change', render_benchmarks);
|
||||||
|
document.querySelector('#branch-order').addEventListener('change', render_benchmarks);
|
||||||
|
document.querySelector('#generated').textContent =
|
||||||
|
'Generated ' + new Date(report.generated_at).toLocaleString();
|
||||||
|
|
||||||
|
render_standings();
|
||||||
|
render_benchmarks();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,18 +1,67 @@
|
|||||||
|
import { fork } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js';
|
import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js';
|
||||||
import { with_cpu_profile } from '../utils.js';
|
import { with_cpu_profile } from '../utils.js';
|
||||||
|
|
||||||
|
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR ?? null;
|
||||||
|
const single = process.env.BENCH_SINGLE;
|
||||||
|
|
||||||
|
if (single) {
|
||||||
|
// child mode — run a single benchmark and report the result to the parent
|
||||||
|
const benchmark = reactivity_benchmarks.find((b) => b.label === single);
|
||||||
|
|
||||||
|
if (!benchmark) {
|
||||||
|
throw new Error(`Unknown benchmark ${single}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
|
||||||
|
|
||||||
|
// exit via the callback so the message is guaranteed to be delivered
|
||||||
|
/** @type {NodeJS.Process} */ (process).send(result, () => process.exit(0));
|
||||||
|
} else {
|
||||||
|
// parent mode — run every benchmark in its own child process, so that
|
||||||
|
// heap/GC/JIT state from one benchmark cannot contaminate the others
|
||||||
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const results = [];
|
const results = [];
|
||||||
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR;
|
|
||||||
|
|
||||||
for (let i = 0; i < reactivity_benchmarks.length; i += 1) {
|
for (let i = 0; i < reactivity_benchmarks.length; i += 1) {
|
||||||
const benchmark = reactivity_benchmarks[i];
|
const benchmark = reactivity_benchmarks[i];
|
||||||
|
|
||||||
process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `);
|
process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `);
|
||||||
|
|
||||||
|
const result = await new Promise((fulfil, reject) => {
|
||||||
|
const child = fork(filename, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BENCH_SINGLE: benchmark.label
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @type {object | null} */
|
||||||
|
let message_received = null;
|
||||||
|
|
||||||
|
child.on('message', (message) => {
|
||||||
|
message_received = /** @type {object} */ (message);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
if (message_received === null) {
|
||||||
|
reject(new Error(`benchmark ${benchmark.label} exited with code ${code}`));
|
||||||
|
} else {
|
||||||
|
fulfil(message_received);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
benchmark: benchmark.label,
|
benchmark: benchmark.label,
|
||||||
...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()))
|
.../** @type {object} */ (result)
|
||||||
});
|
});
|
||||||
|
|
||||||
process.stderr.write('\x1b[2K\r');
|
process.stderr.write('\x1b[2K\r');
|
||||||
}
|
}
|
||||||
|
|
||||||
process.send(results);
|
/** @type {NodeJS.Process} */ (process).send(results);
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
# Compiler CPU profiling
|
||||||
|
|
||||||
|
Run the full test suite with V8 CPU profiling enabled in every Vitest worker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm profile:compiler
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments are passed to `pnpm test`, so a smaller smoke run can target a suite:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm profile:compiler snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Each run is stored under `benchmarking/.profiles/compiler-tests/<timestamp>`. Set
|
||||||
|
`SVELTE_PROFILE_NAME` to give a run a stable name; existing runs are never overwritten.
|
||||||
|
`benchmarking/.profiles/compiler-tests/latest.txt` contains the latest successful run name.
|
||||||
|
|
||||||
|
Each run contains:
|
||||||
|
|
||||||
|
- `raw/*.cpuprofile`: the original V8 profile from every Vitest worker
|
||||||
|
- `flamegraph.speedscope.json`: compiler-active stacks merged for use in
|
||||||
|
[Speedscope](https://www.speedscope.app/)
|
||||||
|
- `summary.md`: ranked self/inclusive hotspots and files
|
||||||
|
- `summary.json`: the same measurements for further analysis
|
||||||
|
- `manifest.json`: the command, revision, timestamps, and test result
|
||||||
|
|
||||||
|
The merged profile includes downstream callees while a compiler source frame is on the stack.
|
||||||
|
This captures parser, walker, and printer dependencies without including unrelated test runtime.
|
||||||
|
V8 reports garbage collection outside the originating stack, so the summary reports process-wide
|
||||||
|
GC separately rather than attributing it to the compiler.
|
||||||
|
|
||||||
|
The default sampling interval is 5 ms. It can be changed for shorter, focused runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SVELTE_CPU_PROF_INTERVAL=1000 pnpm profile:compiler snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing raw profiles can be analyzed again without rerunning tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node benchmarking/analyze-compiler-profile.js benchmarking/.profiles/compiler-tests/<run>
|
||||||
|
```
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { analyze_compiler_profiles } from './analyze-compiler-profile.js';
|
||||||
|
|
||||||
|
const root = path.resolve(fileURLToPath(import.meta.url), '../..');
|
||||||
|
const profiles_root = path.join(root, 'benchmarking/.profiles/compiler-tests');
|
||||||
|
const requested_name = process.env.SVELTE_PROFILE_NAME;
|
||||||
|
const run_name = safe(requested_name || new Date().toISOString().replaceAll(':', '-'));
|
||||||
|
const run_dir = path.join(profiles_root, run_name);
|
||||||
|
const raw_dir = path.join(run_dir, 'raw');
|
||||||
|
const test_args = process.argv.slice(2);
|
||||||
|
const sampling_interval = process.env.SVELTE_CPU_PROF_INTERVAL || '5000';
|
||||||
|
|
||||||
|
if (fs.existsSync(run_dir)) {
|
||||||
|
console.error(`Profile run already exists: ${path.relative(root, run_dir)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(raw_dir, { recursive: true });
|
||||||
|
|
||||||
|
const revision = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const manifest_file = path.join(run_dir, 'manifest.json');
|
||||||
|
const command = [
|
||||||
|
'pnpm',
|
||||||
|
'test',
|
||||||
|
'--execArgv=--cpu-prof',
|
||||||
|
`--execArgv=--cpu-prof-dir=${raw_dir}`,
|
||||||
|
`--execArgv=--cpu-prof-interval=${sampling_interval}`,
|
||||||
|
...test_args
|
||||||
|
];
|
||||||
|
|
||||||
|
write_manifest({
|
||||||
|
command,
|
||||||
|
revision: revision.status === 0 ? revision.stdout.trim() : null,
|
||||||
|
started_at,
|
||||||
|
status: 'running'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Compiler profiles will be written to ${path.relative(root, run_dir)}`);
|
||||||
|
|
||||||
|
const result = spawnSync(command[0], command.slice(1), {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
|
||||||
|
let analysis_error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
analyze_compiler_profiles(run_dir);
|
||||||
|
fs.writeFileSync(path.join(profiles_root, 'latest.txt'), `${run_name}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
analysis_error = error instanceof Error ? error.stack || error.message : String(error);
|
||||||
|
console.error(analysis_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_manifest({
|
||||||
|
command,
|
||||||
|
revision: revision.status === 0 ? revision.stdout.trim() : null,
|
||||||
|
started_at,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
status: result.status === 0 && analysis_error === null ? 'completed' : 'failed',
|
||||||
|
test_exit_code: result.status,
|
||||||
|
test_signal: result.signal,
|
||||||
|
analysis_error
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 0 && analysis_error === null) {
|
||||||
|
console.log(
|
||||||
|
`Compiler flame graph: ${path.relative(root, path.join(run_dir, 'flamegraph.speedscope.json'))}`
|
||||||
|
);
|
||||||
|
console.log(`Compiler hotspot summary: ${path.relative(root, path.join(run_dir, 'summary.md'))}`);
|
||||||
|
} else {
|
||||||
|
process.exitCode = result.status || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} value */
|
||||||
|
function safe(value) {
|
||||||
|
return value.replace(/[^a-z0-9._-]+/gi, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown>} manifest */
|
||||||
|
function write_manifest(manifest) {
|
||||||
|
fs.writeFileSync(manifest_file, `${JSON.stringify(manifest, null, '\t')}\n`);
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
|
||||||
|
|
||||||
|
| Feature | Chrome/Edge | Firefox | Safari |
|
||||||
|
| - | - | - | - |
|
||||||
|
| [`$state.snapshot`](/docs/svelte/$state#$state.snapshot) | 98 | 94 | 15.4 |
|
||||||
|
| [`bind:devicePixelContentBoxSize`](/docs/svelte/bind#Dimensions) | <span style="color: var(--sk-fg-4)">—</span> | 93 | not supported |
|
||||||
|
| [`flip` from `svelte/animate`](/docs/svelte/svelte-animate#flip) | <span style="color: var(--sk-fg-4)">—</span> | 126 | <span style="color: var(--sk-fg-4)">—</span> |
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
|
||||||
|
|
||||||
|
| Browser | Minimum version |
|
||||||
|
| - | - |
|
||||||
|
| Chrome/Edge | 87 |
|
||||||
|
| Firefox | 83 |
|
||||||
|
| Safari | 14 |
|
||||||
|
| Opera | 73 |
|
||||||
|
| Opera (Android) | 62 |
|
||||||
|
| Samsung Internet | 14.0 |
|
||||||
|
| Android WebView | 87 |
|
||||||
|
| Internet Explorer | not supported |
|
||||||
|
|
||||||
|
|
||||||
|
> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of 2020.
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: Browser support
|
||||||
|
---
|
||||||
|
|
||||||
|
The table below shows the minimum browser versions Svelte is expected to work in, derived from the browser APIs used by Svelte's internal code.
|
||||||
|
|
||||||
|
@include .generated/browser-support.md
|
||||||
|
|
||||||
|
This table only covers Svelte itself. It does not include [SvelteKit](/docs/kit), other Svelte libraries, or your own code.
|
||||||
|
|
||||||
|
## Exceptions
|
||||||
|
|
||||||
|
A few Svelte features require a higher minimum browser version. You'll only need to take the following table into consideration if you use these specific features.
|
||||||
|
|
||||||
|
@include .generated/browser-support-features.md
|
||||||
@ -0,0 +1,417 @@
|
|||||||
|
import ts from 'typescript';
|
||||||
|
import { features } from 'web-features';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps compat-key suffixes under `javascript.operators` and
|
||||||
|
* `javascript.statements` (and a few other `javascript.*` subtrees) to
|
||||||
|
* detection callbacks. The callback receives a TS AST node and returns
|
||||||
|
* true if that node represents the operator or statement.
|
||||||
|
*
|
||||||
|
* @type {Record<string, (node: ts.Node) => boolean>}
|
||||||
|
*/
|
||||||
|
const SYNTAX_PREDICATES = {
|
||||||
|
nullish_coalescing: (node) =>
|
||||||
|
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken,
|
||||||
|
nullish_coalescing_assignment: (node) =>
|
||||||
|
ts.isBinaryExpression(node) &&
|
||||||
|
node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken,
|
||||||
|
logical_or_assignment: (node) =>
|
||||||
|
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken,
|
||||||
|
logical_and_assignment: (node) =>
|
||||||
|
ts.isBinaryExpression(node) &&
|
||||||
|
node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken,
|
||||||
|
optional_chaining: (node) =>
|
||||||
|
(ts.isPropertyAccessExpression(node) ||
|
||||||
|
ts.isElementAccessExpression(node) ||
|
||||||
|
ts.isCallExpression(node)) &&
|
||||||
|
node.questionDotToken !== undefined,
|
||||||
|
spread: (node) => ts.isSpreadElement(node) || ts.isSpreadAssignment(node),
|
||||||
|
destructuring: (node) => ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node),
|
||||||
|
arrow_functions: (node) => ts.isArrowFunction(node),
|
||||||
|
try_catch_optional_binding: (node) =>
|
||||||
|
ts.isCatchClause(node) && node.variableDeclaration === undefined,
|
||||||
|
async_iteration: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
|
||||||
|
for_await: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
|
||||||
|
private_class_fields: (node) => ts.isPrivateIdentifier(node),
|
||||||
|
async_generator_function: (node) =>
|
||||||
|
(ts.isFunctionDeclaration(node) ||
|
||||||
|
ts.isFunctionExpression(node) ||
|
||||||
|
ts.isMethodDeclaration(node)) &&
|
||||||
|
node.asteriskToken !== undefined &&
|
||||||
|
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
|
||||||
|
generator_function: (node) =>
|
||||||
|
(ts.isFunctionDeclaration(node) ||
|
||||||
|
ts.isFunctionExpression(node) ||
|
||||||
|
ts.isMethodDeclaration(node)) &&
|
||||||
|
node.asteriskToken !== undefined &&
|
||||||
|
!(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
|
||||||
|
async_function: (node) =>
|
||||||
|
(ts.isFunctionDeclaration(node) ||
|
||||||
|
ts.isFunctionExpression(node) ||
|
||||||
|
ts.isArrowFunction(node) ||
|
||||||
|
ts.isMethodDeclaration(node)) &&
|
||||||
|
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false) &&
|
||||||
|
node.asteriskToken === undefined,
|
||||||
|
classes: (node) => ts.isClassDeclaration(node) || ts.isClassExpression(node),
|
||||||
|
let_const: (node) =>
|
||||||
|
ts.isVariableDeclarationList(node) &&
|
||||||
|
((node.flags & ts.NodeFlags.Let) !== 0 || (node.flags & ts.NodeFlags.Const) !== 0),
|
||||||
|
template_literals: (node) =>
|
||||||
|
ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk `web-features` once and partition every `compat_features` path
|
||||||
|
* into the lookup tables the AST walker uses.
|
||||||
|
*/
|
||||||
|
function build_detection_maps() {
|
||||||
|
/** @type {Map<string, string>} identifier name → feature_id */
|
||||||
|
const globals = new Map();
|
||||||
|
/** @type {Map<string, Map<string, string>>} type → member → feature_id */
|
||||||
|
const members = new Map();
|
||||||
|
/** @type {Map<string, string>} string-literal value → feature_id */
|
||||||
|
const string_literals = new Map();
|
||||||
|
/** @type {Array<{ predicate: (n: ts.Node) => boolean, feature_id: string }>} */
|
||||||
|
const syntax_predicates = [];
|
||||||
|
|
||||||
|
const add_member = (
|
||||||
|
/** @type {string} */ type,
|
||||||
|
/** @type {string} */ member,
|
||||||
|
/** @type {string} */ feature_id
|
||||||
|
) => {
|
||||||
|
let by_member = members.get(type);
|
||||||
|
if (!by_member) {
|
||||||
|
by_member = new Map();
|
||||||
|
members.set(type, by_member);
|
||||||
|
}
|
||||||
|
// Only set if not already present — the first feature claiming a
|
||||||
|
// (type, member) pair wins. (Multiple features can map to the same
|
||||||
|
// pair via duplicated compat paths; we just need any.)
|
||||||
|
if (!by_member.has(member)) by_member.set(member, feature_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [feature_id, feature] of Object.entries(features)) {
|
||||||
|
if (!('compat_features' in feature) || !feature.compat_features) continue;
|
||||||
|
for (const path of feature.compat_features) {
|
||||||
|
const parts = path.split('.');
|
||||||
|
|
||||||
|
// `api.X` → global identifier (constructor or function), e.g.
|
||||||
|
// `api.ResizeObserver`, `api.structuredClone`.
|
||||||
|
if (parts[0] === 'api' && parts.length === 2) {
|
||||||
|
globals.set(parts[1], feature_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `api.X.Y` → member access on type X. E.g.
|
||||||
|
// `api.HTMLElement.inert`, `api.ResizeObserverEntry.contentBoxSize`.
|
||||||
|
if (parts[0] === 'api' && parts.length === 3) {
|
||||||
|
add_member(parts[1], parts[2], feature_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `javascript.builtins.X` → global like Promise, Symbol, Proxy.
|
||||||
|
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 3) {
|
||||||
|
globals.set(parts[2], feature_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `javascript.builtins.X.Y` → method/property on type X.
|
||||||
|
// We also accept the `ArrayConstructor`-style mapping for static
|
||||||
|
// methods: when the AST walker sees `Array.from(...)`, the
|
||||||
|
// receiver type's symbol name is `ArrayConstructor`, not `Array`.
|
||||||
|
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 4) {
|
||||||
|
add_member(parts[2], parts[3], feature_id);
|
||||||
|
add_member(`${parts[2]}Constructor`, parts[3], feature_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `javascript.*` syntax: try the second segment first (covers
|
||||||
|
// `javascript.operators.X` and `javascript.statements.X`), then
|
||||||
|
// the last segment (covers `javascript.classes.private_class_fields`
|
||||||
|
// and similar).
|
||||||
|
if (parts[0] === 'javascript' && parts.length >= 3) {
|
||||||
|
const candidates = [parts[2], parts[parts.length - 1]];
|
||||||
|
for (const key of candidates) {
|
||||||
|
if (Object.hasOwn(SYNTAX_PREDICATES, key)) {
|
||||||
|
syntax_predicates.push({ predicate: SYNTAX_PREDICATES[key], feature_id });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { globals, members, string_literals, syntax_predicates };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAPS = build_detection_maps();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versions and friendly names for synthetic feature IDs registered via
|
||||||
|
* `register_extra_rules` — APIs the type-aware walker can see but that
|
||||||
|
* `web-features` doesn't (yet) catalogue. `versions_for_feature` consults
|
||||||
|
* this map before falling back to `web-features` lookups.
|
||||||
|
*
|
||||||
|
* @type {Map<string, { name: string, versions: Record<string, string | null>, baseline_year: number | null }>}
|
||||||
|
*/
|
||||||
|
const EXTRA_FEATURE_INFO = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register additional detection rules for APIs the `web-features` dataset
|
||||||
|
* doesn't track yet. Two rule shapes are accepted:
|
||||||
|
*
|
||||||
|
* - **Member access**: `{ receiver_type, member, ... }` — flags
|
||||||
|
* `expr.member` when `expr`'s type resolves to `receiver_type`.
|
||||||
|
* Equivalent to a `api.X.Y` rule auto-derived from web-features.
|
||||||
|
*
|
||||||
|
* - **String literal**: `{ string_literal, ... }` — flags any
|
||||||
|
* occurrence of the literal value in source. Used for API options
|
||||||
|
* that are string-typed (e.g. `{ box: 'device-pixel-content-box' }`).
|
||||||
|
* The walker can be tightened later with a contextual-type check if
|
||||||
|
* false positives ever surface; for now an exact match is enough
|
||||||
|
* (these strings are too specific to occur incidentally).
|
||||||
|
*
|
||||||
|
* Each rule contributes its `feature_id`, per-browser versions,
|
||||||
|
* baseline year, and display name to the shared lookup tables.
|
||||||
|
*
|
||||||
|
* @param {Array<{
|
||||||
|
* feature_id: string,
|
||||||
|
* name: string,
|
||||||
|
* baseline_year: number,
|
||||||
|
* versions: Record<string, string | null>,
|
||||||
|
* receiver_type?: string,
|
||||||
|
* member?: string,
|
||||||
|
* string_literal?: string
|
||||||
|
* }>} rules
|
||||||
|
*/
|
||||||
|
export function register_extra_rules(rules) {
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.receiver_type && rule.member) {
|
||||||
|
let by_member = MAPS.members.get(rule.receiver_type);
|
||||||
|
if (!by_member) {
|
||||||
|
by_member = new Map();
|
||||||
|
MAPS.members.set(rule.receiver_type, by_member);
|
||||||
|
}
|
||||||
|
// Don't overwrite an existing web-features rule; that's canonical.
|
||||||
|
if (!by_member.has(rule.member)) {
|
||||||
|
by_member.set(rule.member, rule.feature_id);
|
||||||
|
}
|
||||||
|
} else if (rule.string_literal) {
|
||||||
|
if (!MAPS.string_literals.has(rule.string_literal)) {
|
||||||
|
MAPS.string_literals.set(rule.string_literal, rule.feature_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXTRA_FEATURE_INFO.set(rule.feature_id, {
|
||||||
|
name: rule.name,
|
||||||
|
baseline_year: rule.baseline_year,
|
||||||
|
versions: rule.versions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile bundle files into a single `ts.Program` so type-checking is
|
||||||
|
* amortised across all of them.
|
||||||
|
*
|
||||||
|
* @param {string[]} files
|
||||||
|
*/
|
||||||
|
function build_program(files) {
|
||||||
|
const program = ts.createProgram(files, {
|
||||||
|
allowJs: true,
|
||||||
|
checkJs: false,
|
||||||
|
target: ts.ScriptTarget.ESNext,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||||
|
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
|
||||||
|
strict: false,
|
||||||
|
noEmit: true,
|
||||||
|
skipLibCheck: true,
|
||||||
|
isolatedModules: true,
|
||||||
|
noErrorTruncation: true
|
||||||
|
});
|
||||||
|
const checker = program.getTypeChecker();
|
||||||
|
return { program, checker };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the names of a type and its base types so a `member` lookup
|
||||||
|
* keyed on (say) `HTMLElement` matches a receiver typed as
|
||||||
|
* `HTMLDivElement`. Also includes the apparent type to catch primitives
|
||||||
|
* (`'foo'` apparent-types to `String`).
|
||||||
|
*
|
||||||
|
* @param {ts.Type} type
|
||||||
|
* @param {ts.TypeChecker} checker
|
||||||
|
*/
|
||||||
|
function get_type_names(type, checker) {
|
||||||
|
const names = new Set();
|
||||||
|
const constituents = type.isUnionOrIntersection() ? type.types : [type];
|
||||||
|
for (const t of constituents) {
|
||||||
|
const symbol = t.getSymbol() ?? t.aliasSymbol;
|
||||||
|
if (symbol) names.add(symbol.getName());
|
||||||
|
for (const base of t.getBaseTypes?.() ?? []) {
|
||||||
|
const base_symbol = base.getSymbol() ?? base.aliasSymbol;
|
||||||
|
if (base_symbol) names.add(base_symbol.getName());
|
||||||
|
}
|
||||||
|
const apparent = checker.getApparentType(t);
|
||||||
|
if (apparent && apparent !== t) {
|
||||||
|
const apparent_symbol = apparent.getSymbol() ?? apparent.aliasSymbol;
|
||||||
|
if (apparent_symbol) names.add(apparent_symbol.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap test for whether a symbol refers to a global binding (declared
|
||||||
|
* in a lib.d.ts or ambient module) rather than a user-defined local.
|
||||||
|
*
|
||||||
|
* @param {ts.Symbol | undefined} symbol
|
||||||
|
*/
|
||||||
|
function is_global_binding(symbol) {
|
||||||
|
if (!symbol) return true; // unresolved → assume global
|
||||||
|
const declarations = symbol.getDeclarations() ?? [];
|
||||||
|
if (declarations.length === 0) return true;
|
||||||
|
for (const decl of declarations) {
|
||||||
|
const file_name = decl.getSourceFile().fileName;
|
||||||
|
if (file_name.includes('/lib.') && file_name.endsWith('.d.ts')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a TS source file emitting feature IDs as they're discovered.
|
||||||
|
*
|
||||||
|
* @param {ts.SourceFile} source
|
||||||
|
* @param {ts.TypeChecker | null} checker
|
||||||
|
* When `null`, type-aware checks (member access) are skipped. Used for
|
||||||
|
* the compiler-output fixtures which are parsed without a Program.
|
||||||
|
* @param {(feature_id: string) => void} emit
|
||||||
|
*/
|
||||||
|
function walk_source(source, checker, emit) {
|
||||||
|
/** @param {ts.Node} node */
|
||||||
|
function visit(node) {
|
||||||
|
// Syntax predicates run regardless of whether we have a checker.
|
||||||
|
for (const { predicate, feature_id } of MAPS.syntax_predicates) {
|
||||||
|
if (predicate(node)) emit(feature_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global identifier detection.
|
||||||
|
if (
|
||||||
|
ts.isIdentifier(node) &&
|
||||||
|
!(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) &&
|
||||||
|
!ts.isPropertyAssignment(node.parent) &&
|
||||||
|
!ts.isMethodDeclaration(node.parent) &&
|
||||||
|
!ts.isPropertySignature(node.parent)
|
||||||
|
) {
|
||||||
|
const feature_id = MAPS.globals.get(node.text);
|
||||||
|
if (feature_id) {
|
||||||
|
if (!checker || is_global_binding(checker.getSymbolAtLocation(node))) {
|
||||||
|
emit(feature_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String-literal detection. The walker matches by value alone;
|
||||||
|
// false positives are theoretically possible but the literal
|
||||||
|
// values we care about (e.g. `'device-pixel-content-box'`) are
|
||||||
|
// distinctive enough that one hasn't been observed.
|
||||||
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||||
|
const feature_id = MAPS.string_literals.get(node.text);
|
||||||
|
if (feature_id) emit(feature_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member access detection (requires the checker).
|
||||||
|
if (checker && ts.isPropertyAccessExpression(node)) {
|
||||||
|
const member_name = node.name.text;
|
||||||
|
const receiver_type = checker.getTypeAtLocation(node.expression);
|
||||||
|
const type_names = get_type_names(receiver_type, checker);
|
||||||
|
for (const type_name of type_names) {
|
||||||
|
const by_member = MAPS.members.get(type_name);
|
||||||
|
if (!by_member) continue;
|
||||||
|
const feature_id = by_member.get(member_name);
|
||||||
|
if (feature_id) {
|
||||||
|
emit(feature_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect features used in a set of bundle files. Returns the union of
|
||||||
|
* web-features IDs flagged across all files.
|
||||||
|
*
|
||||||
|
* @param {string[]} bundle_files Absolute paths to JS/TS files.
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function detect_features(bundle_files) {
|
||||||
|
const { program, checker } = build_program(bundle_files);
|
||||||
|
const flagged = new Set();
|
||||||
|
for (const file of bundle_files) {
|
||||||
|
const source = program.getSourceFile(file);
|
||||||
|
if (!source) continue;
|
||||||
|
walk_source(source, checker, (id) => flagged.add(id));
|
||||||
|
}
|
||||||
|
return flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect features used in a single in-memory source string. No type
|
||||||
|
* checker (so member-based rules silently skip) — useful for the
|
||||||
|
* compiler-output fixtures, where syntax-level detection is sufficient.
|
||||||
|
*
|
||||||
|
* @param {string} source_text
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function detect_features_in_text(source_text) {
|
||||||
|
const source = ts.createSourceFile(
|
||||||
|
'fixture.js',
|
||||||
|
source_text,
|
||||||
|
ts.ScriptTarget.ESNext,
|
||||||
|
true,
|
||||||
|
ts.ScriptKind.JS
|
||||||
|
);
|
||||||
|
const flagged = new Set();
|
||||||
|
walk_source(source, null, (id) => flagged.add(id));
|
||||||
|
return flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-browser minimum versions for a feature ID. Consults
|
||||||
|
* supplemental rules first (from `register_extra_rules`), then falls
|
||||||
|
* back to `web-features`. Returns null when neither has data.
|
||||||
|
*
|
||||||
|
* @param {string} feature_id
|
||||||
|
*/
|
||||||
|
export function versions_for_feature(feature_id) {
|
||||||
|
const extra = EXTRA_FEATURE_INFO.get(feature_id);
|
||||||
|
if (extra) return extra.versions;
|
||||||
|
const feature = features[feature_id];
|
||||||
|
if (!feature || !('status' in feature)) return null;
|
||||||
|
return /** @type {Record<string, string> | null} */ (
|
||||||
|
/** @type {unknown} */ (feature.status.support)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baseline year for a feature. Returns `null` for features without a
|
||||||
|
* Baseline date (limited availability, supplemental rules without a
|
||||||
|
* year, or absent from the dataset).
|
||||||
|
*
|
||||||
|
* @param {string} feature_id
|
||||||
|
*/
|
||||||
|
export function baseline_year_for_feature(feature_id) {
|
||||||
|
const extra = EXTRA_FEATURE_INFO.get(feature_id);
|
||||||
|
if (extra) return extra.baseline_year;
|
||||||
|
const feature = features[feature_id];
|
||||||
|
if (!feature || !('status' in feature)) return null;
|
||||||
|
const status = /** @type {{ baseline_low_date?: string }} */ (feature.status);
|
||||||
|
if (!status.baseline_low_date) return null;
|
||||||
|
return Number(status.baseline_low_date.slice(0, 4));
|
||||||
|
}
|
||||||
@ -0,0 +1,877 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
// Regenerates `documentation/docs/07-misc/05-browser-support.md`.
|
||||||
|
//
|
||||||
|
// Pipeline:
|
||||||
|
// 1. Bundle each runtime entry point with rollup using production export
|
||||||
|
// conditions, then walk the resulting JS with TypeScript's compiler
|
||||||
|
// API + TypeChecker. The walker (see `browser-support.detector.js`)
|
||||||
|
// flags any web-features ID the runtime references.
|
||||||
|
// 2. Verify each entry in `BEHAVIORAL_IGNORE` is still flagged by the
|
||||||
|
// detector — if not, the entry can be removed.
|
||||||
|
// 3. Enumerate every user-facing feature (each `bind:*`, every public
|
||||||
|
// subpackage export, every rune from the compiler's `RUNES` array,
|
||||||
|
// and the handful of directives that need their own fixtures). For
|
||||||
|
// each: compile, bundle, walk. If the bundle requires browser
|
||||||
|
// versions newer than the runtime floor, emit a row in the
|
||||||
|
// conditional-features table. Blind-spot regexes pick up APIs the
|
||||||
|
// AST walker can't see (string-literal constructor options,
|
||||||
|
// `getComputedStyle(...).zoom` reads).
|
||||||
|
// 4. Translate floors into concrete browser versions via `web-features`
|
||||||
|
// data (exact per-feature versions), falling back to
|
||||||
|
// `baseline-browser-mapping` for year-only resolution.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { rollup, type OutputChunk } from 'rollup';
|
||||||
|
import virtual from '@rollup/plugin-virtual';
|
||||||
|
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||||
|
import { getCompatibleVersions } from 'baseline-browser-mapping';
|
||||||
|
import {
|
||||||
|
detect_features,
|
||||||
|
detect_features_in_text,
|
||||||
|
versions_for_feature,
|
||||||
|
baseline_year_for_feature,
|
||||||
|
register_extra_rules
|
||||||
|
} from './browser-support.detector.js';
|
||||||
|
import { binding_properties } from '../src/compiler/phases/bindings.js';
|
||||||
|
import { RUNES } from '../src/utils.js';
|
||||||
|
import { compile as svelte_compile } from '../src/compiler/index.js';
|
||||||
|
|
||||||
|
type BindingProperty = import('../src/compiler/phases/bindings.js').BindingProperty;
|
||||||
|
type PackageExport = string | { browser?: string; default?: string };
|
||||||
|
type CompilerFixture = { filename: string; code: string };
|
||||||
|
type Feature = { name: string; source: string; kind: 'svelte' | 'js' };
|
||||||
|
type BrowserVersions = Record<string, string | null>;
|
||||||
|
type RuntimeFloor = number | 'newly';
|
||||||
|
type ConditionalRow = {
|
||||||
|
name: string;
|
||||||
|
doc_link: string | null;
|
||||||
|
versions: BrowserVersions;
|
||||||
|
baseline_year: RuntimeFloor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doc_links: Record<string, string | null> = {
|
||||||
|
'`$state.snapshot`': '/docs/svelte/$state#$state.snapshot',
|
||||||
|
'`bind:devicePixelContentBoxSize`': '/docs/svelte/bind#Dimensions',
|
||||||
|
'`flip` from `svelte/animate`': '/docs/svelte/svelte-animate#flip'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supplemental detection rules for APIs `web-features` doesn't track
|
||||||
|
// yet. Each rule is checked with full TS type-aware precision — the
|
||||||
|
// only reason it lives here instead of being auto-derived is that no
|
||||||
|
// compat key in `web-features` covers the API.
|
||||||
|
register_extra_rules([
|
||||||
|
{
|
||||||
|
// `getComputedStyle(current).zoom` walk in `svelte/animate`'s
|
||||||
|
// `flip` fallback path. Firefox didn't expose `.zoom` on
|
||||||
|
// `CSSStyleDeclaration` until v126 (May 2024) — pre-126 the read
|
||||||
|
// yields an empty string, breaking the animation math. No entry
|
||||||
|
// for it exists in `web-features` (CSS `zoom` is an IDL accessor
|
||||||
|
// without its own Baseline feature record).
|
||||||
|
receiver_type: 'CSSStyleDeclaration',
|
||||||
|
member: 'zoom',
|
||||||
|
feature_id: 'extra:css-zoom-read',
|
||||||
|
name: 'CSS zoom property reads (getComputedStyle(...).zoom)',
|
||||||
|
baseline_year: 2024,
|
||||||
|
versions: { firefox: '126' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// `box: 'device-pixel-content-box'` in `bind_resize_observer`
|
||||||
|
// (size.js). The TS DOM lib declares the option value as part of
|
||||||
|
// the `ResizeObserverBoxOptions` union — we could check the
|
||||||
|
// contextual type, but matching the literal value is enough since
|
||||||
|
// the string is too specific to occur incidentally. Per MDN BCD:
|
||||||
|
// - constructor option: Chrome 84, Firefox 93, Safari 15.4
|
||||||
|
// - `ResizeObserverEntry.devicePixelContentBoxSize`: Safari NOT
|
||||||
|
// SUPPORTED (`version_added: false`)
|
||||||
|
// Safari therefore silently accepts the option from 15.4 onwards
|
||||||
|
// but never exposes the matching entry property, so the binding
|
||||||
|
// reads `undefined` on any Safari.
|
||||||
|
string_literal: 'device-pixel-content-box',
|
||||||
|
feature_id: 'extra:device-pixel-content-box',
|
||||||
|
name: 'ResizeObserver `box: device-pixel-content-box` option + `entry.devicePixelContentBoxSize`',
|
||||||
|
baseline_year: 2023,
|
||||||
|
versions: {
|
||||||
|
chrome: '84',
|
||||||
|
edge: '84',
|
||||||
|
firefox: '93',
|
||||||
|
safari: null,
|
||||||
|
safari_ios: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pkg_dir = path.resolve(__dirname, '..');
|
||||||
|
const repo_root = path.resolve(pkg_dir, '..', '..');
|
||||||
|
const docs_dir = path.join(repo_root, 'documentation/docs/07-misc/.generated');
|
||||||
|
const snapshot_dir = path.join(pkg_dir, 'tests/snapshot/samples');
|
||||||
|
const tmp_dir = path.join(__dirname, '_baseline');
|
||||||
|
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(pkg_dir, 'package.json'), 'utf-8')) as {
|
||||||
|
exports: Record<string, PackageExport>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppressions that should NEVER affect the floor regardless of whether
|
||||||
|
* the runtime currently uses the API. Two reasons an entry belongs here:
|
||||||
|
*
|
||||||
|
* - The `web-features` dataset misclassifies the API (e.g.
|
||||||
|
* `devicepixelratio` is marked Baseline `false` because Safari is
|
||||||
|
* missing from its `support` map, but the property has shipped in
|
||||||
|
* every Safari for over a decade).
|
||||||
|
* - Svelte feature-detects the API at runtime with `?.` and degrades
|
||||||
|
* gracefully when it's unavailable. Example: `trusted-types` in
|
||||||
|
* `src/internal/client/dom/reconciler.js`.
|
||||||
|
*
|
||||||
|
* These are exempt from the staleness check.
|
||||||
|
*/
|
||||||
|
const SAFE_TO_IGNORE = new Set(['devicepixelratio', 'trusted-types']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppressions for features that DO live in the runtime but are reached
|
||||||
|
* only via a specific code path documented in the per-feature table on
|
||||||
|
* the docs page.
|
||||||
|
*/
|
||||||
|
const BEHAVIORAL_IGNORE = new Set([
|
||||||
|
'structured-clone',
|
||||||
|
'extra:css-zoom-read',
|
||||||
|
'extra:device-pixel-content-box'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Aggregate ignore set — used for the headline floor. */
|
||||||
|
const AGGREGATE_IGNORE = new Set([...SAFE_TO_IGNORE, ...BEHAVIORAL_IGNORE]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subpaths in `pkg.exports` whose runtime is Node-only. Can't be derived
|
||||||
|
* from the exports map alone — `./compiler` has a `require:` field that
|
||||||
|
* hints at CJS, but `./server` and `./internal/server` are plain `default`
|
||||||
|
* entries indistinguishable from a browser module.
|
||||||
|
*/
|
||||||
|
const NODE_ONLY_EXPORTS = new Set(['./compiler', './server', './internal/server']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every subpath in `pkg.exports` that ships browser JS. Type-only entries
|
||||||
|
* (`./action`, `./elements`) and the `./package.json` re-export filter out
|
||||||
|
* naturally on the `.js` check; only Node-only subpaths need an explicit
|
||||||
|
* exception, so new browser exports are picked up automatically.
|
||||||
|
*/
|
||||||
|
function browser_subpaths(): string[] {
|
||||||
|
const subpaths: string[] = [];
|
||||||
|
for (const [subpath, conditions] of Object.entries(pkg.exports)) {
|
||||||
|
if (NODE_ONLY_EXPORTS.has(subpath)) continue;
|
||||||
|
if (typeof conditions !== 'object' || conditions === null) continue;
|
||||||
|
const file = conditions.browser ?? conditions.default;
|
||||||
|
if (typeof file !== 'string' || !file.endsWith('.js')) continue;
|
||||||
|
subpaths.push(subpath);
|
||||||
|
}
|
||||||
|
return subpaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `.` → `svelte`, `./animate` → `svelte/animate`, etc.
|
||||||
|
*/
|
||||||
|
function importee_for(subpath: string): string {
|
||||||
|
return subpath === '.' ? 'svelte' : `svelte${subpath.slice(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if a subpath represents a public, user-facing subpackage whose
|
||||||
|
* named exports should each get their own per-feature fixture. Excludes
|
||||||
|
* the main entry (covered by the aggregate scan), the `./legacy` shim,
|
||||||
|
* and everything under `./internal/`.
|
||||||
|
*/
|
||||||
|
function is_public_subpackage(subpath: string): boolean {
|
||||||
|
return subpath !== '.' && subpath !== './legacy' && !subpath.startsWith('./internal');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each public subpackage, dynamically import the module and return
|
||||||
|
* its named exports. Driven entirely by `pkg.exports`, so a new
|
||||||
|
* subpackage is picked up the next time the script runs.
|
||||||
|
*/
|
||||||
|
async function enumerate_subpackage_exports(): Promise<Record<string, string[]>> {
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
for (const subpath of browser_subpaths()) {
|
||||||
|
if (!is_public_subpackage(subpath)) continue;
|
||||||
|
const module_id = importee_for(subpath);
|
||||||
|
try {
|
||||||
|
const ns = await import(module_id);
|
||||||
|
const names = Object.keys(ns)
|
||||||
|
.filter((k) => k !== 'default')
|
||||||
|
.sort();
|
||||||
|
if (names.length > 0) result[module_id] = names;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(` (could not enumerate ${module_id}: ${message.split('\n')[0]})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rune_fixtures: Record<(typeof RUNES)[number], string> = {
|
||||||
|
$state: `<script>let v = $state(0); console.log(v);</script>`,
|
||||||
|
'$state.raw': `<script>let v = $state.raw({}); console.log(v);</script>`,
|
||||||
|
'$state.eager': `<script>let v = $state.eager(0); console.log(v);</script>`,
|
||||||
|
'$state.snapshot': `<script>const v = $state({}); const snap = $state.snapshot(v); console.log(snap);</script>`,
|
||||||
|
$derived: `<script>let a = $state(0); let d = $derived(a + 1); console.log(d);</script>`,
|
||||||
|
'$derived.by': `<script>let a = $state(0); let d = $derived.by(() => a + 1); console.log(d);</script>`,
|
||||||
|
$props: `<script>let { x } = $props(); console.log(x);</script>`,
|
||||||
|
'$props.id': `<script>const id = $props.id(); console.log(id);</script>`,
|
||||||
|
$bindable: `<script>let { v = $bindable() } = $props(); console.log(v);</script>`,
|
||||||
|
$effect: `<script>$effect(() => { console.log('e'); });</script>`,
|
||||||
|
'$effect.pre': `<script>$effect.pre(() => { console.log('p'); });</script>`,
|
||||||
|
'$effect.tracking': `<script>$effect(() => { console.log($effect.tracking()); });</script>`,
|
||||||
|
'$effect.root': `<script>const stop = $effect.root(() => () => {}); stop();</script>`,
|
||||||
|
'$effect.pending': `<script>$effect(() => { console.log($effect.pending()); });</script>`,
|
||||||
|
$inspect: `<script>let v = $state(0); $inspect(v);</script>`,
|
||||||
|
'$inspect().with': `<script>let v = $state(0); $inspect(v).with(() => {});</script>`,
|
||||||
|
'$inspect.trace': `<script>$effect(() => { $inspect.trace(); });</script>`,
|
||||||
|
$host: `<svelte:options customElement="x-y" />\n<script>const h = $host(); console.log(h);</script>`
|
||||||
|
};
|
||||||
|
|
||||||
|
function rune_fixture(rune: (typeof RUNES)[number]): string {
|
||||||
|
if (!Object.hasOwn(rune_fixtures, rune)) {
|
||||||
|
throw new Error(`Fixture missing for ${rune}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rune_fixtures[rune];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiled-fixture sources for directives. Bindings are covered by the
|
||||||
|
* `binding_properties` enumeration; transitions, animate, actions, and
|
||||||
|
* `@attach` need explicit fixtures because they require accompanying
|
||||||
|
* imports or surrounding markup.
|
||||||
|
*/
|
||||||
|
const TESTED_DIRECTIVES = [
|
||||||
|
{
|
||||||
|
name: '`transition:` / `in:` / `out:`',
|
||||||
|
source: `<script>import { fade } from 'svelte/transition'; let show = $state(false);</script>{#if show}<div transition:fade></div>{/if}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '`animate:`',
|
||||||
|
source: `<script>const flip = () => {}; let items = $state([1,2,3]);</script>{#each items as item (item)}<div animate:flip>{item}</div>{/each}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '`use:` actions',
|
||||||
|
source: `<script>function action(node){return {destroy(){}}}</script><div use:action></div>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '`@attach`',
|
||||||
|
source: `<script>const attachment = (node) => () => {};</script><div {@attach attachment}></div>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '`{@html ...}`',
|
||||||
|
source: `<script>let html = $state('<b>x</b>');</script>{@html html}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Custom elements (`<svelte:options customElement>`)',
|
||||||
|
source: `<svelte:options customElement="my-el" />\n<div></div>`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem-safe identifier for an importee like `svelte/internal/client`.
|
||||||
|
*/
|
||||||
|
function safe_name(importee: string): string {
|
||||||
|
return importee.replace(/[^a-z0-9]+/gi, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle an entry the way users receive it, so we scan the same code the
|
||||||
|
* browser does. Mirrors `check-treeshakeability.js`.
|
||||||
|
*
|
||||||
|
* `entry_code` is virtual module source: typically `export * from
|
||||||
|
* 'svelte/...'` for a runtime entry, or compiled fixture JS for a per-feature
|
||||||
|
* scan. `silent` suppresses rollup's circular-dependency warnings — used for
|
||||||
|
* fixture bundles where they're known and noisy.
|
||||||
|
*/
|
||||||
|
async function bundle(entry_code: string, options: { silent?: boolean } = {}): Promise<string> {
|
||||||
|
const built = await rollup({
|
||||||
|
input: '__entry__',
|
||||||
|
plugins: [
|
||||||
|
virtual({ __entry__: entry_code }),
|
||||||
|
{
|
||||||
|
name: 'resolve-svelte',
|
||||||
|
resolveId(id: string) {
|
||||||
|
if (id.startsWith('svelte')) {
|
||||||
|
const entry = pkg.exports[id.replace('svelte', '.')];
|
||||||
|
if (!entry) return;
|
||||||
|
if (typeof entry === 'string') return path.resolve(pkg_dir, entry);
|
||||||
|
const file = entry.browser ?? entry.default;
|
||||||
|
if (file) return path.resolve(pkg_dir, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nodeResolve({ exportConditions: ['production', 'import', 'browser', 'default'] })
|
||||||
|
],
|
||||||
|
// Treat optional peers / Node-only branches as external so we only scan
|
||||||
|
// code that actually runs in the browser.
|
||||||
|
external: ['esm-env'],
|
||||||
|
onwarn: options.silent
|
||||||
|
? () => {}
|
||||||
|
: (warning, handler) => {
|
||||||
|
if (warning.code === 'CIRCULAR_DEPENDENCY') return;
|
||||||
|
handler(warning);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { output } = await built.generate({ format: 'esm' });
|
||||||
|
await built.close();
|
||||||
|
|
||||||
|
return output
|
||||||
|
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
|
||||||
|
.map((chunk) => chunk.code)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read every compiler-emitted client file from the snapshot tests. These
|
||||||
|
* fixtures cover the full range of patterns the compiler emits — bindings,
|
||||||
|
* transitions, `<svelte:element>`, async derived, hydration markers, etc.
|
||||||
|
*/
|
||||||
|
function load_compiler_output_fixtures(): CompilerFixture[] {
|
||||||
|
const fixtures: CompilerFixture[] = [];
|
||||||
|
|
||||||
|
for (const sample of fs.readdirSync(snapshot_dir)) {
|
||||||
|
const client_dir = path.join(snapshot_dir, sample, '_expected/client');
|
||||||
|
if (!fs.existsSync(client_dir)) continue;
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(client_dir)) {
|
||||||
|
if (!file.endsWith('.js')) continue;
|
||||||
|
fixtures.push({
|
||||||
|
filename: `${sample}/${file}`,
|
||||||
|
code: fs.readFileSync(path.join(client_dir, file), 'utf-8')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixtures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine per-feature version data into a single Record. Takes the max
|
||||||
|
* (strictest) version per browser across the input feature IDs.
|
||||||
|
*
|
||||||
|
* `null` propagates as "not supported" — if any contributing feature
|
||||||
|
* marks a browser unsupported, the merged record does too.
|
||||||
|
*
|
||||||
|
* Returns `null` if NONE of the IDs have versions in `web-features` or
|
||||||
|
* supplemental rules, so callers can fall back to year-based mapping.
|
||||||
|
*/
|
||||||
|
function versions_from_features(ids: Iterable<string>): BrowserVersions | null {
|
||||||
|
const merged: BrowserVersions = {};
|
||||||
|
let any_found = false;
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const support = versions_for_feature(id);
|
||||||
|
if (!support) continue;
|
||||||
|
any_found = true;
|
||||||
|
for (const [browser, version] of Object.entries(support)) {
|
||||||
|
const current = merged[browser];
|
||||||
|
// `null` means "not supported"; propagate it directly.
|
||||||
|
if (version === null) {
|
||||||
|
merged[browser] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === null) continue; // already known unsupported
|
||||||
|
if (current === undefined || Number(version) > Number(current)) {
|
||||||
|
merged[browser] = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return any_found ? merged : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highest baseline year among `detected`, and the set of feature IDs that
|
||||||
|
* drove it. Used both for the aggregate runtime floor and for per-fixture
|
||||||
|
* scans — the two differ only in their ignore set.
|
||||||
|
*/
|
||||||
|
function compute_floor(
|
||||||
|
detected: Iterable<string>,
|
||||||
|
ignore: Set<string>
|
||||||
|
): { year: number; drivers: Set<string> } {
|
||||||
|
let year = 0;
|
||||||
|
const drivers = new Set<string>();
|
||||||
|
for (const id of detected) {
|
||||||
|
if (ignore.has(id)) continue;
|
||||||
|
const y = baseline_year_for_feature(id);
|
||||||
|
if (!y) continue;
|
||||||
|
if (y > year) {
|
||||||
|
year = y;
|
||||||
|
drivers.clear();
|
||||||
|
}
|
||||||
|
if (y === year) drivers.add(id);
|
||||||
|
}
|
||||||
|
return { year, drivers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the TS-based detector across the runtime bundles and the compiler-
|
||||||
|
* output fixtures, then compute the highest baseline year among the
|
||||||
|
* detected features (after subtracting `AGGREGATE_IGNORE`).
|
||||||
|
*
|
||||||
|
* `runtime_files` are absolute paths to runtime bundle files. Returns the
|
||||||
|
* minimum Baseline year the combined code satisfies.
|
||||||
|
*/
|
||||||
|
function find_minimum_target(
|
||||||
|
runtime_files: string[],
|
||||||
|
compiler_fixtures: CompilerFixture[]
|
||||||
|
): number {
|
||||||
|
// Type-aware walk over the runtime bundles.
|
||||||
|
const detected = detect_features(runtime_files);
|
||||||
|
|
||||||
|
// Syntax-only walk over the compiler-output fixtures (text only, no
|
||||||
|
// program context; the bare TS source-file parser handles the syntax
|
||||||
|
// features the compiler emits).
|
||||||
|
for (const fixture of compiler_fixtures) {
|
||||||
|
for (const id of detect_features_in_text(fixture.code)) detected.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { year, drivers } = compute_floor(detected, AGGREGATE_IGNORE);
|
||||||
|
// Floor at 2015 so the docs never claim a pre-ES6 target if every
|
||||||
|
// detected feature happens to lack a Baseline year.
|
||||||
|
const final_year = Math.max(year, 2015);
|
||||||
|
|
||||||
|
console.log(` → ${final_year} (features that drove the floor:)`);
|
||||||
|
for (const id of [...drivers].sort()) {
|
||||||
|
console.log(` - ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return final_year;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify every entry in `BEHAVIORAL_IGNORE` is actually used by the
|
||||||
|
* runtime. Without this, a behavioural suppression can outlive the API
|
||||||
|
* it suppresses — the comment stays in the config pointing at code that
|
||||||
|
* no longer exists.
|
||||||
|
*
|
||||||
|
* `SAFE_TO_IGNORE` entries are exempt: they're safe to carry regardless
|
||||||
|
* of whether the runtime currently uses the API.
|
||||||
|
*/
|
||||||
|
function validate_ignore_features(runtime_files: string[]): void {
|
||||||
|
if (BEHAVIORAL_IGNORE.size === 0) return;
|
||||||
|
const detected = detect_features(runtime_files);
|
||||||
|
const stale = [...BEHAVIORAL_IGNORE].filter((id) => !detected.has(id));
|
||||||
|
if (stale.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`BEHAVIORAL_IGNORE contains entries that the detector does not flag — ` +
|
||||||
|
`they can be removed:\n` +
|
||||||
|
stale.map((id) => ` - ${id}`).join('\n') +
|
||||||
|
`\n\nEdit \`packages/svelte/scripts/generate-browser-support.js\` ` +
|
||||||
|
`and delete the stale entries. If the API was removed from the runtime ` +
|
||||||
|
`as part of this change, that is exactly the intended signal.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full list of user-facing features to test for conditional
|
||||||
|
* floor bumps. Each feature gets a self-contained fixture, compiled and
|
||||||
|
* bundled like real user code, then scanned. If the bundle's floor
|
||||||
|
* exceeds the runtime floor, a row is auto-emitted in the docs.
|
||||||
|
*
|
||||||
|
* `subpackage_exports` maps subpath → list of exported symbols, produced by
|
||||||
|
* `enumerate_subpackage_exports`. Passed in rather than computed here so the
|
||||||
|
* dynamic-import discovery can happen once in `main`.
|
||||||
|
*/
|
||||||
|
function enumerate_features(subpackage_exports: Record<string, string[]>): Feature[] {
|
||||||
|
const features: Feature[] = [];
|
||||||
|
|
||||||
|
// Every `bind:*` accepted by the compiler. Element selection respects
|
||||||
|
// the `valid_elements` constraint declared in `binding_properties`.
|
||||||
|
for (const [name, props] of Object.entries(binding_properties)) {
|
||||||
|
const fixture = binding_fixture(name, props);
|
||||||
|
if (fixture) {
|
||||||
|
features.push({
|
||||||
|
name: `\`bind:${name}\``,
|
||||||
|
kind: 'svelte',
|
||||||
|
source: fixture
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [module, exports] of Object.entries(subpackage_exports)) {
|
||||||
|
for (const exp of exports) {
|
||||||
|
features.push({
|
||||||
|
name: `\`${exp}\` from \`${module}\``,
|
||||||
|
kind: 'js',
|
||||||
|
source: `import { ${exp} } from '${module}'; export const _ = ${exp};`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rune of RUNES) {
|
||||||
|
features.push({
|
||||||
|
name: `\`${rune}\``,
|
||||||
|
kind: 'svelte',
|
||||||
|
source: rune_fixture(rune)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directive of TESTED_DIRECTIVES) {
|
||||||
|
features.push({ name: directive.name, kind: 'svelte', source: directive.source });
|
||||||
|
}
|
||||||
|
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce the `.svelte` source for a single binding fixture. Returns
|
||||||
|
* `null` for bindings the compiler treats as elements rather than
|
||||||
|
* properties (none currently, but defensive).
|
||||||
|
*/
|
||||||
|
function binding_fixture(name: string, props: BindingProperty): string {
|
||||||
|
// Map declared `valid_elements` to a concrete element + minimal attrs
|
||||||
|
// so the compiler accepts the binding.
|
||||||
|
const tag = (props.valid_elements ?? ['div'])[0];
|
||||||
|
|
||||||
|
const reactive = `let v = $state();`;
|
||||||
|
|
||||||
|
if (tag === 'svelte:window') {
|
||||||
|
return `<script>${reactive}</script><svelte:window bind:${name}={v} />`;
|
||||||
|
}
|
||||||
|
if (tag === 'svelte:document') {
|
||||||
|
return `<script>${reactive}</script><svelte:document bind:${name}={v} />`;
|
||||||
|
}
|
||||||
|
if (tag === 'input') {
|
||||||
|
// `bind:checked` and `bind:group` require type="checkbox" | "radio"
|
||||||
|
const type =
|
||||||
|
name === 'checked' || name === 'indeterminate'
|
||||||
|
? ' type="checkbox"'
|
||||||
|
: name === 'group'
|
||||||
|
? ' type="radio" value="a"'
|
||||||
|
: name === 'files'
|
||||||
|
? ' type="file"'
|
||||||
|
: '';
|
||||||
|
return `<script>${reactive}</script><input${type} bind:${name}={v} />`;
|
||||||
|
}
|
||||||
|
if (tag === 'details') {
|
||||||
|
return `<script>${reactive}</script><details bind:${name}={v}><summary>x</summary></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<script>${reactive}</script><${tag} bind:${name}={v}></${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a `.svelte` fixture to JS (no-op for `.js` fixtures), then
|
||||||
|
* bundle the result through the shared `bundle` helper. Fixtures are tiny
|
||||||
|
* so circular-dep warnings from the Svelte runtime are silenced.
|
||||||
|
*/
|
||||||
|
async function bundle_fixture(feature: Feature): Promise<string> {
|
||||||
|
const entry_code =
|
||||||
|
feature.kind === 'svelte'
|
||||||
|
? svelte_compile(feature.source, {
|
||||||
|
generate: 'client',
|
||||||
|
filename: 'Fixture.svelte',
|
||||||
|
dev: false
|
||||||
|
}).js.code
|
||||||
|
: feature.source;
|
||||||
|
return bundle(entry_code, { silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect features in a single fixture bundle and report the per-fixture
|
||||||
|
* floor year along with the IDs that drove it. Used for the per-feature
|
||||||
|
* conditional table.
|
||||||
|
*
|
||||||
|
* `fixture_file` is the absolute path to the `.ts` bundle.
|
||||||
|
*/
|
||||||
|
function scan_fixture(fixture_file: string): {
|
||||||
|
year: number;
|
||||||
|
driving_ids: string[];
|
||||||
|
} {
|
||||||
|
const { year, drivers } = compute_floor(detect_features([fixture_file]), SAFE_TO_IGNORE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
driving_ids: [...drivers]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate every feature, bundle its fixture, scan it. Return the rows
|
||||||
|
* that need to appear in the conditional-features table.
|
||||||
|
*/
|
||||||
|
async function find_all_conditional_features(
|
||||||
|
runtime_floor: RuntimeFloor,
|
||||||
|
subpackage_exports: Record<string, string[]>
|
||||||
|
): Promise<ConditionalRow[]> {
|
||||||
|
const runtime_year = typeof runtime_floor === 'number' ? runtime_floor : Infinity;
|
||||||
|
const features = enumerate_features(subpackage_exports);
|
||||||
|
const rows: ConditionalRow[] = [];
|
||||||
|
|
||||||
|
const missing_doc_links: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < features.length; i++) {
|
||||||
|
const feature = features[i];
|
||||||
|
process.stdout.write(`\r ${i + 1}/${features.length} ${feature.name}`.padEnd(80));
|
||||||
|
|
||||||
|
let bundle_code;
|
||||||
|
try {
|
||||||
|
bundle_code = await bundle_fixture(feature);
|
||||||
|
} catch {
|
||||||
|
continue; // some fixtures (rare element combos) may fail to compile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the bundle so the type-aware scanner can resolve its types.
|
||||||
|
const fixture_file = path.join(tmp_dir, `fixture_${i}.ts`);
|
||||||
|
fs.writeFileSync(fixture_file, bundle_code);
|
||||||
|
|
||||||
|
const scanned = scan_fixture(fixture_file);
|
||||||
|
const final_year = scanned.year;
|
||||||
|
|
||||||
|
// Skip features at or below the runtime floor — they don't need a row.
|
||||||
|
if (final_year <= runtime_year || final_year === 0) continue;
|
||||||
|
|
||||||
|
// Use exact per-feature versions where available (from web-features
|
||||||
|
// or supplemental rules), falling back to the conservative year
|
||||||
|
// mapping only if no feature has explicit version data.
|
||||||
|
let versions = versions_from_features(scanned.driving_ids);
|
||||||
|
if (!versions) {
|
||||||
|
try {
|
||||||
|
versions = browser_versions_for(final_year);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let doc_link = doc_links[feature.name];
|
||||||
|
if (doc_link === undefined) {
|
||||||
|
doc_link = null;
|
||||||
|
missing_doc_links.push(feature.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
name: feature.name,
|
||||||
|
doc_link,
|
||||||
|
versions,
|
||||||
|
baseline_year: final_year
|
||||||
|
});
|
||||||
|
}
|
||||||
|
process.stdout.write('\n');
|
||||||
|
|
||||||
|
if (missing_doc_links.length) {
|
||||||
|
throw new Error(`Missing documentation url for some features.
|
||||||
|
Add them to the \`doc_links\` map in \`scripts/generate-browser-support.ts\`, or add an explicit \`null\` if they don't have a documentation url.
|
||||||
|
${missing_doc_links.map((name) => ` - "${name}"`).join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_conditional_table(features: ConditionalRow[], runtime_floor: RuntimeFloor): string {
|
||||||
|
if (features.length === 0) {
|
||||||
|
return '_No features currently require browser versions newer than the runtime floor._';
|
||||||
|
}
|
||||||
|
features.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const browsers = [
|
||||||
|
['chrome', 'Chrome/Edge'],
|
||||||
|
['firefox', 'Firefox'],
|
||||||
|
['safari', 'Safari']
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const floor_versions = browser_versions_for(runtime_floor);
|
||||||
|
|
||||||
|
const rows: string[][] = [];
|
||||||
|
for (const row of features) {
|
||||||
|
const name_cell = row.doc_link ? `[${row.name}](${row.doc_link})` : row.name;
|
||||||
|
const versions = browsers.map(([key]) => {
|
||||||
|
const v = row.versions[key];
|
||||||
|
if (v === null) return 'not supported';
|
||||||
|
if (v === undefined) return '<span style="color: var(--sk-fg-4)">—</span>';
|
||||||
|
const floor_v = floor_versions[key];
|
||||||
|
if (floor_v && Number(v) <= Number(floor_v))
|
||||||
|
return '<span style="color: var(--sk-fg-4)">—</span>';
|
||||||
|
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
rows.push([name_cell, ...versions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_markdown_table(['Feature', ...browsers.map(([, label]) => `${label}`)], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function browser_versions_for(target: RuntimeFloor): Record<string, string> {
|
||||||
|
// `targetYear` returns the minimum versions in which every feature that
|
||||||
|
// reached Baseline by the end of that year is supported. If the lint
|
||||||
|
// search fell through to `'newly'`, we use the current year — that gives
|
||||||
|
// the most recent Newly-available cutoff, which is the strongest
|
||||||
|
// statement `baseline-browser-mapping` is able to make.
|
||||||
|
const target_year = typeof target === 'number' ? target : new Date().getFullYear();
|
||||||
|
|
||||||
|
const versions = getCompatibleVersions({
|
||||||
|
targetYear: target_year,
|
||||||
|
includeDownstreamBrowsers: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// The core Baseline browsers plus the downstream browsers worth listing
|
||||||
|
// in the docs. Downstream browsers come from `baseline-browser-mapping`'s
|
||||||
|
// dataset and represent the highest-traffic Chromium derivatives; the
|
||||||
|
// long tail (UC, QQ, Yandex, in-app Facebook/Instagram browsers, etc.)
|
||||||
|
// is omitted to keep the table focused.
|
||||||
|
const visible_browsers = new Set([
|
||||||
|
'chrome',
|
||||||
|
'chrome_android',
|
||||||
|
'edge',
|
||||||
|
'firefox',
|
||||||
|
'firefox_android',
|
||||||
|
'safari',
|
||||||
|
'safari_ios',
|
||||||
|
'opera',
|
||||||
|
'opera_android',
|
||||||
|
'samsunginternet_android',
|
||||||
|
'webview_android'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suffixes = ['_android', '_ios'];
|
||||||
|
|
||||||
|
const lookup: Record<string, string> = {};
|
||||||
|
outer: for (const { browser, version } of versions) {
|
||||||
|
if (visible_browsers.has(browser)) {
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
// skip e.g. 'Chrome (Android)' if it matches Chrome
|
||||||
|
if (browser.endsWith(suffix) && version === lookup[browser.replace(suffix, '')]) {
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup[browser] = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BROWSER = {
|
||||||
|
chrome: 'Chrome',
|
||||||
|
edge: 'Edge',
|
||||||
|
firefox: 'Firefox',
|
||||||
|
safari: 'Safari',
|
||||||
|
opera: 'Opera',
|
||||||
|
samsung_internet: 'Samsung Internet',
|
||||||
|
webview_android: 'Android WebView',
|
||||||
|
internet_explorer: 'Internet Explorer'
|
||||||
|
};
|
||||||
|
|
||||||
|
function render_browser_table(versions: Record<string, string>, target: RuntimeFloor): string {
|
||||||
|
const rows: Array<[string, string]> = [
|
||||||
|
[BROWSER.chrome, versions.chrome],
|
||||||
|
[`${BROWSER.chrome} (Android)`, versions.chrome_android]
|
||||||
|
];
|
||||||
|
|
||||||
|
if (versions.chrome === versions.edge) {
|
||||||
|
rows[0][0] += `/${BROWSER.edge}`;
|
||||||
|
} else {
|
||||||
|
rows.push([BROWSER.edge, versions.edge]);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
[BROWSER.firefox, versions.firefox],
|
||||||
|
[`${BROWSER.firefox} (Android)`, versions.firefox_android],
|
||||||
|
[BROWSER.safari, versions.safari],
|
||||||
|
[`${BROWSER.safari} (iOS)`, versions.safari_ios],
|
||||||
|
[BROWSER.opera, versions.opera],
|
||||||
|
[`${BROWSER.opera} (Android)`, versions.opera_android],
|
||||||
|
[BROWSER.samsung_internet, versions.samsunginternet_android],
|
||||||
|
[BROWSER.webview_android, versions.webview_android],
|
||||||
|
[BROWSER.internet_explorer, 'not supported']
|
||||||
|
);
|
||||||
|
|
||||||
|
const target_label = target === 'newly' ? '"newly available"' : target;
|
||||||
|
|
||||||
|
return (
|
||||||
|
render_markdown_table(
|
||||||
|
['Browser', 'Minimum version'],
|
||||||
|
rows.filter(([, version]) => version !== undefined)
|
||||||
|
) +
|
||||||
|
`\n\n> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of ${target_label}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_markdown_table(headers: string[], rows: string[][]): string {
|
||||||
|
return `| ${headers.join(' | ')} |
|
||||||
|
| ${headers.map(() => '-').join(' | ')} |
|
||||||
|
${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Preparing scratch directory…');
|
||||||
|
// Wipe and recreate so stale bundles can't leak into the next scan.
|
||||||
|
fs.rmSync(tmp_dir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(tmp_dir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Bundling runtime entries…');
|
||||||
|
const runtime_files: string[] = [];
|
||||||
|
for (const importee of browser_subpaths().map(importee_for)) {
|
||||||
|
// `import * as` + re-export keeps default and named exports
|
||||||
|
// alive, so flag modules (only a default export) don't produce
|
||||||
|
// empty chunks but their code still ends up in the scan.
|
||||||
|
const code = await bundle(`import * as __ns from '${importee}'; export default __ns;`);
|
||||||
|
const file = path.join(tmp_dir, `${safe_name(importee)}.ts`);
|
||||||
|
fs.writeFileSync(file, code);
|
||||||
|
runtime_files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Loading compiler-output fixtures…');
|
||||||
|
const compiler_fixtures = load_compiler_output_fixtures();
|
||||||
|
console.log(` (${compiler_fixtures.length} fixtures found)`);
|
||||||
|
|
||||||
|
console.log('Searching for the minimum Baseline target (type-aware)…');
|
||||||
|
const target = find_minimum_target(runtime_files, compiler_fixtures);
|
||||||
|
|
||||||
|
console.log('Checking BEHAVIORAL_IGNORE for stale entries…');
|
||||||
|
validate_ignore_features(runtime_files);
|
||||||
|
console.log(' no stale entries');
|
||||||
|
|
||||||
|
console.log('Enumerating subpackage exports…');
|
||||||
|
const subpackage_exports = await enumerate_subpackage_exports();
|
||||||
|
const total_exports = Object.values(subpackage_exports).reduce((n, list) => n + list.length, 0);
|
||||||
|
console.log(
|
||||||
|
` ${total_exports} export(s) across ${Object.keys(subpackage_exports).length} subpackage(s)`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Scanning per-feature fixtures for conditional requirements…');
|
||||||
|
const conditional_rows = await find_all_conditional_features(target, subpackage_exports);
|
||||||
|
console.log(
|
||||||
|
` ${conditional_rows.length} feature(s) require browsers newer than the runtime floor`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Resolving browser versions…');
|
||||||
|
const versions = browser_versions_for(target);
|
||||||
|
|
||||||
|
console.log('Rewriting docs page…');
|
||||||
|
generate('browser-support.md', render_browser_table(versions, target));
|
||||||
|
generate('browser-support-features.md', render_conditional_table(conditional_rows, target));
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp_dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate(file: string, content: string): void {
|
||||||
|
const filename = path.join(docs_dir, file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const backlink = path.relative(filename, fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
fs.writeFileSync(filename, `<!-- generated in ${backlink}. do not edit -->\n\n${content}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue