From 27e74c4df7b49f6b7663e00acd065a68ddaa337f Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Thu, 28 May 2026 20:19:56 +0200 Subject: [PATCH 1/2] docs: add auto-generated browser support page (#18276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a [Browser support](https://github.com/MathiasWP/svelte/blob/document-pipeline-draft/documentation/docs/07-misc/05-browser-support.md) page listing the minimum browser versions Svelte's runtime and compiler output require. The page (headline floor + per-feature requirements) is auto-generated by `packages/svelte/scripts/generate-browser-support.js`, which scans the code Svelte actually ships against the [web-features](https://github.com/web-platform-dx/web-features) Baseline dataset and resolves results to concrete browser versions via [baseline-browser-mapping](https://github.com/web-platform-dx/baseline-browser-mapping). CI fails if a PR moves the floor without regenerating the page. For 5.55.9 the floor is **Baseline 2020**: Chrome/Edge 87, Firefox 83, Safari 14. ## How it works 1. **Headline floor.** Each runtime entry from `pkg.exports` is bundled with rollup using production conditions, then walked with TypeScript's compiler API + TypeChecker. The walker (`browser-support.detector.js`) flags any web-features ID the runtime references; the highest Baseline year among them is the floor. 2. **Per-feature requirements.** Every user-facing surface is enumerated programmatically — every `bind:*` from `binding_properties`, every public subpackage export discovered via `pkg.exports` + `import * as ns`, every rune from `RUNES`, and directives (`transition:` / `animate:` / `use:` / `@attach` / `{@html}` / custom elements). Each gets a fixture, compiled and bundled like user code, then scanned. A row appears only when the fixture's floor exceeds the runtime floor. 3. **Supplemental rules.** A handful of APIs `web-features` doesn't catalogue (`getComputedStyle(...).zoom`, `box: 'device-pixel-content-box'`) are detected with the same type-aware walker via a small `register_extra_rules` list with inline justifications. 4. **Ignore sets.** `SAFE_TO_IGNORE` covers dataset bugs and feature-detected APIs with graceful fallbacks. `BEHAVIORAL_IGNORE` hides APIs reached only via specific user code from the headline (they still surface as conditional rows); staleness-checked, so CI fails if an entry no longer exists in the runtime. Resolves #18198. Provides an answer to the long-standing #558. ## Test plan - [ ] \`pnpm --filter svelte generate:browser-support\` regenerates the page idempotently - [ ] CI fails when a code change bumps the floor without regenerating the page - [ ] Page renders correctly on svelte.dev 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Rich Harris --- .github/workflows/ci.yml | 2 + .gitignore | 1 + .../.generated/browser-support-features.md | 7 + .../07-misc/.generated/browser-support.md | 13 + .../docs/07-misc/05-browser-support.md | 30 + eslint.config.js | 1 + packages/svelte/package.json | 5 +- .../scripts/browser-support.detector.js | 417 +++++++++ .../scripts/generate-browser-support.ts | 874 ++++++++++++++++++ packages/svelte/src/utils.js | 2 +- pnpm-lock.yaml | 31 +- 11 files changed, 1378 insertions(+), 5 deletions(-) create mode 100644 documentation/docs/07-misc/.generated/browser-support-features.md create mode 100644 documentation/docs/07-misc/.generated/browser-support.md create mode 100644 documentation/docs/07-misc/05-browser-support.md create mode 100644 packages/svelte/scripts/browser-support.detector.js create mode 100644 packages/svelte/scripts/generate-browser-support.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df9f755874..4f72b6c23e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,8 @@ jobs: - name: build and check generated types if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); } + - name: check browser-support docs page is up to date + run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/05-browser-support.md`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/05-browser-support.md; exit 1); }' Benchmarks: permissions: {} runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d3c1819bd5..556cae6344 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ coverage .DS_Store tmp +packages/svelte/scripts/_baseline/ benchmarking/.profiles benchmarking/compare/.results diff --git a/documentation/docs/07-misc/.generated/browser-support-features.md b/documentation/docs/07-misc/.generated/browser-support-features.md new file mode 100644 index 0000000000..1e1a00d4d3 --- /dev/null +++ b/documentation/docs/07-misc/.generated/browser-support-features.md @@ -0,0 +1,7 @@ + + +| Feature | Chrome/Edge | Firefox | Safari | +| --- | ---: | ---: | ---: | +| `$state.snapshot` | 98 | 94 | 15.4 | +| `bind:devicePixelContentBoxSize` | | 93 | not supported | +| `flip` from `svelte/animate` | | 126 | | \ No newline at end of file diff --git a/documentation/docs/07-misc/.generated/browser-support.md b/documentation/docs/07-misc/.generated/browser-support.md new file mode 100644 index 0000000000..8c58588960 --- /dev/null +++ b/documentation/docs/07-misc/.generated/browser-support.md @@ -0,0 +1,13 @@ + + +| Browser | Minimum version | +| ---------------- | --------------- | +| Chrome/Edge | 87 | +| Firefox | 83 | +| Safari | 14 | +| Opera | 73 | +| Opera (Android) | 62 | +| Samsung Internet | 14.0 | +| Android WebView | 87 | + +> [!NOTE] This equates to a Baseline target of 2020. \ No newline at end of file diff --git a/documentation/docs/07-misc/05-browser-support.md b/documentation/docs/07-misc/05-browser-support.md new file mode 100644 index 0000000000..1a8830bdb9 --- /dev/null +++ b/documentation/docs/07-misc/05-browser-support.md @@ -0,0 +1,30 @@ +--- +title: Browser support +--- + +The table below shows the minimum browser versions Svelte's runtime and compiled output are expected to work in. + +@include .generated/browser-support.md + +These numbers describe what Svelte's output _requires_ in order to run — they're derived from the APIs the code uses, not from a list of browsers the team commits to testing. + +## What is covered + +- **Svelte's runtime.** Everything you import from `svelte` or its subpackages, in the form your bundler ships to the browser. +- **Compiler output.** The JavaScript the Svelte compiler emits from your `.svelte` files, including the DOM operations, bindings and transitions used in your components. + +## What is not covered + +- **Your own code** inside ``, + '$state.raw': ``, + '$state.eager': ``, + '$state.snapshot': ``, + $derived: ``, + '$derived.by': ``, + $props: ``, + '$props.id': ``, + $bindable: ``, + $effect: ``, + '$effect.pre': ``, + '$effect.tracking': ``, + '$effect.root': ``, + '$effect.pending': ``, + $inspect: ``, + '$inspect().with': ``, + '$inspect.trace': ``, + $host: `\n` +}; + +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: `{#if show}
{/if}` + }, + { + name: '`animate:`', + source: `{#each items as item (item)}
{item}
{/each}` + }, + { + name: '`use:` actions', + source: `
` + }, + { + name: '`@attach`', + source: `
` + }, + { + name: '`{@html ...}`', + source: `{@html html}` + }, + { + name: 'Custom elements (``)', + source: `\n
` + } +]; + +/** + * 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 { + 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, ``, 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): 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, + ignore: Set +): { year: number; drivers: Set } { + let year = 0; + const drivers = new Set(); + 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); + + // eslint-disable-next-line no-console + console.log(` → ${final_year} (features that drove the floor:)`); + for (const id of [...drivers].sort()) { + // eslint-disable-next-line no-console + 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): 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]; + + // Pick a sensible initial value and attribute set per binding. + const reactive = `let v = $state();`; + + if (tag === 'svelte:window') { + return ``; + } + if (tag === 'svelte:document') { + return ``; + } + if (tag === 'input') { + // `bind:checked` requires type="checkbox"|"radio"; `bind:group` too. + const type = + name === 'checked' || name === 'indeterminate' + ? ' type="checkbox"' + : name === 'group' + ? ' type="radio" value="a"' + : name === 'files' + ? ' type="file"' + : ''; + return ``; + } + if (tag === 'details') { + return `
x
`; + } + + return `<${tag} bind:${name}={v}>`; +} + +/** + * 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 { + 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 +): Promise { + const runtime_year = typeof runtime_floor === 'number' ? runtime_floor : Infinity; + const features = enumerate_features(subpackage_exports); + const rows: ConditionalRow[] = []; + + 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; + } + } + + rows.push({ + name: feature.name, + versions, + baseline_year: final_year + }); + } + process.stdout.write('\n'); + + return rows; +} + +/** + * Render the per-feature browser-requirements table from the auto-detected + * rows. Sorted by Safari floor descending, then alphabetically. + */ +function render_conditional_table(rows: ConditionalRow[], runtime_floor: RuntimeFloor): string { + if (rows.length === 0) { + return '_No features currently require browser versions newer than the runtime floor._'; + } + + const browsers = [ + ['chrome', 'Chrome/Edge'], + ['firefox', 'Firefox'], + ['safari', 'Safari'] + ] as const; + + const runtime_versions = browser_versions_for(runtime_floor); + + const sorted = [...rows].sort((a, b) => { + const sa = Number(a.versions.safari ?? '0'); + const sb = Number(b.versions.safari ?? '0'); + if (sb !== sa) return sb - sa; + return a.name.localeCompare(b.name); + }); + + const header = '| Feature | ' + browsers.map(([, label]) => `${label}`).join(' | ') + ' |'; + const sep = '| --- |' + browsers.map(() => ' ---: |').join(''); + + const body = sorted.map((entry) => { + const cells = browsers.map(([key]) => { + const v = entry.versions[key]; + if (v === null) return 'not supported'; + if (v === undefined) return ''; + const floor_v = runtime_versions[key]; + return floor_v && Number(v) <= Number(floor_v) + ? '' + : v; + }); + return `| ${entry.name} | ${cells.join(' | ')} |`; + }); + + return [header, sep, ...body].join('\n'); +} + +function browser_versions_for(target: RuntimeFloor): Record { + // `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 = {}; + 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; +} + +function render_table(versions: Record, target: RuntimeFloor): string { + // Chrome and Edge ship from the same engine and historically resolve to the + // same Baseline version. Collapse them into one row when they match, but + // fall back to listing them separately if they ever drift. + const chrome_edge: [string, string] | null = + versions.chrome && versions.chrome === versions.edge ? ['Chrome/Edge', versions.chrome] : null; + + const base_rows: Array<[string, string]> = chrome_edge + ? [chrome_edge, ['Chrome (Android)', versions.chrome_android]] + : [ + ['Chrome', versions.chrome], + ['Chrome (Android)', versions.chrome_android], + ['Edge', versions.edge] + ]; + + const rows = [ + ...base_rows, + ['Firefox', versions.firefox], + ['Firefox (Android)', versions.firefox_android], + ['Safari', versions.safari], + ['Safari (iOS)', versions.safari_ios], + ['Opera', versions.opera], + ['Opera (Android)', versions.opera_android], + ['Samsung Internet', versions.samsunginternet_android], + ['Android WebView', versions.webview_android] + ].filter(([label, version]) => version !== undefined) as Array<[string, string]>; + + const headings = ['Browser', 'Minimum version']; + + const widths = headings.map((heading, i) => + Math.max(heading.length, ...rows.map((r) => String(r[i]).length)) + ); + + const pad = (s: string, n: number) => s + ' '.repeat(Math.max(0, n - s.length)); + + const header = `| ${headings.map((heading, i) => pad(heading, widths[i])).join(' | ')} |`; + const sep = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`; + const body = rows + .map(([a, b]) => `| ${pad(a, widths[0])} | ${pad(String(b), widths[1])} |`) + .join('\n'); + + const target_label = target === 'newly' ? '"newly available"' : target; + + return `${header}\n${sep}\n${body}\n\n> [!NOTE] This equates to a Baseline target of ${target_label}.`; +} + +/* eslint-disable no-console */ +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_table(versions, target)); + generate('browser-support-features.md', render_conditional_table(conditional_rows, target)); + + console.log('Done.'); + } finally { + // Ensure cleanup happens even on failure — otherwise leftover bundle + // files in `scripts/_baseline/` get picked up by `pnpm lint` on the + // next CI step and produce spurious naming/no-console errors. + 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, `\n\n${content}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index 54757a6f13..40b558d0f9 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -434,7 +434,7 @@ const STATE_CREATION_RUNES = /** @type {const} */ ([ '$derived.by' ]); -const RUNES = /** @type {const} */ ([ +export const RUNES = /** @type {const} */ ([ ...STATE_CREATION_RUNES, '$state.eager', '$state.snapshot', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25899b297d..4c077f0f6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,7 +103,7 @@ importers: version: 1.2.1 esrap: specifier: ^2.2.9 - version: 2.2.9(@typescript-eslint/types@8.56.0) + version: 2.2.9(@typescript-eslint/types@8.59.4) is-reference: specifier: ^3.0.3 version: 3.0.3 @@ -141,6 +141,9 @@ importers: '@types/node': specifier: ^20.11.5 version: 20.19.17 + baseline-browser-mapping: + specifier: ^2.10.32 + version: 2.10.32 dts-buddy: specifier: ^0.5.5 version: 0.5.5(typescript@5.5.4) @@ -162,6 +165,9 @@ importers: vitest: specifier: ^2.1.9 version: 2.1.9(@types/node@20.19.17)(jsdom@25.0.1)(lightningcss@1.23.0)(sass@1.70.0)(terser@5.27.0) + web-features: + specifier: ^3.29.0 + version: 3.29.0 playgrounds/sandbox: devDependencies: @@ -1153,6 +1159,10 @@ packages: resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.0': resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1281,6 +1291,11 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -2660,6 +2675,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-features@3.29.0: + resolution: {integrity: sha512-r8m0Xj77/PSHXEbv2U3UuLhw5gE4U+YAPek5hCor5DMsS9Qnwtxl5FSfam3dXQo7Gl0gxK9BRrcQLlOBZZZXpw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3530,6 +3548,9 @@ snapshots: '@typescript-eslint/types@8.56.0': {} + '@typescript-eslint/types@8.59.4': + optional: true + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.5.4)': dependencies: '@typescript-eslint/project-service': 8.56.0(typescript@5.5.4) @@ -3676,6 +3697,8 @@ snapshots: balanced-match@1.0.2: {} + baseline-browser-mapping@2.10.32: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -4051,11 +4074,11 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.9(@typescript-eslint/types@8.56.0): + esrap@2.2.9(@typescript-eslint/types@8.59.4): dependencies: '@jridgewell/sourcemap-codec': 1.5.0 optionalDependencies: - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/types': 8.59.4 esrecurse@4.3.0: dependencies: @@ -5036,6 +5059,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-features@3.29.0: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} From a9916053d9bbad77143d34dd7fdd5ed8b2394413 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Thu, 28 May 2026 21:08:42 +0200 Subject: [PATCH 2/2] perf: use createElement instead of createElementNS for HTML elements (#18262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The current wrapper always calls `document.createElementNS(namespace ?? NAMESPACE_HTML, tag, options)` — even for HTML elements (the >99% case), and even when `options` would be `undefined`. Two effects compound: 1. **Route HTML elements through `createElement`** — Blink has a fast path that skips the namespace lookup `createElementNS` always performs. 2. **Omit the trailing `undefined` argument** — V8/Blink take a slower path for `createElementNS(ns, tag, undefined)` (and `createElement(tag, undefined)`) than for the bare 2-arg form. This applies symmetrically to the SVG/MathML branch, where the wrapper now also avoids the `undefined` 3rd arg. The wrapper dispatches to the fastest call shape for every input — `{HTML, non-HTML}` × `{with is, without is}` × no `undefined` ever. ## Affects Every place Svelte constructs a DOM element internally: ``, `run_scripts`, the per-component `