diff --git a/.changeset/calm-menus-resume.md b/.changeset/calm-menus-resume.md new file mode 100644 index 00000000000..eda324537cc --- /dev/null +++ b/.changeset/calm-menus-resume.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: preserve captured qrl declaration order diff --git a/.changeset/const-fold-shorthand.md b/.changeset/const-fold-shorthand.md new file mode 100644 index 00000000000..f6c1f48f197 --- /dev/null +++ b/.changeset/const-fold-shorthand.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: build constants in shorthand object properties fold to `key: value` instead of corrupting the key diff --git a/.changeset/dangling-auto-export.md b/.changeset/dangling-auto-export.md new file mode 100644 index 00000000000..7609b92ff16 --- /dev/null +++ b/.changeset/dangling-auto-export.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: un-exported route loaders and actions no longer break the build diff --git a/.changeset/dev-eager-server-fns.md b/.changeset/dev-eager-server-fns.md new file mode 100644 index 00000000000..323cf89687c --- /dev/null +++ b/.changeset/dev-eager-server-fns.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +fix: server$ functions are registered at startup in development builds too diff --git a/.changeset/great-moons-invent.md b/.changeset/great-moons-invent.md new file mode 100644 index 00000000000..c84f84b4baf --- /dev/null +++ b/.changeset/great-moons-invent.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: honour a JSDoc-style `@jsxImportSource` pragma, and survive a failed raw-transfer allocation diff --git a/.changeset/hoist-numbering-align.md b/.changeset/hoist-numbering-align.md new file mode 100644 index 00000000000..3c54d5fa89d --- /dev/null +++ b/.changeset/hoist-numbering-align.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: hoisted signal functions could reference the wrong body in multi-component files diff --git a/.changeset/jsx-tag-captures.md b/.changeset/jsx-tag-captures.md new file mode 100644 index 00000000000..ef50a17761f --- /dev/null +++ b/.changeset/jsx-tag-captures.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: components referenced as JSX tags inside function scopes are captured like any other binding diff --git a/.changeset/keep-decls-no-extractions.md b/.changeset/keep-decls-no-extractions.md new file mode 100644 index 00000000000..98885651ef0 --- /dev/null +++ b/.changeset/keep-decls-no-extractions.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: files without any $ extraction no longer lose module-level declarations diff --git a/.changeset/keep-qrl-trailing-args.md b/.changeset/keep-qrl-trailing-args.md new file mode 100644 index 00000000000..437f98127b7 --- /dev/null +++ b/.changeset/keep-qrl-trailing-args.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: arguments after a $ closure (e.g. task options) are no longer dropped diff --git a/.changeset/key-numbering-parity.md b/.changeset/key-numbering-parity.md new file mode 100644 index 00000000000..8407d14b499 --- /dev/null +++ b/.changeset/key-numbering-parity.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: SSR and client builds now assign identical JSX keys, preventing full subtree recreation on first rerender diff --git a/.changeset/lazy-cars-repeat.md b/.changeset/lazy-cars-repeat.md new file mode 100644 index 00000000000..852a3a697cd --- /dev/null +++ b/.changeset/lazy-cars-repeat.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: stop letting regex pre-checks decide whether the TS strip and constant folding run diff --git a/.changeset/light-workers-lift.md b/.changeset/light-workers-lift.md new file mode 100644 index 00000000000..ca26202aee2 --- /dev/null +++ b/.changeset/light-workers-lift.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: the optimizer runs transforms in worker threads, isolating memory and parallelizing builds diff --git a/.changeset/loud-labels-vanish.md b/.changeset/loud-labels-vanish.md new file mode 100644 index 00000000000..ddc4fefdf92 --- /dev/null +++ b/.changeset/loud-labels-vanish.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: statement labels no longer pull same-named module variables into segments diff --git a/.changeset/olive-lions-yawn.md b/.changeset/olive-lions-yawn.md new file mode 100644 index 00000000000..08896d5320b --- /dev/null +++ b/.changeset/olive-lions-yawn.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: the Rust optimizer is now an optional peer dependency, installed only if you set `tsOptimizer: false` diff --git a/.changeset/olive-pugs-repeat.md b/.changeset/olive-pugs-repeat.md new file mode 100644 index 00000000000..11edb09c181 --- /dev/null +++ b/.changeset/olive-pugs-repeat.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: drop code a folded isServer/isBrowser branch made unreachable, so server-only imports leave the client bundle diff --git a/.changeset/passive-scope-hoist.md b/.changeset/passive-scope-hoist.md new file mode 100644 index 00000000000..3d1f80dee0f --- /dev/null +++ b/.changeset/passive-scope-hoist.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: passive event scopes survive the server build's handler rewrite diff --git a/.changeset/qps-slot-order.md b/.changeset/qps-slot-order.md new file mode 100644 index 00000000000..e40c824ff01 --- /dev/null +++ b/.changeset/qps-slot-order.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: serialized q:ps capture arrays follow the client's handler slot order on multi-handler elements diff --git a/.changeset/ref-and-space-fixes.md b/.changeset/ref-and-space-fixes.md new file mode 100644 index 00000000000..ae370426794 --- /dev/null +++ b/.changeset/ref-and-space-fixes.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: ref props are no longer hoisted into read-only signals, and same-line spaces between sibling elements are preserved diff --git a/.changeset/repl-rolldown-oxc.md b/.changeset/repl-rolldown-oxc.md new file mode 100644 index 00000000000..ff7a42bc857 --- /dev/null +++ b/.changeset/repl-rolldown-oxc.md @@ -0,0 +1,5 @@ +--- +'qwik-docs': patch +--- + +chore: reuse rolldown's bundled oxc in the REPL instead of a second wasm copy diff --git a/.changeset/repl-ts-optimizer.md b/.changeset/repl-ts-optimizer.md new file mode 100644 index 00000000000..72460fb90a7 --- /dev/null +++ b/.changeset/repl-ts-optimizer.md @@ -0,0 +1,5 @@ +--- +'qwik-docs': patch +--- + +fix: the docs REPL bundles with the TypeScript optimizer and browser oxc, no Rust wasm needed for current versions diff --git a/.changeset/rotten-hoops-melt.md b/.changeset/rotten-hoops-melt.md new file mode 100644 index 00000000000..8179d433ffa --- /dev/null +++ b/.changeset/rotten-hoops-melt.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +perf: cut optimizer wall time ~18% by reusing parses and rewriting the code/comment blanking pass diff --git a/.changeset/router-config-decoupling.md b/.changeset/router-config-decoupling.md new file mode 100644 index 00000000000..671f02234b8 --- /dev/null +++ b/.changeset/router-config-decoupling.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +fix: the router config no longer evaluates app route and server$ modules during the runtime's own import phase, eliminating module-order TDZ errors in bundled SSR output and making the middleware importable outside an app build diff --git a/.changeset/router-loader-tdz.md b/.changeset/router-loader-tdz.md new file mode 100644 index 00000000000..05dae947124 --- /dev/null +++ b/.changeset/router-loader-tdz.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +fix: route loader factories no longer throw a TDZ error in bundled SSR builds diff --git a/.changeset/spread-override-order.md b/.changeset/spread-override-order.md new file mode 100644 index 00000000000..56ec3d912b3 --- /dev/null +++ b/.changeset/spread-override-order.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: props spread after a named prop now overrides it, including with multiple spreads diff --git a/.changeset/strip-fnsignal-types.md b/.changeset/strip-fnsignal-types.md new file mode 100644 index 00000000000..297adfc39b2 --- /dev/null +++ b/.changeset/strip-fnsignal-types.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: serialized reactive expressions no longer leak typescript casts into browser scripts diff --git a/.changeset/stripped-loop-captures.md b/.changeset/stripped-loop-captures.md new file mode 100644 index 00000000000..db0dc623227 --- /dev/null +++ b/.changeset/stripped-loop-captures.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: stripped loop event handlers no longer lose their lexical captures diff --git a/.changeset/tidy-onions-brake.md b/.changeset/tidy-onions-brake.md new file mode 100644 index 00000000000..12afc083ee1 --- /dev/null +++ b/.changeset/tidy-onions-brake.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: keep CRLF sources and third-party `$` marker packages correct in segments diff --git a/.changeset/tricky-jars-repeat.md b/.changeset/tricky-jars-repeat.md new file mode 100644 index 00000000000..992c14af2d8 --- /dev/null +++ b/.changeset/tricky-jars-repeat.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: don't declare the private ts-optimizer as a dependency, it is bundled into core/optimizer diff --git a/.changeset/ts-optimizer-vite-flag.md b/.changeset/ts-optimizer-vite-flag.md new file mode 100644 index 00000000000..d5c7b8edb09 --- /dev/null +++ b/.changeset/ts-optimizer-vite-flag.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: add `tsOptimizer` flag to qwikVite, selecting the TypeScript optimizer by default diff --git a/.changeset/unit-transform-fixes.md b/.changeset/unit-transform-fixes.md new file mode 100644 index 00000000000..86d0b8e1e33 --- /dev/null +++ b/.changeset/unit-transform-fixes.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: string-keyed prop destructures, multi-capital event names, and manual qrl captures no longer break the transform diff --git a/.changeset/wild-donkeys-repeat.md b/.changeset/wild-donkeys-repeat.md new file mode 100644 index 00000000000..88077b153ce --- /dev/null +++ b/.changeset/wild-donkeys-repeat.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +fix: export the Editable type that routeActionQrl's return type already exposed diff --git a/.changeset/worker-qrl-escaped-quote.md b/.changeset/worker-qrl-escaped-quote.md new file mode 100644 index 00000000000..d7602d06e9f --- /dev/null +++ b/.changeset/worker-qrl-escaped-quote.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: worker qrl chunk rewrite no longer breaks on escaped quotes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91d06e9ec92..ee3002de4f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,8 +145,9 @@ jobs: path: packages/qwik/dist # Note that this includes the package.json of qwik # qwik-vite source is bundled into packages/qwik/dist/optimizer.mjs by scripts/submodule-optimizer.ts, so its sources must invalidate this cache. + # ts-optimizer is bundled into packages/qwik/dist/ts-optimizer.mjs by the same script, so it must too — otherwise every job that consumes this dist builds against a stale optimizer. # CI-only helpers should not invalidate compiled package output caches. - key: ${{ hashfiles('pnpm-lock.yaml', 'scripts.json', 'scripts/**/*', '!scripts/ci-restore-artifacts.ts', 'packages/qwik/**/*', 'packages/qwik-vite/**/*', 'starters/features/**/*', 'starters/adapters/**/*', 'starters/templates/**/*', '!**/*.unit.*', '!**/*.rs', '!e2e/qwik-e2e/apps/e2e') }} + key: ${{ hashfiles('pnpm-lock.yaml', 'scripts.json', 'scripts/**/*', '!scripts/ci-restore-artifacts.ts', 'packages/qwik/**/*', 'packages/qwik-vite/**/*', 'packages/ts-optimizer/**/*', 'starters/features/**/*', 'starters/adapters/**/*', 'starters/templates/**/*', '!**/*.unit.*', '!**/*.rs', '!e2e/qwik-e2e/apps/e2e') }} - run: 'echo ${{ steps.cache-qwik.outputs.cache-primary-key }} > qwik-key.txt' - name: 'check cache: rust' id: cache-rust diff --git a/.prettierignore b/.prettierignore index 8f244821454..0747be9b5a9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -58,3 +58,7 @@ packages/browser-extension/public/devtools-hook.js # ruler-check CI). Edit .ruler/ and regenerate instead of letting Prettier reformat these. /AGENTS.md /CLAUDE.md + +# Playwright artifacts (gitignored, but prettier still walks them) +playwright-report/ +test-results/ diff --git a/.ruler/AGENTS.md b/.ruler/AGENTS.md index 52756d9b88f..d356c464f4a 100644 --- a/.ruler/AGENTS.md +++ b/.ruler/AGENTS.md @@ -44,7 +44,8 @@ the cursor system, and the Rust optimizer. | `@qwik.dev/utils` | `packages/qwik-utils` | Less-common helpers (`usePoll`, `zod$`), may use qwik internals | | `@qwik.dev/dom` | `packages/qwik-dom` | Server-side DOM implementation | | `@qwik.dev/qwik-vite` | `packages/qwik-vite` | `vite-plugin-qwik`, private, bundled into `@qwik.dev/core/optimizer` | -| `@qwik.dev/optimizer` | `packages/optimizer` | Rust optimizer, WASM, NAPI bindings | +| `@qwik.dev/ts-optimizer` | `packages/ts-optimizer` | TypeScript optimizer, private, bundled into `@qwik.dev/core/optimizer` | +| `@qwik.dev/optimizer` | `packages/optimizer` | Rust optimizer, WASM, NAPI bindings; kept for parity comparison | | `eslint-plugin-qwik` | `packages/eslint-plugin-qwik` | ESLint rules | | `create-qwik` | `packages/create-qwik` | Project scaffolding CLI | | `qwik-docs` | `packages/docs` | Docs site, private package | diff --git a/.ruler/skills/qwik-e2e-verification/SKILL.md b/.ruler/skills/qwik-e2e-verification/SKILL.md index 14ea1efd797..ecc1894ccde 100644 --- a/.ruler/skills/qwik-e2e-verification/SKILL.md +++ b/.ruler/skills/qwik-e2e-verification/SKILL.md @@ -37,6 +37,14 @@ pnpm playwright test e2e/qwik-e2e/tests/events.e2e.ts --browser=chromium --confi pnpm playwright test e2e/qwik-e2e/tests/qwikrouter/nav.e2e.ts --browser=chromium --config e2e/qwik-e2e/playwright.config.ts ``` +The brotli budget tests in `ssg-snapshot.e2e.ts` measure the built core chunk, so they need a +production `pnpm build.core` — `build.core.dev` leaves core unminified and blows the budget by ~4%. +Before believing a budget failure, rebuild with `build.core` and re-run `pnpm test.e2e.router.ssg`. + +Playwright's `reuseExistingServer: !CI` means a dev server left over from an earlier run serves +stale bundles, so a local pass can hide a build break that CI catches. Kill port 3301 before +trusting a green local sweep after optimizer or bundler changes. + Other suites may use named Playwright projects. Check their config before choosing flags: ```bash diff --git a/AGENTS.md b/AGENTS.md index 5224d0cd7f6..822c57ac78f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,8 @@ the cursor system, and the Rust optimizer. | `@qwik.dev/utils` | `packages/qwik-utils` | Less-common helpers (`usePoll`, `zod$`), may use qwik internals | | `@qwik.dev/dom` | `packages/qwik-dom` | Server-side DOM implementation | | `@qwik.dev/qwik-vite` | `packages/qwik-vite` | `vite-plugin-qwik`, private, bundled into `@qwik.dev/core/optimizer` | -| `@qwik.dev/optimizer` | `packages/optimizer` | Rust optimizer, WASM, NAPI bindings | +| `@qwik.dev/ts-optimizer` | `packages/ts-optimizer` | TypeScript optimizer, private, bundled into `@qwik.dev/core/optimizer` | +| `@qwik.dev/optimizer` | `packages/optimizer` | Rust optimizer, WASM, NAPI bindings; kept for parity comparison | | `eslint-plugin-qwik` | `packages/eslint-plugin-qwik` | ESLint rules | | `create-qwik` | `packages/create-qwik` | Project scaffolding CLI | | `qwik-docs` | `packages/docs` | Docs site, private package | diff --git a/CLAUDE.md b/CLAUDE.md index a7f039dd779..382cc2fbfd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,8 @@ the cursor system, and the Rust optimizer. | `@qwik.dev/utils` | `packages/qwik-utils` | Less-common helpers (`usePoll`, `zod$`), may use qwik internals | | `@qwik.dev/dom` | `packages/qwik-dom` | Server-side DOM implementation | | `@qwik.dev/qwik-vite` | `packages/qwik-vite` | `vite-plugin-qwik`, private, bundled into `@qwik.dev/core/optimizer` | -| `@qwik.dev/optimizer` | `packages/optimizer` | Rust optimizer, WASM, NAPI bindings | +| `@qwik.dev/ts-optimizer` | `packages/ts-optimizer` | TypeScript optimizer, private, bundled into `@qwik.dev/core/optimizer` | +| `@qwik.dev/optimizer` | `packages/optimizer` | Rust optimizer, WASM, NAPI bindings; kept for parity comparison | | `eslint-plugin-qwik` | `packages/eslint-plugin-qwik` | ESLint rules | | `create-qwik` | `packages/create-qwik` | Project scaffolding CLI | | `qwik-docs` | `packages/docs` | Docs site, private package | diff --git a/e2e/docs-e2e/package.json b/e2e/docs-e2e/package.json index b2bb76c666f..4828e181e09 100644 --- a/e2e/docs-e2e/package.json +++ b/e2e/docs-e2e/package.json @@ -3,8 +3,8 @@ "description": "", "author": "", "devDependencies": { - "@playwright/test": "1.61.0", - "@types/node": "24.10.0" + "@playwright/test": "1.61.1", + "@types/node": "25.6.0" }, "keywords": [], "license": "ISC", diff --git a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html index 24b83bed450..306eb159186 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html +++ b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html @@ -1,3 +1,3 @@ -Qwik Router SSG Snapshot

SSG Snapshot Fixture

routeLoader$ | useResource$ | useSignal

{"heading":"SSG Snapshot Fixture","stats":[2,3,5,8],"tags":["routeLoader$","useResource$","useSignal"],"profile":{"name":"router-state","status":"stable"}}
{"total":18,"selectedCount":2,"clicks":2}
{"headline":"router-state:stable","selected":["alpha","beta"],"weighted":[2,6,15,32],"summary":{"total":18,"selectedCount":2,"clicks":2}}
[state omitted] +Qwik Router SSG Snapshot

SSG Snapshot Fixture

routeLoader$ | useResource$ | useSignal

{"heading":"SSG Snapshot Fixture","stats":[2,3,5,8],"tags":["routeLoader$","useResource$","useSignal"],"profile":{"name":"router-state","status":"stable"}}
{"total":18,"selectedCount":2,"clicks":2}
{"headline":"router-state:stable","selected":["alpha","beta"],"weighted":[2,6,15,32],"summary":{"total":18,"selectedCount":2,"clicks":2}}
[state omitted] [vnode map omitted] - \ No newline at end of file + \ No newline at end of file diff --git a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt index 643cea0f92f..8404c993c13 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt +++ b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt @@ -2,17 +2,48 @@ 0 VNode "0A" 1 QRL "[omitted]" 2 QRL "[omitted]" -3 QRL "[omitted]" -4 Map [ +3 Map [ Constant ':' EffectSubscriptionNoData [ RootRef [omitted] Constant ':' ] ] -5 {number} 24 -6 Array [ - RootRef [omitted] +4 {number} 24 +5 Array [ + Store [ + Object [ + {string} "url" + URL "https://snapshot.qwik.dev/" + {string} "params" + Object 0 + {string} "isNavigating" + Constant false + {string} "prevUrl" + ] + {number} 0 + Map [ + {string} "url" + Set [ + EffectSubscriptionNoData [ + WrappedSignal [ + {number} 1 + Array [ + RootRef [omitted] + ] + {number} 3 + VNode "8" + EffectSubscription [ + RootRef [omitted] + {string} "href" + SubscriptionDataConstTrue 0 + ] + ] + Constant '.' + ] + ] + ] + ] Store [ Object [ {string} "loaderPaths" @@ -21,7 +52,7 @@ {string} "/" ] {string} "manifestHash" - RootRef [omitted] + {string} "MANIFEST_HASH" ] {number} 1 Map [ @@ -54,7 +85,7 @@ ] EffectSubscriptionNoData [ WrappedSignal [ - {number} 1 + {number} 2 Array [ RootRef [omitted] ] @@ -99,7 +130,23 @@ ] ] ] - RootRef [omitted] + Signal [ + Object [ + {string} "type" + {string} "initial" + {string} "dest" + RootRef [omitted] + ] + EffectSubscriptionNoData [ + Task [ + QRL "[omitted]" + {number} 10 + {number} 20 + RootRef [omitted] + ] + Constant ':' + ] + ] Store [ Object [ {string} "title" @@ -225,50 +272,10 @@ {number} 1 ] ] - Store [ - Object [ - {string} "headings" - Constant undefined - {string} "menu" - ] - {number} 1 - ] - Signal [ - Constant undefined - EffectSubscriptionNoData [ - Task [ - QRL "[omitted]" - {number} 10 - {number} 21 - RootRef [omitted] - ] - Constant ':' - ] - EffectSubscriptionNoData [ - VNode "11A" - Constant ':' - ] - ] - Signal [ - Constant undefined - EffectSubscriptionNoData [ - Task [ - QRL "[omitted]" - {number} 2 - {number} 22 - RootRef [omitted] - ] - Constant ':' - ] - ] - Signal [ - Object [ - {string} "status" - {number} 200 - {string} "message" - {string} "OK" - ] - ] + RootRef [omitted] + RootRef [omitted] + RootRef [omitted] + RootRef [omitted] RootRef [omitted] RootRef [omitted] {number} 1 @@ -281,15 +288,10 @@ {number} 1 {number} 1 {number} 1 - Task [ - QRL "[omitted]" - {number} 10 - {number} 20 - RootRef [omitted] - ] + RootRef [omitted] ... ] -7 Array [ +6 Array [ {string} "qr--a" RootRef [omitted] {string} "qr--c" @@ -311,90 +313,55 @@ {string} "qr--s" RootRef [omitted] ] +7 QRL "[omitted]" 8 {string} "q-xxxxxxxx.js" 9 {string} "s_aRL6GvFyews" -10 Signal [ - Constant undefined - EffectSubscriptionNoData [ - RootRef [omitted] - Constant ':' - ] -] -11 {string} "MANIFEST_HASH" -12 Object 0 -13 Signal [ - Object [ - {string} "type" - {string} "initial" - {string} "dest" - URL "https://snapshot.qwik.dev/" - ] +10 {string} "q-xxxxxxxx.js" +11 {string} "s_TG0a9Yb7gLI" +12 RootRef [omitted] +13 RootRef [omitted] +14 Map [ RootRef [omitted] -] -14 Store [ RootRef [omitted] - {number} 0 - Map [ - {string} "url" - Set [ - EffectSubscriptionNoData [ - WrappedSignal [ - {number} 2 - Array [ - RootRef [omitted] - ] - {number} 3 - VNode "8" - EffectSubscription [ - RootRef [omitted] - {string} "href" - SubscriptionDataConstTrue 0 - ] - ] - Constant '.' - ] - ] - ] ] -15 {string} "q-xxxxxxxx.js" -16 {string} "s_8j8Vrz2yUIM" -17 {string} "q-xxxxxxxx.js" -18 {string} "s_TG0a9Yb7gLI" -19 RootRef [omitted] -20 RootRef [omitted] -21 {string} "ssg-snapshot-loader" -22 Object [ +15 RootRef [omitted] +16 RootRef [omitted] +17 {string} "ssg-snapshot-loader" +18 Object [ {string} "raw" ] -23 {string} "__qwik_route_loader_value__ssg-snapshot-loader" -24 RootRef [omitted] -25 Array [] -26 Object [ +19 {string} "__qwik_route_loader_value__ssg-snapshot-loader" +20 RootRef [omitted] +21 Array [] +22 Object [ RootRef [omitted] Constant _UNINITIALIZED RootRef [omitted] RootRef [omitted] ] -27 {string} "q-xxxxxxxx.js" -28 {string} "s_4s4ZDCYhDOA" -29 RootRef [omitted] -30 QRL "[omitted]" -31 Map [ +23 {string} "q-xxxxxxxx.js" +24 {string} "s_4s4ZDCYhDOA" +25 RootRef [omitted] +26 QRL "[omitted]" +27 Map [ Constant ':' - RootRef [omitted] + EffectSubscriptionNoData [ + VNode "11A" + Constant ':' + ] ] -32 {number} 2 -33 Array [ +28 {number} 2 +29 Array [ RootRef [omitted] RootRef [omitted] ] -34 QRL "[omitted]" -35 Map [ +30 QRL "[omitted]" +31 Map [ Constant ':' RootRef [omitted] ] -36 {number} 4 -37 Array [ +32 {number} 4 +33 Array [ RootRef [omitted] RootRef [omitted] RootRef [omitted] @@ -519,7 +486,7 @@ Constant false ] ] -38 Signal [ +34 Signal [ {number} 2 EffectSubscriptionNoData [ RootRef [omitted] @@ -527,7 +494,7 @@ ] RootRef [omitted] ] -39 Store [ +35 Store [ Object [ {string} "expanded" Constant true @@ -562,103 +529,126 @@ ] ] ] +36 RootRef [omitted] +37 {string} "q-xxxxxxxx.js" +38 {string} "s_cgSl2l0RC14" +39 RootRef [omitted] 40 RootRef [omitted] -41 {string} "q-xxxxxxxx.js" -42 {string} "s_cgSl2l0RC14" -43 RootRef [omitted] -44 RootRef [omitted] -45 Map [ +41 Map [ Constant '.' RootRef [omitted] ] -46 RootRef [omitted] -47 RootRef [omitted] -48 RootRef [omitted] -49 RootRef [omitted] -50 QRL "[omitted]" -51 Map [ - Constant ':' +42 RootRef [omitted] +43 RootRef [omitted] +44 RootRef [omitted] +45 Signal [ + Object [ + {string} "status" + {number} 200 + {string} "statusMessage" + Constant undefined + {string} "action" + Constant undefined + {string} "actionResult" + Constant undefined + {string} "formData" + ] + EffectSubscriptionNoData [ + RootRef [omitted] + Constant ':' + ] +] +46 Signal [ + Constant undefined RootRef [omitted] ] -52 {number} 1 -53 Array [ +47 Store [ + Object [ + {string} "headings" + Constant undefined + {string} "menu" + ] + {number} 1 +] +48 Signal [ + Constant undefined RootRef [omitted] + RootRef [omitted] +] +49 Constant undefined +50 Signal [ + Object [ + RootRef [omitted] + {number} 200 + {string} "message" + {string} "OK" + ] ] -54 RootRef [omitted] -55 RootRef [omitted] +51 Signal [ + Constant undefined + EffectSubscriptionNoData [ + RootRef [omitted] + Constant ':' + ] +] +52 RootRef [omitted] +53 RootRef [omitted] +54 {string} "q-xxxxxxxx.js" +55 {string} "s_XpalYii770E" 56 RootRef [omitted] 57 RootRef [omitted] 58 RootRef [omitted] -59 RootRef [omitted] -60 RootRef [omitted] -61 RootRef [omitted] -62 RootRef [omitted] +59 QRL "[omitted]" +60 Map [ + Constant ':' + RootRef [omitted] +] +61 {number} 1 +62 Array [ + RootRef [omitted] +] 63 RootRef [omitted] 64 RootRef [omitted] 65 RootRef [omitted] 66 RootRef [omitted] 67 RootRef [omitted] 68 RootRef [omitted] -69 Signal [ - Object [ - RootRef [omitted] - {number} 200 - {string} "statusMessage" - Constant undefined - {string} "action" - Constant undefined - {string} "actionResult" - Constant undefined - {string} "formData" - ] - RootRef [omitted] -] +69 RootRef [omitted] 70 RootRef [omitted] 71 RootRef [omitted] -72 Constant undefined -73 {string} "s_1sKEZ9Pg1ow" +72 RootRef [omitted] +73 RootRef [omitted] 74 RootRef [omitted] 75 RootRef [omitted] -76 {string} "s_yw17chooOac" +76 RootRef [omitted] 77 RootRef [omitted] -78 Constant undefined +78 RootRef [omitted] 79 RootRef [omitted] -80 Object [ - {string} "url" - RootRef [omitted] - {string} "params" - Object 0 - {string} "isNavigating" - Constant false - {string} "prevUrl" -] -81 {string} "s_XpalYii770E" -82 RootRef [omitted] -83 RootRef [omitted] -84 {string} "s_69B0DK0eZJc" -85 RootRef [omitted] -86 RootRef [omitted] -87 RootRef [omitted] -88 Map [ - RootRef [omitted] - RootRef [omitted] -] -89 {string} "s_QwONcWD5gIg" +80 Constant undefined +81 {string} "q-xxxxxxxx.js" +82 {string} "s_1sKEZ9Pg1ow" +83 Object 0 +84 {string} "q-xxxxxxxx.js" +85 {string} "s_yw17chooOac" +86 {string} "q-xxxxxxxx.js" +87 {string} "s_69B0DK0eZJc" +88 {string} "q-xxxxxxxx.js" +89 {string} "s_8j8Vrz2yUIM" 90 RootRef [omitted] -91 {string} "q-xxxxxxxx.js" -92 {string} "s_LqnNyU1Iy8c" -93 RootRef [omitted] -94 QRL "[omitted]" -95 Object [ +91 RootRef [omitted] +92 {string} "q-xxxxxxxx.js" +93 {string} "s_QwONcWD5gIg" +94 RootRef [omitted] +95 {string} "q-xxxxxxxx.js" +96 {string} "s_LqnNyU1Iy8c" +97 RootRef [omitted] +98 QRL "[omitted]" +99 Object [ {string} "r" RootRef [omitted] ] -96 {string} "q-xxxxxxxx.js" -97 {string} "_rsc" -98 RootRef [omitted] -99 RootRef [omitted] -100 RootRef [omitted] -101 RootRef [omitted] +100 {string} "q-xxxxxxxx.js" +101 {string} "_rsc" 102 RootRef [omitted] 103 RootRef [omitted] 104 RootRef [omitted] @@ -672,19 +662,25 @@ 112 RootRef [omitted] 113 RootRef [omitted] 114 RootRef [omitted] -115 {string} "q-xxxxxxxx.js" -116 {string} "s_9CrWYOoCpgY" +115 RootRef [omitted] +116 RootRef [omitted] 117 RootRef [omitted] 118 RootRef [omitted] 119 RootRef [omitted] 120 RootRef [omitted] 121 RootRef [omitted] -122 {string} "s_lKDAUb7ao7U" +122 RootRef [omitted] 123 RootRef [omitted] -124 Promise [ +124 RootRef [omitted] +125 {string} "q-xxxxxxxx.js" +126 {string} "s_9CrWYOoCpgY" +127 {string} "q-xxxxxxxx.js" +128 {string} "s_lKDAUb7ao7U" +129 RootRef [omitted] +130 Promise [ Constant true RootRef [omitted] ] -125 ForwardRefs [ - 124 +131 ForwardRefs [ + 130 ] diff --git a/e2e/qwik-e2e/dev-server.ts b/e2e/qwik-e2e/dev-server.ts index b4ce046dfaa..a98f6041315 100644 --- a/e2e/qwik-e2e/dev-server.ts +++ b/e2e/qwik-e2e/dev-server.ts @@ -239,6 +239,14 @@ export { router } plugins: [ ...plugins, optimizer.qwikVite({ + // QWIK_AB_RUST=1 selects the rust optimizer, for A/B parity comparison. + ...(process.env.QWIK_AB_RUST + ? { + optimizerOptions: { + tsOptimizer: false, + } as any, + } + : {}), entryStrategy: { type: 'segment' }, client: { outDir: join(appDistDir, appName), @@ -258,10 +266,22 @@ export { router } emitAssets: true, minify: false, ssr: enableRouterServer ? qwikRouterVirtualEntry : resolve(appSrcDir, entrySsrFileName), + // Split the SSR build: single-file output inlines dynamic imports and + // evaluates them eagerly at top level, which defeats the config's lazy + // route/server$ imports and reintroduces module-order TDZs. + rollupOptions: { output: { inlineDynamicImports: false } }, }, plugins: [ ...plugins, optimizer.qwikVite({ + // QWIK_AB_RUST=1 selects the rust optimizer, for A/B parity comparison. + ...(process.env.QWIK_AB_RUST + ? { + optimizerOptions: { + tsOptimizer: false, + } as any, + } + : {}), experimental: ['each', 'show', 'suspense', 'blockSSR'], ssr: { manifestInput: clientManifest, diff --git a/eslint.config.mjs b/eslint.config.mjs index 0d3d83b1a28..2d06bf288db 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -114,6 +114,13 @@ export default tseslint.config( 'no-console': 'off', }, }, + { + // Benchmark and rust-convergence tests report their results on stdout. + files: ['packages/ts-optimizer/tests/**/*.{ts,tsx}'], + rules: { + 'no-console': 'off', + }, + }, { files: ['packages/qwik/src/**/*.{ts,tsx}'], languageOptions: { diff --git a/flake.lock b/flake.lock index d7e0b490582..298ec4167a0 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1767640445, - "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=", + "lastModified": 1784356753, + "narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5", + "rev": "61b7c44c4073f0b827768aff0049561b5110ea5a", "type": "github" }, "original": { @@ -43,11 +43,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1767754000, - "narHash": "sha256-znoNJs2QZFl+wCFLd6FbUJ00c74kvzOjyQYXc45uFvo=", + "lastModified": 1784438913, + "narHash": "sha256-NYF7ZM5ip0u+w1pBFDpIGEbrbgN/wpnLFAmBkWkYMXw=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "0b3a5ad260479f2c9bdadf3ba5b2a4be359cfcdd", + "rev": "afacd6819d3765a05814ee8e3de74c77d42ac799", "type": "github" }, "original": { diff --git a/package.json b/package.json index 27025997d16..391c49e8908 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "comments": { "01": "devDependencies includes reference to @qwik.dev/core: workspace:*. This is needed or e2e tests will fail", "02": " It would be nice to be able to remove this dependency and fix the test.", - "03": "devDependencies can't include reference to @qwik.dev/router or e2e test will fail." + "03": "devDependencies can't include reference to @qwik.dev/router or e2e test will fail.", + "04": "pnpm.peerDependencyRules covers the two peers no version bump can satisfy:", + "05": " eslint-plugin-import has no eslint 10 release yet, and it lints clean against it.", + "06": " @modular-forms/qwik asks for the v1 @builder.io peers; the docs demo runs on v2." }, "config": { "syncpack": { @@ -24,6 +27,7 @@ { "label": "optimizer needs exact workspace version", "dependencies": [ + "@qwik.dev/ts-optimizer", "@qwik.dev/optimizer" ], "dependencyTypes": [ @@ -59,7 +63,7 @@ "dependencyTypes": [ "dev" ], - "pinVersion": "1.61.0" + "pinVersion": "1.61.1" } ], "semverGroups": [ @@ -89,7 +93,7 @@ } }, "dependencies": { - "esbuild-plugin-raw": "^0.3.0" + "esbuild-plugin-raw": "^0.4.2" }, "devDependencies": { "@changesets/cli": "2.29.5", @@ -104,7 +108,7 @@ "@napi-rs/triples": "1.2.0", "@node-rs/helper": "1.6.0", "@octokit/action": "6.1.0", - "@playwright/test": "1.61.0", + "@playwright/test": "1.61.1", "@qwik.dev/core": "workspace:*", "@qwik.dev/optimizer": "workspace:*", "@qwik.dev/partytown": "0.13.2", @@ -112,7 +116,7 @@ "@types/brotli": "1.3.4", "@types/bun": "1.3.10", "@types/express": "5.0.6", - "@types/node": "24.10.0", + "@types/node": "25.6.0", "@types/path-browserify": "1.0.3", "@types/prompts": "2.4.9", "@types/react": "19.2.14", @@ -120,7 +124,7 @@ "@types/tmp": "0.2.6", "@types/which-pm-runs": "1.0.2", "@vitejs/plugin-basic-ssl": "2.3.0", - "@vitest/coverage-v8": "4.0.18", + "@vitest/coverage-v8": "4.1.10", "all-contributors-cli": "6.26.1", "brotli": "1.3.3", "create-qwik": "workspace:*", @@ -175,6 +179,31 @@ "pnpm": ">=10.14.0" }, "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a", + "pnpm": { + "supportedArchitectures": { + "cpu": [ + "current", + "wasm32" + ], + "os": [ + "current", + "any" + ], + "libc": [ + "current", + "any" + ] + }, + "peerDependencyRules": { + "allowedVersions": { + "eslint-plugin-import>eslint": "10" + }, + "ignoreMissing": [ + "@builder.io/qwik", + "@builder.io/qwik-city" + ] + } + }, "private": true, "scripts": { "actions.update-pins": "node --experimental-strip-types scripts/update-action-pins.ts", diff --git a/packages/browser-extension/package.json b/packages/browser-extension/package.json index 6bce9126883..dd336a9535b 100644 --- a/packages/browser-extension/package.json +++ b/packages/browser-extension/package.json @@ -8,6 +8,8 @@ "wxt": "^0.20.20" }, "devDependencies": { + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", "@types/chrome": "0.1.38", "typescript": "5.9.3", "vitest": "4.1.10" diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 63a494b5c8e..aeb999b2ac8 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -8,7 +8,7 @@ "debug": "4.4.3", "dree": "^5.1.5", "fast-glob": "^3.3.3", - "oxc-parser": "^0.120.0", + "oxc-parser": "^0.144.0", "rollup-plugin-visualizer": "^7.0.1", "superjson": "^2.2.6", "vite-hot-client": "^2.1.0", @@ -25,7 +25,7 @@ "@tailwindcss/vite": "4.2.4", "@types/debug": "4.1.13", "@types/eslint": "9.6.1", - "@types/node": "24.10.0", + "@types/node": "25.6.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@typescript-eslint/eslint-plugin": "8.57.1", @@ -43,7 +43,7 @@ "react-dom": "19.2.4", "shiki": "4.0.2", "tailwindcss": "4.2.4", - "tsdown": "0.22.0", + "tsdown": "0.22.3", "tsx": "4.21.0", "typescript": "5.9.3", "vite": "8.2.1", diff --git a/packages/docs/package.json b/packages/docs/package.json index ba25d2a6fa6..4e065c018eb 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -5,7 +5,8 @@ "author": "Qwik Team", "bugs": "https://github.com/QwikDev/qwik", "devDependencies": { - "@emnapi/core": "1.9.2", + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@modular-forms/qwik": "0.29.1", @@ -30,7 +31,7 @@ "@supabase/supabase-js": "2.53.0", "@tailwindcss/vite": "4.2.4", "@types/leaflet": "1.9.21", - "@types/node": "24.10.0", + "@types/node": "25.6.0", "@types/prismjs": "1.26.5", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", @@ -40,9 +41,12 @@ "fflate": "0.8.2", "gray-matter": "4.0.3", "leaflet": "1.9.4", + "magic-regexp": "0.11.0", "magic-string": "0.30.21", + "oxc-walker": "1.1.1", "pagefind": "1.4.0", - "playwright": "1.61.0", + "pathe": "2.0.3", + "playwright": "1.61.1", "prettier": "3.7.4", "prism-themes": "1.9.0", "prismjs": "1.30.0", @@ -50,6 +54,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "shiki": "4.0.2", + "siphash": "1.2.0", "snarkdown": "2.0.0", "tailwindcss": "4.2.4", "terser": "5.46.0", diff --git a/packages/docs/src/repl/bundler/bundled.tsx b/packages/docs/src/repl/bundler/bundled.tsx index a18d6eb8610..3cee795e4ab 100644 --- a/packages/docs/src/repl/bundler/bundled.tsx +++ b/packages/docs/src/repl/bundler/bundled.tsx @@ -16,9 +16,7 @@ import qWorkerSharedJs from '../../../node_modules/@qwik.dev/core/dist/worker/wo import qQwikLoaderJs from '../../../node_modules/@qwik.dev/core/dist/qwikloader.debug.js?raw-source'; import qServerMjs from '../../../node_modules/@qwik.dev/core/dist/server.mjs?raw-source'; import qServerDts from '../../../node_modules/@qwik.dev/core/dist/server.d.ts?raw-source'; -import qWasmMjs from '../../../node_modules/@qwik.dev/optimizer/bindings/qwik.wasm.mjs?raw-source'; -import qWasmBinUrl from '../../../node_modules/@qwik.dev/optimizer/bindings/qwik_wasm_bg.wasm?raw-source'; -import qOptimizerMjs from '../../../node_modules/@qwik.dev/optimizer/dist/index.mjs?raw-source'; +import qTsOptimizerMjs from '../../../node_modules/@qwik.dev/core/dist/ts-optimizer.mjs?raw-source'; import { QWIK_PKG_NAME_V1, QWIK_PKG_NAME_V2 } from '../repl-constants'; @@ -76,19 +74,12 @@ const qwikUrls: PkgUrls[string] = { '/dist/worker/worker.node.js': qWorkerNodeJs, '/dist/worker/worker.shared.js': qWorkerSharedJs, '/dist/qwikloader.js': qQwikLoaderJs, - '/bindings/qwik.wasm.mjs': qWasmMjs, - '/bindings/qwik_wasm_bg.wasm': qWasmBinUrl, + '/dist/ts-optimizer.mjs': qTsOptimizerMjs, '/handlers.mjs': qHandlersMjs, }; export const bundled: PkgUrls = { [QWIK_PKG_NAME_V1]: qwikUrls, [QWIK_PKG_NAME_V2]: qwikUrls, - '@qwik.dev/optimizer': { - version: qwikVersion, - '/bindings/qwik.wasm.mjs': qWasmMjs, - '/bindings/qwik_wasm_bg.wasm': qWasmBinUrl, - '/dist/index.mjs': qOptimizerMjs, - }, }; export const getDeps = (qwikVersion: string) => { @@ -106,8 +97,7 @@ export const getDeps = (qwikVersion: string) => { `/dist/core.min.mjs`, `/dist/optimizer.mjs`, `/dist/server.mjs`, - `/bindings/qwik.wasm.mjs`, - `/bindings/qwik_wasm_bg.wasm`, + ...(isV2 ? [`/dist/ts-optimizer.mjs`] : []), `/dist/qwikloader.js`, `/dist/preloader.mjs`, `/dist/worker/index.mjs`, diff --git a/packages/docs/src/repl/bundler/node-module-shim.ts b/packages/docs/src/repl/bundler/node-module-shim.ts new file mode 100644 index 00000000000..f9672e9df68 --- /dev/null +++ b/packages/docs/src/repl/bundler/node-module-shim.ts @@ -0,0 +1,8 @@ +/** + * Oxc-walker statically imports `createRequire` for a lazy parser fallback it only reaches from + * `parseAndWalk`, which the REPL never calls — but the static import alone breaks the browser + * bundle. Throwing beats a silent stub if that ever changes. + */ +export function createRequire(): never { + throw new Error('node:module createRequire is not available in the browser REPL'); +} diff --git a/packages/docs/src/repl/bundler/repl-bundler-worker.ts b/packages/docs/src/repl/bundler/repl-bundler-worker.ts index c277836bd0e..8d263e7821b 100644 --- a/packages/docs/src/repl/bundler/repl-bundler-worker.ts +++ b/packages/docs/src/repl/bundler/repl-bundler-worker.ts @@ -1,4 +1,13 @@ import { rolldown, type OutputAsset, type OutputChunk } from '@rolldown/browser'; +// Browser builds of the TS optimizer's runtime deps, bundled into this worker by vite. +// Rolldown embeds the same oxc version, so reusing its parser saves 4.5MB of extra wasm. +// `/experimental` re-exports these as deprecated aliases; move to `/utils` once that +// subpath ships a `browser` condition (absent as of @rolldown/browser 1.2.2). +import * as rolldownOxc from '@rolldown/browser/experimental'; +import * as oxcWalker from 'oxc-walker'; +import * as magicRegexp from 'magic-regexp'; +import * as magicString from 'magic-string'; +import * as pathe from 'pathe'; import type { PkgUrls, ReplInputOptions, ReplModuleOutput, ReplResult } from '../types'; import { definesPlugin, @@ -89,23 +98,96 @@ self.onmessage = async (e: MessageEvent) => { }; let version: number[]; + +/** + * The per-version `ts-optimizer.mjs` imports its runtime deps by bare specifier. A worker has no + * import map, so each dep bundled into this worker is re-exported through a generated Blob module, + * and the fetched code's import specifiers are rewritten to those Blob URLs before importing. + */ +const shimUrls = new Map(); +function getDepShimUrl(specifier: string, mod: Record): string { + let url = shimUrls.get(specifier); + if (url) { + return url; + } + const globalKey = `__replTsOptimizerDep_${specifier.replace(/\W/g, '_')}`; + (globalThis as Record)[globalKey] = mod; + const lines = [`const m = globalThis[${JSON.stringify(globalKey)}];`]; + for (const name of Object.keys(mod)) { + if (name === 'default') { + lines.push('export default m.default;'); + } else { + lines.push(`export const ${name} = m[${JSON.stringify(name)}];`); + } + } + url = URL.createObjectURL(new Blob([lines.join('\n')], { type: 'text/javascript' })); + shimUrls.set(specifier, url); + return url; +} + +const TS_OPTIMIZER_DEPS: Record> = { + 'oxc-parser': rolldownOxc, + 'oxc-transform': rolldownOxc, + 'oxc-walker': oxcWalker, + 'magic-regexp': magicRegexp, + 'magic-string': magicString, + pathe: pathe, +}; + +async function loadTsOptimizer(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${url}: ${response.status}`); + } + let code = await response.text(); + for (const [specifier, mod] of Object.entries(TS_OPTIMIZER_DEPS)) { + code = code.replaceAll( + new RegExp(`from\\s*(['"])${specifier}\\1`, 'g'), + () => `from ${JSON.stringify(getDepShimUrl(specifier, mod))}` + ); + } + const blobUrl = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); + try { + return await import(/* @vite-ignore */ blobUrl); + } finally { + URL.revokeObjectURL(blobUrl); + } +} + async function loadOptimizer() { const qwikDeps = deps[QWIK_PKG_NAME_V1]; version = qwikDeps.version.split('.').map((v) => parseInt(v, 10)); - const optimizerDeps = deps['@qwik.dev/optimizer']; - const wasmLoader = await import(/* @vite-ignore */ optimizerDeps['/bindings/qwik.wasm.mjs']); - - const wasmBuffer = await fetch(optimizerDeps['/bindings/qwik_wasm_bg.wasm']).then((r) => - r.arrayBuffer() - ); - const wasm = await WebAssembly.compile(wasmBuffer); - await wasmLoader.default(wasm); - binding = wasmLoader; - qwikOptimizerModule = await import(/* @vite-ignore */ optimizerDeps['/dist/index.mjs']); + const tsOptimizerUrl = qwikDeps['/dist/ts-optimizer.mjs']; + let tsOptimizerLoaded = false; + if (tsOptimizerUrl) { + // This core version ships the TS optimizer — no Rust wasm needed. + // Versions predating it 404 here and fall back to the Rust wasm. + try { + qwikOptimizerModule = await loadTsOptimizer(tsOptimizerUrl); + tsOptimizerLoaded = true; + } catch (e) { + console.warn(`ts-optimizer not loadable for ${qwikDeps.version}, using Rust wasm`, e); + } + } + if (!tsOptimizerLoaded) { + const optimizerDeps = deps['@qwik.dev/optimizer']; + const wasmLoader = await import(/* @vite-ignore */ optimizerDeps['/bindings/qwik.wasm.mjs']); + + const wasmBuffer = await fetch(optimizerDeps['/bindings/qwik_wasm_bg.wasm']).then((r) => + r.arrayBuffer() + ); + const wasm = await WebAssembly.compile(wasmBuffer); + await wasmLoader.default(wasm); + binding = wasmLoader; + + qwikOptimizerModule = await import(/* @vite-ignore */ optimizerDeps['/dist/index.mjs']); + } qwikViteModule = await import(/* @vite-ignore */ qwikDeps['/dist/optimizer.mjs']); - console.warn(`Bundler for ${qwikDeps.version} ready (optimizer ${optimizerDeps.version})`); + console.warn( + `Bundler for ${qwikDeps.version} ready (${tsOptimizerLoaded ? 'ts-optimizer' : 'rust optimizer'})` + ); } const getOutput = (o: OutputChunk | OutputAsset) => { diff --git a/packages/docs/src/routes/api/qwik-optimizer/api.json b/packages/docs/src/routes/api/qwik-optimizer/api.json index 66476eeb128..5a4f4ee7b10 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/api.json +++ b/packages/docs/src/routes/api/qwik-optimizer/api.json @@ -2,6 +2,23 @@ "id": "qwik-optimizer", "package": "@qwik.dev/qwik/optimizer", "members": [ + { + "name": "basename", + "id": "path-basename", + "hierarchy": [ + { + "name": "Path", + "id": "path-basename" + }, + { + "name": "basename", + "id": "path-basename" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nbasename(path: string, ext?: string): string;\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npath\n\n\n\n\nstring\n\n\n\n\n\n
\n\next\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.basename.md" + }, { "name": "blockSSR", "id": "experimentalfeatures-blockssr", @@ -33,6 +50,75 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/bundle-graph.ts", "mdFile": "qwik-vite.bundlegraphadder.md" }, + { + "name": "ComponentEntryStrategy", + "id": "componententrystrategy", + "hierarchy": [ + { + "name": "ComponentEntryStrategy", + "id": "componententrystrategy" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface ComponentEntryStrategy \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nmanual?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ntype\n\n\n\n\n\n\n\n'component'\n\n\n\n\n\n
", + "mdFile": "qwik-vite.componententrystrategy.md" + }, + { + "name": "createOptimizer", + "id": "createoptimizer", + "hierarchy": [ + { + "name": "createOptimizer", + "id": "createoptimizer" + } + ], + "kind": "Function", + "content": "```typescript\ncreateOptimizer: (optimizerOptions?: OptimizerOptions) => Promise\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\noptimizerOptions\n\n\n\n\n[OptimizerOptions](#optimizeroptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nPromise<[Optimizer](#optimizer)>", + "mdFile": "qwik-vite.createoptimizer.md" + }, + { + "name": "Diagnostic", + "id": "diagnostic", + "hierarchy": [ + { + "name": "Diagnostic", + "id": "diagnostic" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface Diagnostic \n```\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ncategory\n\n\n\n\n\n\n\n[DiagnosticCategory](#diagnosticcategory)\n\n\n\n\n\n
\n\ncode\n\n\n\n\n\n\n\nstring \\| null\n\n\n\n\n\n
\n\nfile\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nhighlights\n\n\n\n\n\n\n\n[SourceLocation](#sourcelocation)\\[\\] \\| null\n\n\n\n\n\n
\n\nmessage\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nscope\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nsuggestions\n\n\n\n\n\n\n\nstring\\[\\] \\| null\n\n\n\n\n\n
", + "mdFile": "qwik-vite.diagnostic.md" + }, + { + "name": "DiagnosticCategory", + "id": "diagnosticcategory", + "hierarchy": [ + { + "name": "DiagnosticCategory", + "id": "diagnosticcategory" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type DiagnosticCategory = 'error' | 'warning' | 'sourceError';\n```", + "mdFile": "qwik-vite.diagnosticcategory.md" + }, + { + "name": "dirname", + "id": "path-dirname", + "hierarchy": [ + { + "name": "Path", + "id": "path-dirname" + }, + { + "name": "dirname", + "id": "path-dirname" + } + ], + "kind": "MethodSignature", + "content": "```typescript\ndirname(path: string): string;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npath\n\n\n\n\nstring\n\n\n\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.dirname.md" + }, { "name": "each", "id": "experimentalfeatures-each", @@ -50,6 +136,32 @@ "content": "", "mdFile": "qwik-vite.experimentalfeatures.each.md" }, + { + "name": "EmitMode", + "id": "emitmode", + "hierarchy": [ + { + "name": "EmitMode", + "id": "emitmode" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type EmitMode = 'dev' | 'prod' | 'lib' | 'hmr';\n```", + "mdFile": "qwik-vite.emitmode.md" + }, + { + "name": "EntryStrategy", + "id": "entrystrategy", + "hierarchy": [ + { + "name": "EntryStrategy", + "id": "entrystrategy" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type EntryStrategy = InlineEntryStrategy | HoistEntryStrategy | SingleEntryStrategy | HookEntryStrategy | SegmentEntryStrategy | ComponentEntryStrategy | SmartEntryStrategy;\n```\n**References:** [InlineEntryStrategy](#inlineentrystrategy), [HoistEntryStrategy](#hoistentrystrategy), [SingleEntryStrategy](#singleentrystrategy), [HookEntryStrategy](#hookentrystrategy), [SegmentEntryStrategy](#segmententrystrategy), [ComponentEntryStrategy](#componententrystrategy), [SmartEntryStrategy](#smartentrystrategy)", + "mdFile": "qwik-vite.entrystrategy.md" + }, { "name": "ExperimentalFeatures", "id": "experimentalfeatures", @@ -64,6 +176,40 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts", "mdFile": "qwik-vite.experimentalfeatures.md" }, + { + "name": "extname", + "id": "path-extname", + "hierarchy": [ + { + "name": "Path", + "id": "path-extname" + }, + { + "name": "extname", + "id": "path-extname" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nextname(path: string): string;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npath\n\n\n\n\nstring\n\n\n\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.extname.md" + }, + { + "name": "format", + "id": "path-format", + "hierarchy": [ + { + "name": "Path", + "id": "path-format" + }, + { + "name": "format", + "id": "path-format" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nformat(pathObject: {\n root: string;\n dir: string;\n base: string;\n ext: string;\n name: string;\n }): string;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npathObject\n\n\n\n\n{ root: string; dir: string; base: string; ext: string; name: string; }\n\n\n\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.format.md" + }, { "name": "GlobalInjections", "id": "globalinjections", @@ -78,6 +224,45 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts", "mdFile": "qwik-vite.globalinjections.md" }, + { + "name": "HoistEntryStrategy", + "id": "hoistentrystrategy", + "hierarchy": [ + { + "name": "HoistEntryStrategy", + "id": "hoistentrystrategy" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface HoistEntryStrategy \n```\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ntype\n\n\n\n\n\n\n\n'hoist'\n\n\n\n\n\n
", + "mdFile": "qwik-vite.hoistentrystrategy.md" + }, + { + "name": "HookEntryStrategy", + "id": "hookentrystrategy", + "hierarchy": [ + { + "name": "HookEntryStrategy", + "id": "hookentrystrategy" + } + ], + "kind": "Interface", + "content": "> Warning: This API is now obsolete.\n> \n> Use SegmentStrategy instead\n> \n\n\n```typescript\nexport interface HookEntryStrategy \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nmanual?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ntype\n\n\n\n\n\n\n\n'hook'\n\n\n\n\n\n
", + "mdFile": "qwik-vite.hookentrystrategy.md" + }, + { + "name": "InlineEntryStrategy", + "id": "inlineentrystrategy", + "hierarchy": [ + { + "name": "InlineEntryStrategy", + "id": "inlineentrystrategy" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface InlineEntryStrategy \n```\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ntype\n\n\n\n\n\n\n\n'inline'\n\n\n\n\n\n
", + "mdFile": "qwik-vite.inlineentrystrategy.md" + }, { "name": "insights", "id": "experimentalfeatures-insights", @@ -95,6 +280,70 @@ "content": "", "mdFile": "qwik-vite.experimentalfeatures.insights.md" }, + { + "name": "isAbsolute", + "id": "path-isabsolute", + "hierarchy": [ + { + "name": "Path", + "id": "path-isabsolute" + }, + { + "name": "isAbsolute", + "id": "path-isabsolute" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nisAbsolute(path: string): boolean;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npath\n\n\n\n\nstring\n\n\n\n\n\n
\n\n**Returns:**\n\nboolean", + "mdFile": "qwik-vite.path.isabsolute.md" + }, + { + "name": "join", + "id": "path-join", + "hierarchy": [ + { + "name": "Path", + "id": "path-join" + }, + { + "name": "join", + "id": "path-join" + } + ], + "kind": "MethodSignature", + "content": "```typescript\njoin(...paths: string[]): string;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npaths\n\n\n\n\nstring\\[\\]\n\n\n\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.join.md" + }, + { + "name": "MinifyMode", + "id": "minifymode", + "hierarchy": [ + { + "name": "MinifyMode", + "id": "minifymode" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type MinifyMode = 'simplify' | 'none';\n```", + "mdFile": "qwik-vite.minifymode.md" + }, + { + "name": "normalize", + "id": "path-normalize", + "hierarchy": [ + { + "name": "Path", + "id": "path-normalize" + }, + { + "name": "normalize", + "id": "path-normalize" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nnormalize(path: string): string;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npath\n\n\n\n\nstring\n\n\n\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.normalize.md" + }, { "name": "noSPA", "id": "experimentalfeatures-nospa", @@ -112,6 +361,75 @@ "content": "", "mdFile": "qwik-vite.experimentalfeatures.nospa.md" }, + { + "name": "Optimizer", + "id": "optimizer", + "hierarchy": [ + { + "name": "Optimizer", + "id": "optimizer" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface Optimizer \n```\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nsys\n\n\n\n\n\n\n\n[OptimizerSystem](#optimizersystem)\n\n\n\n\nOptimizer system use. This can be updated with a custom file system.\n\n\n
\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[transformModules(opts)](#optimizer-transformmodules)\n\n\n\n\nTransforms the input code string, does not access the file system.\n\n\n
", + "mdFile": "qwik-vite.optimizer.md" + }, + { + "name": "OptimizerOptions", + "id": "optimizeroptions", + "hierarchy": [ + { + "name": "OptimizerOptions", + "id": "optimizeroptions" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface OptimizerOptions \n```\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n\\_optimizer?\n\n\n\n\n\n\n\nunknown\n\n\n\n\n_(Optional)_\n\n\n
\n\nbinding?\n\n\n\n\n\n\n\nany\n\n\n\n\n_(Optional)_\n\n\n
\n\ninlineStylesUpToBytes?\n\n\n\n\n\n\n\nnumber\n\n\n\n\n_(Optional)_ Inline the global styles if they're smaller than this\n\n\n
\n\nsourcemap?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ Enable sourcemaps\n\n\n
\n\nsys?\n\n\n\n\n\n\n\n[OptimizerSystem](#optimizersystem)\n\n\n\n\n_(Optional)_\n\n\n
\n\ntsOptimizer?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ Use the TypeScript optimizer (`@qwik.dev/ts-optimizer`) instead of the Rust one.\n\nDefault `true`\n\n\n
", + "mdFile": "qwik-vite.optimizeroptions.md" + }, + { + "name": "OptimizerSystem", + "id": "optimizersystem", + "hierarchy": [ + { + "name": "OptimizerSystem", + "id": "optimizersystem" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface OptimizerSystem \n```\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ncwd\n\n\n\n\n\n\n\n() => string\n\n\n\n\n\n
\n\ndynamicImport\n\n\n\n\n\n\n\n(path: string) => Promise<any>\n\n\n\n\n\n
\n\nenv\n\n\n\n\n\n\n\n[SystemEnvironment](#systemenvironment)\n\n\n\n\n\n
\n\ngetInputFiles?\n\n\n\n\n\n\n\n(rootDir: string) => Promise<[TransformModuleInput](#transformmoduleinput)\\[\\]>\n\n\n\n\n_(Optional)_\n\n\n
\n\nos\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\npath\n\n\n\n\n\n\n\n[Path](#path)\n\n\n\n\n\n
\n\nstrictDynamicImport\n\n\n\n\n\n\n\n(path: string) => Promise<any>\n\n\n\n\n\n
", + "mdFile": "qwik-vite.optimizersystem.md" + }, + { + "name": "parse", + "id": "path-parse", + "hierarchy": [ + { + "name": "Path", + "id": "path-parse" + }, + { + "name": "parse", + "id": "path-parse" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nparse(path: string): {\n root: string;\n dir: string;\n base: string;\n ext: string;\n name: string;\n };\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npath\n\n\n\n\nstring\n\n\n\n\n\n
\n\n**Returns:**\n\n{ root: string; dir: string; base: string; ext: string; name: string; }", + "mdFile": "qwik-vite.path.parse.md" + }, + { + "name": "Path", + "id": "path", + "hierarchy": [ + { + "name": "Path", + "id": "path" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface Path \n```\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ndelimiter\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\n\n
\n\nposix\n\n\n\n\n`readonly`\n\n\n\n\n[Path](#path)\n\n\n\n\n\n
\n\nsep\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\n\n
\n\nwin32\n\n\n\n\n`readonly`\n\n\n\n\nnull\n\n\n\n\n\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[basename(path, ext)](#path-basename)\n\n\n\n\n\n
\n\n[dirname(path)](#path-dirname)\n\n\n\n\n\n
\n\n[extname(path)](#path-extname)\n\n\n\n\n\n
\n\n[format(pathObject)](#path-format)\n\n\n\n\n\n
\n\n[isAbsolute(path)](#path-isabsolute)\n\n\n\n\n\n
\n\n[join(paths)](#path-join)\n\n\n\n\n\n
\n\n[normalize(path)](#path-normalize)\n\n\n\n\n\n
\n\n[parse(path)](#path-parse)\n\n\n\n\n\n
\n\n[relative(from, to)](#path-relative)\n\n\n\n\n\n
\n\n[resolve(paths)](#path-resolve)\n\n\n\n\n\n
", + "mdFile": "qwik-vite.path.md" + }, { "name": "QwikAsset", "id": "qwikasset", @@ -192,7 +510,7 @@ } ], "kind": "Interface", - "content": "The metadata of the build. One of its uses is storing where QRL symbols are located.\n\n\n```typescript\nexport interface QwikManifest \n```\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nassets?\n\n\n\n\n\n\n\n{ \\[fileName: string\\]: [QwikAsset](#qwikasset); }\n\n\n\n\n_(Optional)_ All assets. The key is the fileName relative to the rootDir\n\n\n
\n\nbundleGraph?\n\n\n\n\n\n\n\n[QwikBundleGraph](#qwikbundlegraph)\n\n\n\n\n_(Optional)_ All bundles in a compact graph format with probabilities\n\n\n
\n\nbundleGraphAsset?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The bundle graph fileName\n\n\n
\n\nbundles\n\n\n\n\n\n\n\n{ \\[fileName: string\\]: [QwikBundle](#qwikbundle); }\n\n\n\n\nAll code bundles, used to know the import graph. The key is the bundle fileName relative to \"build/\"\n\n\n
\n\ncore?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The Qwik core bundle fileName\n\n\n
\n\ninjections?\n\n\n\n\n\n\n\n[GlobalInjections](#globalinjections)\\[\\]\n\n\n\n\n_(Optional)_ CSS etc to inject in the document head\n\n\n
\n\nmanifestHash\n\n\n\n\n\n\n\nstring\n\n\n\n\nContent hash of the manifest, if this changes, the code changed\n\n\n
\n\nmapping\n\n\n\n\n\n\n\n{ \\[symbolName: string\\]: string; }\n\n\n\n\nWhere QRLs are located. The key is the symbol name, the value is the bundle fileName\n\n\n
\n\noptions?\n\n\n\n\n\n\n\n{ target?: string; buildMode?: string; entryStrategy?: { type: EntryStrategy\\['type'\\]; }; }\n\n\n\n\n_(Optional)_ The options used to build the manifest\n\n\n
\n\nplatform?\n\n\n\n\n\n\n\n{ \\[name: string\\]: string; }\n\n\n\n\n_(Optional)_ The platform used to build the manifest\n\n\n
\n\npreloader?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The preloader bundle fileName\n\n\n
\n\nqwikLoader?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The Qwik loader bundle fileName\n\n\n
\n\nsymbols\n\n\n\n\n\n\n\n{ \\[symbolName: string\\]: [QwikSymbol](#qwiksymbol); }\n\n\n\n\nQRL symbols\n\n\n
\n\nversion\n\n\n\n\n\n\n\nstring\n\n\n\n\nThe version of the manifest\n\n\n
", + "content": "The metadata of the build. One of its uses is storing where QRL symbols are located.\n\n\n```typescript\nexport interface QwikManifest \n```\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nassets?\n\n\n\n\n\n\n\n{ \\[fileName: string\\]: [QwikAsset](#qwikasset); }\n\n\n\n\n_(Optional)_ All assets. The key is the fileName relative to the rootDir\n\n\n
\n\nbundleGraph?\n\n\n\n\n\n\n\n[QwikBundleGraph](#qwikbundlegraph)\n\n\n\n\n_(Optional)_ All bundles in a compact graph format with probabilities\n\n\n
\n\nbundleGraphAsset?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The bundle graph fileName\n\n\n
\n\nbundles\n\n\n\n\n\n\n\n{ \\[fileName: string\\]: [QwikBundle](#qwikbundle); }\n\n\n\n\nAll code bundles, used to know the import graph. The key is the bundle fileName relative to \"build/\"\n\n\n
\n\ncore?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The Qwik core bundle fileName\n\n\n
\n\ninjections?\n\n\n\n\n\n\n\n[GlobalInjections](#globalinjections)\\[\\]\n\n\n\n\n_(Optional)_ CSS etc to inject in the document head\n\n\n
\n\nmanifestHash\n\n\n\n\n\n\n\nstring\n\n\n\n\nContent hash of the manifest, if this changes, the code changed\n\n\n
\n\nmapping\n\n\n\n\n\n\n\n{ \\[symbolName: string\\]: string; }\n\n\n\n\nWhere QRLs are located. The key is the symbol name, the value is the bundle fileName\n\n\n
\n\noptions?\n\n\n\n\n\n\n\n{ target?: string; buildMode?: string; entryStrategy?: { type: [EntryStrategy](#entrystrategy)\\['type'\\]; }; }\n\n\n\n\n_(Optional)_ The options used to build the manifest\n\n\n
\n\nplatform?\n\n\n\n\n\n\n\n{ \\[name: string\\]: string; }\n\n\n\n\n_(Optional)_ The platform used to build the manifest\n\n\n
\n\npreloader?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The preloader bundle fileName\n\n\n
\n\nqwikLoader?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The Qwik loader bundle fileName\n\n\n
\n\nsymbols\n\n\n\n\n\n\n\n{ \\[symbolName: string\\]: [QwikSymbol](#qwiksymbol); }\n\n\n\n\nQRL symbols\n\n\n
\n\nversion\n\n\n\n\n\n\n\nstring\n\n\n\n\nThe version of the manifest\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts", "mdFile": "qwik-vite.qwikmanifest.md" }, @@ -220,7 +538,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface QwikRolldownPluginOptions \n```\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nbuildMode?\n\n\n\n\n\n\n\n[QwikBuildMode](#qwikbuildmode)\n\n\n\n\n_(Optional)_ Build `production` or `development`.\n\nDefault `development`\n\n\n
\n\ncsr?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\ndebug?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ Prints verbose Qwik plugin debug logs.\n\nDefault `false`\n\n\n
\n\nentryStrategy?\n\n\n\n\n\n\n\nEntryStrategy\n\n\n\n\n_(Optional)_ The Qwik entry strategy to use while building for production. During development the type is always `segment`.\n\nDefault `{ type: \"smart\" }`)\n\n\n
\n\nexperimental?\n\n\n\n\n\n\n\n(keyof typeof [ExperimentalFeatures](#experimentalfeatures))\\[\\]\n\n\n\n\n_(Optional)_ Experimental features. These can come and go in patch releases, and their API is not guaranteed to be stable between releases.\n\n\n
\n\nlint?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ Run eslint on the source files for the ssr build or dev server. This can slow down startup on large projects. Defaults to `true`\n\n\n
\n\nmanifestInput?\n\n\n\n\n\n\n\n[QwikManifest](#qwikmanifest)\n\n\n\n\n_(Optional)_ The SSR build requires the manifest generated during the client build. The `manifestInput` option can be used to manually provide a manifest.\n\nDefault `undefined`\n\n\n
\n\nmanifestOutput?\n\n\n\n\n\n\n\n(manifest: [QwikManifest](#qwikmanifest)) => Promise<void> \\| void\n\n\n\n\n_(Optional)_ The client build will create a manifest and this hook is called with the generated build data.\n\nDefault `undefined`\n\n\n
\n\noptimizerOptions?\n\n\n\n\n\n\n\nOptimizerOptions\n\n\n\n\n_(Optional)_\n\n\n
\n\nrootDir?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The root of the application, which is commonly the same directory as `package.json` and `rollup.config.js`.\n\nDefault `process.cwd()`\n\n\n
\n\nsrcDir?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The source directory to find all the Qwik components. Since Qwik does not have a single input, the `srcDir` is used to recursively find Qwik files.\n\nDefault `src`\n\n\n
\n\nsrcInputs?\n\n\n\n\n\n\n\nTransformModuleInput\\[\\] \\| null\n\n\n\n\n_(Optional)_ Alternative to `srcDir`, where `srcInputs` is able to provide the files manually. This option is useful for an environment without a file system, such as a webworker.\n\nDefault: `null`\n\n\n
\n\ntarget?\n\n\n\n\n\n\n\n[QwikBuildTarget](#qwikbuildtarget)\n\n\n\n\n_(Optional)_ Target `client` or `ssr`.\n\nDefault `client`\n\n\n
\n\ntransformedModuleOutput?\n\n\n\n\n\n\n\n((transformedModules: TransformModule\\[\\]) => Promise<void> \\| void) \\| null\n\n\n\n\n_(Optional)_ Hook that's called after the build and provides all of the transformed modules that were used before bundling.\n\n\n
", + "content": "```typescript\nexport interface QwikRolldownPluginOptions \n```\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nbuildMode?\n\n\n\n\n\n\n\n[QwikBuildMode](#qwikbuildmode)\n\n\n\n\n_(Optional)_ Build `production` or `development`.\n\nDefault `development`\n\n\n
\n\ncsr?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\ndebug?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ Prints verbose Qwik plugin debug logs.\n\nDefault `false`\n\n\n
\n\nentryStrategy?\n\n\n\n\n\n\n\n[EntryStrategy](#entrystrategy)\n\n\n\n\n_(Optional)_ The Qwik entry strategy to use while building for production. During development the type is always `segment`.\n\nDefault `{ type: \"smart\" }`)\n\n\n
\n\nexperimental?\n\n\n\n\n\n\n\n(keyof typeof [ExperimentalFeatures](#experimentalfeatures))\\[\\]\n\n\n\n\n_(Optional)_ Experimental features. These can come and go in patch releases, and their API is not guaranteed to be stable between releases.\n\n\n
\n\nlint?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ Run eslint on the source files for the ssr build or dev server. This can slow down startup on large projects. Defaults to `true`\n\n\n
\n\nmanifestInput?\n\n\n\n\n\n\n\n[QwikManifest](#qwikmanifest)\n\n\n\n\n_(Optional)_ The SSR build requires the manifest generated during the client build. The `manifestInput` option can be used to manually provide a manifest.\n\nDefault `undefined`\n\n\n
\n\nmanifestOutput?\n\n\n\n\n\n\n\n(manifest: [QwikManifest](#qwikmanifest)) => Promise<void> \\| void\n\n\n\n\n_(Optional)_ The client build will create a manifest and this hook is called with the generated build data.\n\nDefault `undefined`\n\n\n
\n\noptimizerOptions?\n\n\n\n\n\n\n\n[OptimizerOptions](#optimizeroptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\nrootDir?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The root of the application, which is commonly the same directory as `package.json` and `rollup.config.js`.\n\nDefault `process.cwd()`\n\n\n
\n\nsrcDir?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ The source directory to find all the Qwik components. Since Qwik does not have a single input, the `srcDir` is used to recursively find Qwik files.\n\nDefault `src`\n\n\n
\n\nsrcInputs?\n\n\n\n\n\n\n\n[TransformModuleInput](#transformmoduleinput)\\[\\] \\| null\n\n\n\n\n_(Optional)_ Alternative to `srcDir`, where `srcInputs` is able to provide the files manually. This option is useful for an environment without a file system, such as a webworker.\n\nDefault: `null`\n\n\n
\n\ntarget?\n\n\n\n\n\n\n\n[QwikBuildTarget](#qwikbuildtarget)\n\n\n\n\n_(Optional)_ Target `client` or `ssr`.\n\nDefault `client`\n\n\n
\n\ntransformedModuleOutput?\n\n\n\n\n\n\n\n((transformedModules: [TransformModule](#transformmodule)\\[\\]) => Promise<void> \\| void) \\| null\n\n\n\n\n_(Optional)_ Hook that's called after the build and provides all of the transformed modules that were used before bundling.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts", "mdFile": "qwik-vite.qwikrolldownpluginoptions.md" }, @@ -304,7 +622,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface QwikVitePluginApi \n```\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ngetClientOutDir\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\ngetClientPublicOutDir\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\ngetManifest\n\n\n\n\n\n\n\n() => [QwikManifest](#qwikmanifest) \\| null\n\n\n\n\n\n
\n\ngetOptimizer\n\n\n\n\n\n\n\n() => Optimizer \\| null\n\n\n\n\n\n
\n\ngetOptions\n\n\n\n\n\n\n\n() => NormalizedQwikPluginOptions\n\n\n\n\n\n
\n\ngetRootDir\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\nonSegment\n\n\n\n\n\n\n\n(callback: SegmentCallback) => void\n\n\n\n\nRegister a callback that fires for each segment emitted during transform.\n\n\n
\n\nregisterBundleGraphAdder\n\n\n\n\n\n\n\n(adder: [BundleGraphAdder](#bundlegraphadder)) => void\n\n\n\n\n\n
", + "content": "```typescript\nexport interface QwikVitePluginApi \n```\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ngetClientOutDir\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\ngetClientPublicOutDir\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\ngetManifest\n\n\n\n\n\n\n\n() => [QwikManifest](#qwikmanifest) \\| null\n\n\n\n\n\n
\n\ngetOptimizer\n\n\n\n\n\n\n\n() => [Optimizer](#optimizer) \\| null\n\n\n\n\n\n
\n\ngetOptions\n\n\n\n\n\n\n\n() => NormalizedQwikPluginOptions\n\n\n\n\n\n
\n\ngetRootDir\n\n\n\n\n\n\n\n() => string \\| null\n\n\n\n\n\n
\n\nonSegment\n\n\n\n\n\n\n\n(callback: SegmentCallback) => void\n\n\n\n\nRegister a callback that fires for each segment emitted during transform.\n\n\n
\n\nregisterBundleGraphAdder\n\n\n\n\n\n\n\n(adder: [BundleGraphAdder](#bundlegraphadder)) => void\n\n\n\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts", "mdFile": "qwik-vite.qwikvitepluginapi.md" }, @@ -322,6 +640,40 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts", "mdFile": "qwik-vite.qwikvitepluginoptions.md" }, + { + "name": "relative", + "id": "path-relative", + "hierarchy": [ + { + "name": "Path", + "id": "path-relative" + }, + { + "name": "relative", + "id": "path-relative" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nrelative(from: string, to: string): string;\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfrom\n\n\n\n\nstring\n\n\n\n\n\n
\n\nto\n\n\n\n\nstring\n\n\n\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.relative.md" + }, + { + "name": "resolve", + "id": "path-resolve", + "hierarchy": [ + { + "name": "Path", + "id": "path-resolve" + }, + { + "name": "resolve", + "id": "path-resolve" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nresolve(...paths: string[]): string;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\npaths\n\n\n\n\nstring\\[\\]\n\n\n\n\n\n
\n\n**Returns:**\n\nstring", + "mdFile": "qwik-vite.path.resolve.md" + }, { "name": "ResolvedManifest", "id": "resolvedmanifest", @@ -336,6 +688,32 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts", "mdFile": "qwik-vite.resolvedmanifest.md" }, + { + "name": "SegmentAnalysis", + "id": "segmentanalysis", + "hierarchy": [ + { + "name": "SegmentAnalysis", + "id": "segmentanalysis" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface SegmentAnalysis \n```\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ncanonicalFilename\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\ncaptureNames?\n\n\n\n\n\n\n\nstring\\[\\]\n\n\n\n\n_(Optional)_ The transformed names of scoped variables, if any\n\n\n
\n\ncaptures\n\n\n\n\n\n\n\nboolean\n\n\n\n\n\n
\n\nctxKind\n\n\n\n\n\n\n\n'eventHandler' \\| 'function'\n\n\n\n\n\n
\n\nctxName\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\ndisplayName\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nentry\n\n\n\n\n\n\n\nstring \\| null\n\n\n\n\n\n
\n\nextension\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nhash\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nloc\n\n\n\n\n\n\n\n\\[number, number\\]\n\n\n\n\n\n
\n\nname\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\norigin\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nparamNames?\n\n\n\n\n\n\n\nstring\\[\\]\n\n\n\n\n_(Optional)_ The parameter names if it's a function with parameters\n\n\n
\n\nparent\n\n\n\n\n\n\n\nstring \\| null\n\n\n\n\n\n
", + "mdFile": "qwik-vite.segmentanalysis.md" + }, + { + "name": "SegmentEntryStrategy", + "id": "segmententrystrategy", + "hierarchy": [ + { + "name": "SegmentEntryStrategy", + "id": "segmententrystrategy" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface SegmentEntryStrategy \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nmanual?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ntype\n\n\n\n\n\n\n\n'segment'\n\n\n\n\n\n
", + "mdFile": "qwik-vite.segmententrystrategy.md" + }, { "name": "ServerQwikManifest", "id": "serverqwikmanifest", @@ -367,6 +745,45 @@ "content": "", "mdFile": "qwik-vite.experimentalfeatures.show.md" }, + { + "name": "SingleEntryStrategy", + "id": "singleentrystrategy", + "hierarchy": [ + { + "name": "SingleEntryStrategy", + "id": "singleentrystrategy" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface SingleEntryStrategy \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nmanual?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ntype\n\n\n\n\n\n\n\n'single'\n\n\n\n\n\n
", + "mdFile": "qwik-vite.singleentrystrategy.md" + }, + { + "name": "SmartEntryStrategy", + "id": "smartentrystrategy", + "hierarchy": [ + { + "name": "SmartEntryStrategy", + "id": "smartentrystrategy" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface SmartEntryStrategy \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nmanual?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ntype\n\n\n\n\n\n\n\n'smart'\n\n\n\n\n\n
", + "mdFile": "qwik-vite.smartentrystrategy.md" + }, + { + "name": "SourceLocation", + "id": "sourcelocation", + "hierarchy": [ + { + "name": "SourceLocation", + "id": "sourcelocation" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface SourceLocation \n```\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nendCol\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
\n\nendLine\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
\n\nhi\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
\n\nlo\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
\n\nstartCol\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
\n\nstartLine\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
", + "mdFile": "qwik-vite.sourcelocation.md" + }, { "name": "suspense", "id": "experimentalfeatures-suspense", @@ -412,6 +829,101 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts", "mdFile": "qwik-vite.symbolmapperfn.md" }, + { + "name": "SystemEnvironment", + "id": "systemenvironment", + "hierarchy": [ + { + "name": "SystemEnvironment", + "id": "systemenvironment" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type SystemEnvironment = 'node' | 'deno' | 'bun' | 'webworker' | 'browsermain' | 'unknown';\n```", + "mdFile": "qwik-vite.systemenvironment.md" + }, + { + "name": "TransformModule", + "id": "transformmodule", + "hierarchy": [ + { + "name": "TransformModule", + "id": "transformmodule" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface TransformModule \n```\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ncode\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nimports?\n\n\n\n\n\n\n\nstring\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\nisEntry\n\n\n\n\n\n\n\nboolean\n\n\n\n\n\n
\n\nmap\n\n\n\n\n\n\n\nstring \\| null\n\n\n\n\n\n
\n\norigPath\n\n\n\n\n\n\n\nstring \\| null\n\n\n\n\n\n
\n\npath\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nsegment\n\n\n\n\n\n\n\n[SegmentAnalysis](#segmentanalysis) \\| null\n\n\n\n\n\n
", + "mdFile": "qwik-vite.transformmodule.md" + }, + { + "name": "TransformModuleInput", + "id": "transformmoduleinput", + "hierarchy": [ + { + "name": "TransformModuleInput", + "id": "transformmoduleinput" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface TransformModuleInput \n```\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ncode\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\ndevPath?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\npath\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
", + "mdFile": "qwik-vite.transformmoduleinput.md" + }, + { + "name": "transformModules", + "id": "optimizer-transformmodules", + "hierarchy": [ + { + "name": "Optimizer", + "id": "optimizer-transformmodules" + }, + { + "name": "transformModules", + "id": "optimizer-transformmodules" + } + ], + "kind": "MethodSignature", + "content": "Transforms the input code string, does not access the file system.\n\n\n```typescript\ntransformModules(opts: TransformModulesOptions): Promise;\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nopts\n\n\n\n\n[TransformModulesOptions](#transformmodulesoptions)\n\n\n\n\n\n
\n\n**Returns:**\n\nPromise<[TransformOutput](#transformoutput)>", + "mdFile": "qwik-vite.optimizer.transformmodules.md" + }, + { + "name": "TransformModulesOptions", + "id": "transformmodulesoptions", + "hierarchy": [ + { + "name": "TransformModulesOptions", + "id": "transformmodulesoptions" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface TransformModulesOptions extends TransformOptions \n```\n**Extends:** [TransformOptions](#transformoptions)\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ninput\n\n\n\n\n\n\n\n[TransformModuleInput](#transformmoduleinput)\\[\\]\n\n\n\n\n\n
", + "mdFile": "qwik-vite.transformmodulesoptions.md" + }, + { + "name": "TransformOptions", + "id": "transformoptions", + "hierarchy": [ + { + "name": "TransformOptions", + "id": "transformoptions" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface TransformOptions \n```\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nentryStrategy?\n\n\n\n\n\n\n\n[EntryStrategy](#entrystrategy)\n\n\n\n\n_(Optional)_\n\n\n
\n\nexplicitExtensions?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\nisServer?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\nminify?\n\n\n\n\n\n\n\n[MinifyMode](#minifymode)\n\n\n\n\n_(Optional)_\n\n\n
\n\nmode?\n\n\n\n\n\n\n\n[EmitMode](#emitmode)\n\n\n\n\n_(Optional)_\n\n\n
\n\npreserveFilenames?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\nregCtxName?\n\n\n\n\n\n\n\nstring\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\nrootDir?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\nscope?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\nsourceMaps?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\nsrcDir\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\nstripCtxName?\n\n\n\n\n\n\n\nstring\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\nstripEventHandlers?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\nstripExports?\n\n\n\n\n\n\n\nstring\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\ntranspileJsx?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\ntranspileTs?\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
", + "mdFile": "qwik-vite.transformoptions.md" + }, + { + "name": "TransformOutput", + "id": "transformoutput", + "hierarchy": [ + { + "name": "TransformOutput", + "id": "transformoutput" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface TransformOutput \n```\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\ndiagnostics\n\n\n\n\n\n\n\n[Diagnostic](#diagnostic)\\[\\]\n\n\n\n\n\n
\n\nisJsx\n\n\n\n\n\n\n\nboolean\n\n\n\n\n\n
\n\nisTypeScript\n\n\n\n\n\n\n\nboolean\n\n\n\n\n\n
\n\nmodules\n\n\n\n\n\n\n\n[TransformModule](#transformmodule)\\[\\]\n\n\n\n\n\n
", + "mdFile": "qwik-vite.transformoutput.md" + }, { "name": "valibot", "id": "experimentalfeatures-valibot", diff --git a/packages/docs/src/routes/api/qwik-optimizer/index.mdx b/packages/docs/src/routes/api/qwik-optimizer/index.mdx index 7f09d180e4e..7a7ec5eac6a 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/index.mdx +++ b/packages/docs/src/routes/api/qwik-optimizer/index.mdx @@ -4,45 +4,19 @@ title: \@qwik.dev/qwik/optimizer API Reference # [API](/api) › @qwik.dev/qwik/optimizer -

blockSSR

- -

BundleGraphAdder

- -A function that returns a map of bundle names to their dependencies. - -```typescript -export type BundleGraphAdder = (manifest: QwikManifest) => Record< - string, - { - imports?: string[]; - dynamicImports?: string[]; - } ->; -``` - -**References:** [QwikManifest](#qwikmanifest) - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/bundle-graph.ts) - -

each

- -

ExperimentalFeatures

- -Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement. - -Add experimental features to this enum definition. +

basename

```typescript -export declare enum ExperimentalFeatures +basename(path: string, ext?: string): string; ``` - - - -
-Member +Parameter -Value +Type @@ -51,103 +25,58 @@ Description
-blockSSR - - - -`"blockSSR"` - - - -Enable the `blockSSR: false` route loader option - -
- -each - - - -`"each"` - - - -Enable the Each keyed-list primitive - -
- -insights - - - -`"insights"` - - - -Enable the ability to use the Qwik Insights vite plugin and `` component - -
- -noSPA +path -`"noSPA"` +string -Disable SPA navigation handler in Qwik Router -
-show +ext -`"show"` +string -Enable the Show conditional primitive +_(Optional)_
- -suspense - - - -`"suspense"` - - - -Enable the Suspense fallback primitive +
- - +**Returns:** -valibot +string - +

blockSSR

-`"valibot"` +

BundleGraphAdder

- +A function that returns a map of bundle names to their dependencies. -Enable the Valibot form validation +```typescript +export type BundleGraphAdder = (manifest: QwikManifest) => Record< + string, + { + imports?: string[]; + dynamicImports?: string[]; + } +>; +``` - - +**References:** [QwikManifest](#qwikmanifest) -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/bundle-graph.ts) -

GlobalInjections

+

ComponentEntryStrategy

```typescript -export interface GlobalInjections +export interface ComponentEntryStrategy ``` -
@@ -169,13 +98,13 @@ Description
-attributes? +manual? -\{ [key: string]: string; } +Record<string, string> @@ -184,51 +113,28 @@ _(Optional)_
-location - - - - - -'head' \| 'body' - - - -
- -tag +type -string +'component'
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

insights

- -

noSPA

- -

QwikAsset

+

createOptimizer

```typescript -export interface QwikAsset +createOptimizer: (optimizerOptions?: OptimizerOptions) => Promise; ``` -
-Property - - - -Modifiers +Parameter @@ -241,58 +147,27 @@ Description
-name - - - - - -string \| undefined - - - -Name of the asset - -
- -size +optimizerOptions - - -number +[OptimizerOptions](#optimizeroptions) -Size of the asset +_(Optional)_
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

QwikBuildMode

- -```typescript -export type QwikBuildMode = "production" | "development"; -``` - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts) - -

QwikBuildTarget

- -```typescript -export type QwikBuildTarget = "client" | "ssr" | "lib" | "test"; -``` +**Returns:** -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts) +Promise<[Optimizer](#optimizer)> -

QwikBundle

+

Diagnostic

```typescript -export interface QwikBundle +export interface Diagnostic ```
@@ -314,140 +189,112 @@ Description
-dynamicImports? +category -string[] +[DiagnosticCategory](#diagnosticcategory) -_(Optional)_ Dynamic imports -
-imports? +code -string[] +string \| null -_(Optional)_ Direct imports -
-interactivity? +file -number +string -_(Optional)_ Interactivity score of the bundle -
-origins? +highlights -string[] +[SourceLocation](#sourcelocation)[] \| null -_(Optional)_ Source files of the bundle -
-size +message -number +string -Size of the bundle -
-symbols? +scope -string[] +string -_(Optional)_ Symbols in the bundle -
-total +suggestions -number +string[] \| null -Total size of this bundle's static import graph -
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

QwikBundleGraph

- -Bundle graph. - -Format: [ 'bundle-a.js', 3, 5 // Depends on 'bundle-b.js' and 'bundle-c.js' 'bundle-b.js', 5, // Depends on 'bundle-c.js' 'bundle-c.js', ] +

DiagnosticCategory

```typescript -export type QwikBundleGraph = Array; +export type DiagnosticCategory = "error" | "warning" | "sourceError"; ``` -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

QwikManifest

- -The metadata of the build. One of its uses is storing where QRL symbols are located. +

dirname

```typescript -export interface QwikManifest +dirname(path: string): string; ``` -
-Property - - - -Modifiers +Parameter @@ -460,37 +307,2505 @@ Description
-assets? - - +path -\{ [fileName: string]: [QwikAsset](#qwikasset); } +string -_(Optional)_ All assets. The key is the fileName relative to the rootDir -
+
-bundleGraph? +**Returns:** - +string + +

each

+ +

EmitMode

+ +```typescript +export type EmitMode = "dev" | "prod" | "lib" | "hmr"; +``` + +

EntryStrategy

+ +```typescript +export type EntryStrategy = + | InlineEntryStrategy + | HoistEntryStrategy + | SingleEntryStrategy + | HookEntryStrategy + | SegmentEntryStrategy + | ComponentEntryStrategy + | SmartEntryStrategy; +``` + +**References:** [InlineEntryStrategy](#inlineentrystrategy), [HoistEntryStrategy](#hoistentrystrategy), [SingleEntryStrategy](#singleentrystrategy), [HookEntryStrategy](#hookentrystrategy), [SegmentEntryStrategy](#segmententrystrategy), [ComponentEntryStrategy](#componententrystrategy), [SmartEntryStrategy](#smartentrystrategy) + +

ExperimentalFeatures

+ +Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement. + +Add experimental features to this enum definition. + +```typescript +export declare enum ExperimentalFeatures +``` + + + + + + + + + +
+ +Member + + + +Value + + + +Description + +
+ +blockSSR + + + +`"blockSSR"` + + + +Enable the `blockSSR: false` route loader option + +
+ +each + + + +`"each"` + + + +Enable the Each keyed-list primitive + +
+ +insights + + + +`"insights"` + + + +Enable the ability to use the Qwik Insights vite plugin and `` component + +
+ +noSPA + + + +`"noSPA"` + + + +Disable SPA navigation handler in Qwik Router + +
+ +show + + + +`"show"` + + + +Enable the Show conditional primitive + +
+ +suspense + + + +`"suspense"` + + + +Enable the Suspense fallback primitive + +
+ +valibot + + + +`"valibot"` + + + +Enable the Valibot form validation + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts) + +

extname

+ +```typescript +extname(path: string): string; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +path + + + +string + + + +
+ +**Returns:** + +string + +

format

+ +```typescript +format(pathObject: { + root: string; + dir: string; + base: string; + ext: string; + name: string; + }): string; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +pathObject + + + +\{ root: string; dir: string; base: string; ext: string; name: string; } + + + +
+ +**Returns:** + +string + +

GlobalInjections

+ +```typescript +export interface GlobalInjections +``` + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +attributes? + + + + + +\{ [key: string]: string; } + + + +_(Optional)_ + +
+ +location + + + + + +'head' \| 'body' + + + +
+ +tag + + + + + +string + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

HoistEntryStrategy

+ +```typescript +export interface HoistEntryStrategy +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +type + + + + + +'hoist' + + + +
+ +

HookEntryStrategy

+ +> Warning: This API is now obsolete. +> +> Use SegmentStrategy instead + +```typescript +export interface HookEntryStrategy +``` + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +manual? + + + + + +Record<string, string> + + + +_(Optional)_ + +
+ +type + + + + + +'hook' + + + +
+ +

InlineEntryStrategy

+ +```typescript +export interface InlineEntryStrategy +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +type + + + + + +'inline' + + + +
+ +

insights

+ +

isAbsolute

+ +```typescript +isAbsolute(path: string): boolean; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +path + + + +string + + + +
+ +**Returns:** + +boolean + +

join

+ +```typescript +join(...paths: string[]): string; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +paths + + + +string[] + + + +
+ +**Returns:** + +string + +

MinifyMode

+ +```typescript +export type MinifyMode = "simplify" | "none"; +``` + +

normalize

+ +```typescript +normalize(path: string): string; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +path + + + +string + + + +
+ +**Returns:** + +string + +

noSPA

+ +

Optimizer

+ +```typescript +export interface Optimizer +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +sys + + + + + +[OptimizerSystem](#optimizersystem) + + + +Optimizer system use. This can be updated with a custom file system. + +
+ + + +
+ +Method + + + +Description + +
+ +[transformModules(opts)](#optimizer-transformmodules) + + + +Transforms the input code string, does not access the file system. + +
+ +

OptimizerOptions

+ +```typescript +export interface OptimizerOptions +``` + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +\_optimizer? + + + + + +unknown + + + +_(Optional)_ + +
+ +binding? + + + + + +any + + + +_(Optional)_ + +
+ +inlineStylesUpToBytes? + + + + + +number + + + +_(Optional)_ Inline the global styles if they're smaller than this + +
+ +sourcemap? + + + + + +boolean + + + +_(Optional)_ Enable sourcemaps + +
+ +sys? + + + + + +[OptimizerSystem](#optimizersystem) + + + +_(Optional)_ + +
+ +tsOptimizer? + + + + + +boolean + + + +_(Optional)_ Use the TypeScript optimizer (`@qwik.dev/ts-optimizer`) instead of the Rust one. + +Default `true` + +
+ +

OptimizerSystem

+ +```typescript +export interface OptimizerSystem +``` + + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +cwd + + + + + +() => string + + + +
+ +dynamicImport + + + + + +(path: string) => Promise<any> + + + +
+ +env + + + + + +[SystemEnvironment](#systemenvironment) + + + +
+ +getInputFiles? + + + + + +(rootDir: string) => Promise<[TransformModuleInput](#transformmoduleinput)[]> + + + +_(Optional)_ + +
+ +os + + + + + +string + + + +
+ +path + + + + + +[Path](#path) + + + +
+ +strictDynamicImport + + + + + +(path: string) => Promise<any> + + + +
+ +

parse

+ +```typescript +parse(path: string): { + root: string; + dir: string; + base: string; + ext: string; + name: string; + }; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +path + + + +string + + + +
+ +**Returns:** + +\{ root: string; dir: string; base: string; ext: string; name: string; } + +

Path

+ +```typescript +export interface Path +``` + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +delimiter + + + +`readonly` + + + +string + + + +
+ +posix + + + +`readonly` + + + +[Path](#path) + + + +
+ +sep + + + +`readonly` + + + +string + + + +
+ +win32 + + + +`readonly` + + + +null + + + +
+ + + + + + + + + + + + +
+ +Method + + + +Description + +
+ +[basename(path, ext)](#path-basename) + + + +
+ +[dirname(path)](#path-dirname) + + + +
+ +[extname(path)](#path-extname) + + + +
+ +[format(pathObject)](#path-format) + + + +
+ +[isAbsolute(path)](#path-isabsolute) + + + +
+ +[join(paths)](#path-join) + + + +
+ +[normalize(path)](#path-normalize) + + + +
+ +[parse(path)](#path-parse) + + + +
+ +[relative(from, to)](#path-relative) + + + +
+ +[resolve(paths)](#path-resolve) + + + +
+ +

QwikAsset

+ +```typescript +export interface QwikAsset +``` + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +name + + + + + +string \| undefined + + + +Name of the asset + +
+ +size + + + + + +number + + + +Size of the asset + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

QwikBuildMode

+ +```typescript +export type QwikBuildMode = "production" | "development"; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts) + +

QwikBuildTarget

+ +```typescript +export type QwikBuildTarget = "client" | "ssr" | "lib" | "test"; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts) + +

QwikBundle

+ +```typescript +export interface QwikBundle +``` + + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +dynamicImports? + + + + + +string[] + + + +_(Optional)_ Dynamic imports + +
+ +imports? + + + + + +string[] + + + +_(Optional)_ Direct imports + +
+ +interactivity? + + + + + +number + + + +_(Optional)_ Interactivity score of the bundle + +
+ +origins? + + + + + +string[] + + + +_(Optional)_ Source files of the bundle + +
+ +size + + + + + +number + + + +Size of the bundle + +
+ +symbols? + + + + + +string[] + + + +_(Optional)_ Symbols in the bundle + +
+ +total + + + + + +number + + + +Total size of this bundle's static import graph + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

QwikBundleGraph

+ +Bundle graph. + +Format: [ 'bundle-a.js', 3, 5 // Depends on 'bundle-b.js' and 'bundle-c.js' 'bundle-b.js', 5, // Depends on 'bundle-c.js' 'bundle-c.js', ] + +```typescript +export type QwikBundleGraph = Array; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

QwikManifest

+ +The metadata of the build. One of its uses is storing where QRL symbols are located. + +```typescript +export interface QwikManifest +``` + + + + + + + + + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +assets? + + + + + +\{ [fileName: string]: [QwikAsset](#qwikasset); } + + + +_(Optional)_ All assets. The key is the fileName relative to the rootDir + +
+ +bundleGraph? + + + + + +[QwikBundleGraph](#qwikbundlegraph) + + + +_(Optional)_ All bundles in a compact graph format with probabilities + +
+ +bundleGraphAsset? + + + + + +string + + + +_(Optional)_ The bundle graph fileName + +
+ +bundles + + + + + +\{ [fileName: string]: [QwikBundle](#qwikbundle); } + + + +All code bundles, used to know the import graph. The key is the bundle fileName relative to "build/" + +
+ +core? + + + + + +string + + + +_(Optional)_ The Qwik core bundle fileName + +
+ +injections? + + + + + +[GlobalInjections](#globalinjections)[] + + + +_(Optional)_ CSS etc to inject in the document head + +
+ +manifestHash + + + + + +string + + + +Content hash of the manifest, if this changes, the code changed + +
+ +mapping + + + + + +\{ [symbolName: string]: string; } + + + +Where QRLs are located. The key is the symbol name, the value is the bundle fileName + +
+ +options? + + + + + +\{ target?: string; buildMode?: string; entryStrategy?: \{ type: [EntryStrategy](#entrystrategy)['type']; }; } + + + +_(Optional)_ The options used to build the manifest + +
+ +platform? + + + + + +\{ [name: string]: string; } + + + +_(Optional)_ The platform used to build the manifest + +
+ +preloader? + + + + + +string + + + +_(Optional)_ The preloader bundle fileName + +
+ +qwikLoader? + + + + + +string + + + +_(Optional)_ The Qwik loader bundle fileName + +
+ +symbols + + + + + +\{ [symbolName: string]: [QwikSymbol](#qwiksymbol); } + + + +QRL symbols + +
+ +version + + + + + +string + + + +The version of the manifest + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

qwikRolldown

+ +```typescript +export declare function qwikRolldown( + qwikRolldownOpts?: QwikRolldownPluginOptions, +): any; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +qwikRolldownOpts + + + +[QwikRolldownPluginOptions](#qwikrolldownpluginoptions) + + + +_(Optional)_ + +
+ +**Returns:** + +any + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) + +

QwikRolldownPluginOptions

+ +```typescript +export interface QwikRolldownPluginOptions +``` + + + + + + + + + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +buildMode? + + + + + +[QwikBuildMode](#qwikbuildmode) + + + +_(Optional)_ Build `production` or `development`. + +Default `development` + +
+ +csr? + + + + + +boolean + + + +_(Optional)_ + +
+ +debug? + + + + + +boolean + + + +_(Optional)_ Prints verbose Qwik plugin debug logs. + +Default `false` + +
+ +entryStrategy? + + + + + +[EntryStrategy](#entrystrategy) + + + +_(Optional)_ The Qwik entry strategy to use while building for production. During development the type is always `segment`. + +Default `{ type: "smart" }`) + +
+ +experimental? + + + + + +(keyof typeof [ExperimentalFeatures](#experimentalfeatures))[] + + + +_(Optional)_ Experimental features. These can come and go in patch releases, and their API is not guaranteed to be stable between releases. + +
+ +lint? + + + + + +boolean + + + +_(Optional)_ Run eslint on the source files for the ssr build or dev server. This can slow down startup on large projects. Defaults to `true` + +
+ +manifestInput? + + + + + +[QwikManifest](#qwikmanifest) + + + +_(Optional)_ The SSR build requires the manifest generated during the client build. The `manifestInput` option can be used to manually provide a manifest. + +Default `undefined` + +
+ +manifestOutput? + + + + + +(manifest: [QwikManifest](#qwikmanifest)) => Promise<void> \| void + + + +_(Optional)_ The client build will create a manifest and this hook is called with the generated build data. + +Default `undefined` + +
+ +optimizerOptions? + + + + + +[OptimizerOptions](#optimizeroptions) + + + +_(Optional)_ + +
+ +rootDir? + + + + + +string + + + +_(Optional)_ The root of the application, which is commonly the same directory as `package.json` and `rollup.config.js`. + +Default `process.cwd()` + +
+ +srcDir? + + + + + +string + + + +_(Optional)_ The source directory to find all the Qwik components. Since Qwik does not have a single input, the `srcDir` is used to recursively find Qwik files. + +Default `src` + +
+ +srcInputs? + + + + + +[TransformModuleInput](#transformmoduleinput)[] \| null + + + +_(Optional)_ Alternative to `srcDir`, where `srcInputs` is able to provide the files manually. This option is useful for an environment without a file system, such as a webworker. + +Default: `null` + +
+ +target? + + + + + +[QwikBuildTarget](#qwikbuildtarget) + + + +_(Optional)_ Target `client` or `ssr`. + +Default `client` + +
+ +transformedModuleOutput? + + + + + +((transformedModules: [TransformModule](#transformmodule)[]) => Promise<void> \| void) \| null + + + +_(Optional)_ Hook that's called after the build and provides all of the transformed modules that were used before bundling. + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) + +

qwikRollup

+ +> Warning: This API is now obsolete. +> +> Use `qwikRolldown`. + +Renamed to `qwikRolldown`. + +```typescript +qwikRollup: typeof qwikRolldown; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) + +

QwikRollupPluginOptions

+ +> Warning: This API is now obsolete. +> +> Use `QwikRolldownPluginOptions`. + +Renamed to `QwikRolldownPluginOptions`. + +```typescript +export type QwikRollupPluginOptions = QwikRolldownPluginOptions; +``` + +**References:** [QwikRolldownPluginOptions](#qwikrolldownpluginoptions) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) + +

QwikSymbol

+ +```typescript +export interface QwikSymbol +``` + + + + + + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +canonicalFilename + + + + + +string + + + +
+ +captureNames? + + + + + +string[] + + + +_(Optional)_ The transformed names of scoped variables, if any + +
+ +captures + + + + + +boolean + + + +Whether the symbol captures a variable + +
+ +ctxKind + + + + + +'function' \| 'eventHandler' + + + +
+ +ctxName + + + + + +string + + + +
+ +displayName + + + + + +string + + + +
+ +hash + + + + + +string + + + +
+ +loc + + + + + +[number, number] + + + +
+ +origin + + + + + +string + + + +
+ +paramNames? + + + + + +string[] + + + +_(Optional)_ The parameter names if it's a function with parameters + +
+ +parent + + + + + +string \| null + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

qwikVite

+ +The types for Vite/Rolldown don't allow us to be too specific about the return type. The correct return type is `[QwikVitePlugin, VitePlugin]`, and if you search the plugin by name you'll get the `QwikVitePlugin`. + +```typescript +export declare function qwikVite(qwikViteOpts?: QwikVitePluginOptions): any; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +qwikViteOpts + + + +[QwikVitePluginOptions](#qwikvitepluginoptions) + + + +_(Optional)_ + +
+ +**Returns:** + +any + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) + +

QwikVitePlugin

+ +This is the type of the "pre" Qwik Vite plugin. `qwikVite` actually returns a tuple of two plugins, but after Vite flattens them, you can find the plugin by name. + +```typescript +export type QwikVitePlugin = P & { + name: "vite-plugin-qwik"; +}; +``` + +**References:** [QwikVitePluginApi](#qwikvitepluginapi) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) + +

QwikVitePluginApi

+ +```typescript +export interface QwikVitePluginApi +``` + + + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +getClientOutDir + + + + + +() => string \| null + + + +
+ +getClientPublicOutDir + + + + + +() => string \| null + + + +
+ +getManifest + + + + + +() => [QwikManifest](#qwikmanifest) \| null + + + +
+ +getOptimizer + + + + + +() => [Optimizer](#optimizer) \| null + + + +
+ +getOptions + + + + + +() => NormalizedQwikPluginOptions + + + +
+ +getRootDir + + + + + +() => string \| null + + + +
+ +onSegment + + + + + +(callback: SegmentCallback) => void + + + +Register a callback that fires for each segment emitted during transform. + +
+ +registerBundleGraphAdder + + + + + +(adder: [BundleGraphAdder](#bundlegraphadder)) => void + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) + +

QwikVitePluginOptions

+ +```typescript +export type QwikVitePluginOptions = + | QwikVitePluginCSROptions + | QwikVitePluginSSROptions; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) + +

relative

+ +```typescript +relative(from: string, to: string): string; +``` + + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +from + + + +string + + + +
+ +to + + + +string + + + +
+ +**Returns:** + +string + +

resolve

+ +```typescript +resolve(...paths: string[]): string; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +paths + + + +string[] + + + +
+ +**Returns:** + +string + +

ResolvedManifest

+ +```typescript +export interface ResolvedManifest +``` + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +bundleGraph? + + + + + +[QwikBundleGraph](#qwikbundlegraph) + + + +_(Optional)_ + +
+ +injections? + + + + + +[GlobalInjections](#globalinjections)[] + + + +_(Optional)_ + +
+ +manifest + + + + + +[ServerQwikManifest](#serverqwikmanifest) + + + +
+ +mapper + + + + + +[SymbolMapper](#symbolmapper) + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

SegmentAnalysis

+ +```typescript +export interface SegmentAnalysis +``` + + + + + + + + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +canonicalFilename + + + + + +string + + + +
+ +captureNames? + + + + + +string[] + + + +_(Optional)_ The transformed names of scoped variables, if any + +
+ +captures + + + + + +boolean + + + +
+ +ctxKind + + + + + +'eventHandler' \| 'function' + + + +
+ +ctxName -[QwikBundleGraph](#qwikbundlegraph) + + +string -_(Optional)_ All bundles in a compact graph format with probabilities +
+ +displayName + + + + + +string + +
-bundleGraphAsset? +entry + + + + + +string \| null + + + +
+ +extension @@ -500,27 +2815,49 @@ string -_(Optional)_ The bundle graph fileName +
+ +hash + + + + + +string + +
-bundles +loc -\{ [fileName: string]: [QwikBundle](#qwikbundle); } +[number, number] -All code bundles, used to know the import graph. The key is the bundle fileName relative to "build/" +
+ +name + + + + + +string + +
-core? +origin @@ -530,194 +2867,366 @@ string -_(Optional)_ The Qwik core bundle fileName +
+ +paramNames? + + + + + +string[] + + + +_(Optional)_ The parameter names if it's a function with parameters
-injections? +parent -[GlobalInjections](#globalinjections)[] +string \| null -_(Optional)_ CSS etc to inject in the document head +
+ +

SegmentEntryStrategy

+ +```typescript +export interface SegmentEntryStrategy +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +manual? + + + + + +Record<string, string> + + + +_(Optional)_
-manifestHash +type -string +'segment' -Content hash of the manifest, if this changes, the code changed +
+ +

ServerQwikManifest

+ +The manifest values that are needed for SSR. + +```typescript +export type ServerQwikManifest = Pick< + QwikManifest, + | "manifestHash" + | "injections" + | "bundleGraph" + | "bundleGraphAsset" + | "mapping" + | "preloader" + | "core" + | "qwikLoader" +>; +``` + +**References:** [QwikManifest](#qwikmanifest) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) + +

show

+ +

SingleEntryStrategy

+ +```typescript +export interface SingleEntryStrategy +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +manual? + + + + + +Record<string, string> + + + +_(Optional)_
-mapping +type -\{ [symbolName: string]: string; } +'single' -Where QRLs are located. The key is the symbol name, the value is the bundle fileName +
+ +

SmartEntryStrategy

+ +```typescript +export interface SmartEntryStrategy +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +manual? + + + + + +Record<string, string> + + + +_(Optional)_
-options? +type + + + + + +'smart' + + + +
+ +

SourceLocation

+ +```typescript +export interface SourceLocation +``` + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +endCol -\{ target?: string; buildMode?: string; entryStrategy?: \{ type: EntryStrategy['type']; }; } +number -_(Optional)_ The options used to build the manifest -
-platform? +endLine -\{ [name: string]: string; } +number -_(Optional)_ The platform used to build the manifest -
-preloader? +hi -string +number -_(Optional)_ The preloader bundle fileName -
-qwikLoader? +lo -string +number -_(Optional)_ The Qwik loader bundle fileName -
-symbols +startCol -\{ [symbolName: string]: [QwikSymbol](#qwiksymbol); } +number -QRL symbols -
-version +startLine -string +number -The version of the manifest -
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) +

suspense

-

qwikRolldown

+

SymbolMapper

```typescript -export declare function qwikRolldown( - qwikRolldownOpts?: QwikRolldownPluginOptions, -): any; +export type SymbolMapper = Record< + string, + readonly [symbol: string, chunk: string] +>; ``` - - -
- -Parameter - - - -Type - - - -Description - -
- -qwikRolldownOpts - - - -[QwikRolldownPluginOptions](#qwikrolldownpluginoptions) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - +

SymbolMapperFn

-_(Optional)_ +```typescript +export type SymbolMapperFn = ( + symbolName: string, + mapper: SymbolMapper | undefined, + parent?: string, +) => readonly [symbol: string, chunk: string] | undefined; +``` -
+**References:** [SymbolMapper](#symbolmapper) -**Returns:** +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) -any +

SystemEnvironment

-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) +```typescript +export type SystemEnvironment = + | "node" + | "deno" + | "bun" + | "webworker" + | "browsermain" + | "unknown"; +``` -

QwikRolldownPluginOptions

+

TransformModule

```typescript -export interface QwikRolldownPluginOptions +export interface TransformModule ``` -
@@ -739,30 +3248,26 @@ Description
-buildMode? +code -[QwikBuildMode](#qwikbuildmode) +string -_(Optional)_ Build `production` or `development`. - -Default `development` -
-csr? +imports? -boolean +string[] @@ -771,7 +3276,7 @@ _(Optional)_
-debug? +isEntry @@ -781,110 +3286,87 @@ boolean -_(Optional)_ Prints verbose Qwik plugin debug logs. - -Default `false` -
-entryStrategy? +map -EntryStrategy +string \| null -_(Optional)_ The Qwik entry strategy to use while building for production. During development the type is always `segment`. - -Default `{ type: "smart" }`) -
-experimental? +origPath -(keyof typeof [ExperimentalFeatures](#experimentalfeatures))[] +string \| null -_(Optional)_ Experimental features. These can come and go in patch releases, and their API is not guaranteed to be stable between releases. -
-lint? +path -boolean +string -_(Optional)_ Run eslint on the source files for the ssr build or dev server. This can slow down startup on large projects. Defaults to `true` -
-manifestInput? +segment -[QwikManifest](#qwikmanifest) +[SegmentAnalysis](#segmentanalysis) \| null -_(Optional)_ The SSR build requires the manifest generated during the client build. The `manifestInput` option can be used to manually provide a manifest. - -Default `undefined` -
- -manifestOutput? - - - - - -(manifest: [QwikManifest](#qwikmanifest)) => Promise<void> \| void +
- +

TransformModuleInput

-_(Optional)_ The client build will create a manifest and this hook is called with the generated build data. +```typescript +export interface TransformModuleInput +``` -Default `undefined` + - - + -
-
+Property -optimizerOptions? + - +Modifiers - + -OptimizerOptions +Type - + -_(Optional)_ +Description -
+
-rootDir? +code @@ -894,14 +3376,10 @@ string -_(Optional)_ The root of the application, which is commonly the same directory as `package.json` and `rollup.config.js`. - -Default `process.cwd()` -
-srcDir? +devPath? @@ -911,99 +3389,69 @@ string -_(Optional)_ The source directory to find all the Qwik components. Since Qwik does not have a single input, the `srcDir` is used to recursively find Qwik files. - -Default `src` +_(Optional)_
-srcInputs? +path -TransformModuleInput[] \| null +string -_(Optional)_ Alternative to `srcDir`, where `srcInputs` is able to provide the files manually. This option is useful for an environment without a file system, such as a webworker. - -Default: `null` -
+
-target? +

transformModules

- +Transforms the input code string, does not access the file system. - +```typescript +transformModules(opts: TransformModulesOptions): Promise; +``` -[QwikBuildTarget](#qwikbuildtarget) + - +
- +Parameter -_(Optional)_ Target `client` or `ssr`. + -Default `client` +Type -
+ -transformedModuleOutput? +Description - +
+ +opts -((transformedModules: TransformModule[]) => Promise<void> \| void) \| null +[TransformModulesOptions](#transformmodulesoptions) -_(Optional)_ Hook that's called after the build and provides all of the transformed modules that were used before bundling. -
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) - -

qwikRollup

- -> Warning: This API is now obsolete. -> -> Use `qwikRolldown`. - -Renamed to `qwikRolldown`. - -```typescript -qwikRollup: typeof qwikRolldown; -``` - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) - -

QwikRollupPluginOptions

+**Returns:** -> Warning: This API is now obsolete. -> -> Use `QwikRolldownPluginOptions`. +Promise<[TransformOutput](#transformoutput)> -Renamed to `QwikRolldownPluginOptions`. +

TransformModulesOptions

```typescript -export type QwikRollupPluginOptions = QwikRolldownPluginOptions; +export interface TransformModulesOptions extends TransformOptions ``` -**References:** [QwikRolldownPluginOptions](#qwikrolldownpluginoptions) - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/rolldown.ts) - -

QwikSymbol

- -```typescript -export interface QwikSymbol -``` +**Extends:** [TransformOptions](#transformoptions) - - -
@@ -1024,360 +3472,286 @@ Description
-canonicalFilename - - - - - -string - - - -
- -captureNames? - - - - - -string[] - - - -_(Optional)_ The transformed names of scoped variables, if any - -
- -captures +input -boolean +[TransformModuleInput](#transformmoduleinput)[] -Whether the symbol captures a variable -
- -ctxKind +
- +

TransformOptions

- +```typescript +export interface TransformOptions +``` -'function' \| 'eventHandler' + - - + -
- +Property -
+ -ctxName +Modifiers - + - +Type -string + - +Description -
+
-displayName +entryStrategy? -string +[EntryStrategy](#entrystrategy) +_(Optional)_ +
-hash +explicitExtensions? -string +boolean +_(Optional)_ +
-loc +isServer? -[number, number] +boolean +_(Optional)_ +
-origin +minify? -string +[MinifyMode](#minifymode) +_(Optional)_ +
-paramNames? +mode? -string[] +[EmitMode](#emitmode) -_(Optional)_ The parameter names if it's a function with parameters +_(Optional)_
-parent +preserveFilenames? -string \| null +boolean -
- -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

qwikVite

- -The types for Vite/Rolldown don't allow us to be too specific about the return type. The correct return type is `[QwikVitePlugin, VitePlugin]`, and if you search the plugin by name you'll get the `QwikVitePlugin`. - -```typescript -export declare function qwikVite(qwikViteOpts?: QwikVitePluginOptions): any; -``` - - + - -
- -Parameter - - - -Type - - +_(Optional)_ -Description +
-
+regCtxName? -qwikViteOpts + -[QwikVitePluginOptions](#qwikvitepluginoptions) +string[] _(Optional)_
- -**Returns:** - -any - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) - -

QwikVitePlugin

- -This is the type of the "pre" Qwik Vite plugin. `qwikVite` actually returns a tuple of two plugins, but after Vite flattens them, you can find the plugin by name. - -```typescript -export type QwikVitePlugin = P & { - name: "vite-plugin-qwik"; -}; -``` - -**References:** [QwikVitePluginApi](#qwikvitepluginapi) - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) - -

QwikVitePluginApi

- -```typescript -export interface QwikVitePluginApi -``` - - - +
- -Property +
- +rootDir? -Modifiers + - + -Type +string - + -Description +_(Optional)_ -
+
-getClientOutDir +scope? -() => string \| null +string +_(Optional)_ +
-getClientPublicOutDir +sourceMaps? -() => string \| null +boolean +_(Optional)_ +
-getManifest +srcDir -() => [QwikManifest](#qwikmanifest) \| null +string
-getOptimizer +stripCtxName? -() => Optimizer \| null +string[] +_(Optional)_ +
-getOptions +stripEventHandlers? -() => NormalizedQwikPluginOptions +boolean +_(Optional)_ +
-getRootDir +stripExports? -() => string \| null +string[] +_(Optional)_ +
-onSegment +transpileJsx? -(callback: SegmentCallback) => void +boolean -Register a callback that fires for each segment emitted during transform. +_(Optional)_
-registerBundleGraphAdder +transpileTs? -(adder: [BundleGraphAdder](#bundlegraphadder)) => void +boolean +_(Optional)_ +
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) - -

QwikVitePluginOptions

- -```typescript -export type QwikVitePluginOptions = - | QwikVitePluginCSROptions - | QwikVitePluginSSROptions; -``` - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/vite.ts) - -

ResolvedManifest

+

TransformOutput

```typescript -export interface ResolvedManifest +export interface TransformOutput ```
@@ -1399,113 +3773,56 @@ Description
-bundleGraph? +diagnostics -[QwikBundleGraph](#qwikbundlegraph) +[Diagnostic](#diagnostic)[] -_(Optional)_ -
-injections? +isJsx -[GlobalInjections](#globalinjections)[] +boolean -_(Optional)_ -
-manifest +isTypeScript -[ServerQwikManifest](#serverqwikmanifest) +boolean
-mapper +modules -[SymbolMapper](#symbolmapper) +[TransformModule](#transformmodule)[]
-[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

ServerQwikManifest

- -The manifest values that are needed for SSR. - -```typescript -export type ServerQwikManifest = Pick< - QwikManifest, - | "manifestHash" - | "injections" - | "bundleGraph" - | "bundleGraphAsset" - | "mapping" - | "preloader" - | "core" - | "qwikLoader" ->; -``` - -**References:** [QwikManifest](#qwikmanifest) - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

show

- -

suspense

- -

SymbolMapper

- -```typescript -export type SymbolMapper = Record< - string, - readonly [symbol: string, chunk: string] ->; -``` - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) - -

SymbolMapperFn

- -```typescript -export type SymbolMapperFn = ( - symbolName: string, - mapper: SymbolMapper | undefined, - parent?: string, -) => readonly [symbol: string, chunk: string] | undefined; -``` - -**References:** [SymbolMapper](#symbolmapper) - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) -

valibot

diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index 6a045091963..515e1c6b432 100644 --- a/packages/docs/src/routes/api/qwik-router/api.json +++ b/packages/docs/src/routes/api/qwik-router/api.json @@ -282,6 +282,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.documentstyle.md" }, + { + "name": "Editable", + "id": "editable", + "hierarchy": [ + { + "name": "Editable", + "id": "editable" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type Editable = {\n -readonly [P in keyof T]: T[P];\n};\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", + "mdFile": "router.editable.md" + }, { "name": "ErrorBoundary", "id": "errorboundary", @@ -740,7 +754,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface QwikRouterConfig \n```\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nbasePathname?\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\ncacheModules?\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\nfallthrough?\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ When true, return null instead of rendering the 404 page, letting the adapter handle it\n\n\n
\n\nroutes\n\n\n\n\n`readonly`\n\n\n\n\n[RouteData](#routedata)\n\n\n\n\n\n
\n\nserverPlugins?\n\n\n\n\n`readonly`\n\n\n\n\nRouteModule\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\ntrailingSlash?\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
", + "content": "```typescript\nexport interface QwikRouterConfig \n```\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nbasePathname?\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\ncacheModules?\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\nfallthrough?\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ When true, return null instead of rendering the 404 page, letting the adapter handle it\n\n\n
\n\nimportEagerModules?\n\n\n\n\n`readonly`\n\n\n\n\n() => Promise<unknown>\n\n\n\n\n_(Optional)_ Imports the modules containing `server$` functions so their registration side effects run. Called by the request handler before serving; deliberately async so the config module itself evaluates without touching the runtime (see the import-cycle notes in `route-loaders.ts`).\n\n\n
\n\nroutes\n\n\n\n\n`readonly`\n\n\n\n\n[RouteData](#routedata)\n\n\n\n\n\n
\n\nserverPlugins?\n\n\n\n\n`readonly`\n\n\n\n\nRouteModule\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\ntrailingSlash?\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.qwikrouterconfig.md" }, @@ -907,8 +921,8 @@ "id": "routeactionqrl" } ], - "kind": "Variable", - "content": "```typescript\nrouteActionQrl: ActionConstructorQRL\n```", + "kind": "Function", + "content": "Hoisted function declarations (not consts) on purpose, like `routeLoaderQrl`: generated route modules call these factories back during their own evaluation via the `@qwik-router-config` cycle, where a `const` binding would throw a TDZ ReferenceError.\n\n\n```typescript\nexport declare function routeActionQrl(actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, ...rest: (ActionOptions | DataValidator)[]): {\n (): Editable>;\n __brand: \"server_action\";\n __validators: DataValidator[];\n __qrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>;\n __id: string;\n __invalidate: string[] | undefined;\n};\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nactionQrl\n\n\n\n\nQRL<(form: [JSONObject](#jsonobject), event: RequestEventAction) => unknown>\n\n\n\n\n\n
\n\nrest\n\n\n\n\n([ActionOptions](#actionoptions) \\| [DataValidator](#datavalidator))\\[\\]\n\n\n\n\n\n
\n\n**Returns:**\n\n{ (): [Editable](#editable)<[ActionStore](#actionstore)<unknown, unknown>>; \\_\\_brand: \"server\\_action\"; \\_\\_validators: [DataValidator](#datavalidator)\\[\\]; \\_\\_qrl: QRL<(form: [JSONObject](#jsonobject), event: RequestEventAction) => unknown>; \\_\\_id: string; \\_\\_invalidate: string\\[\\] \\| undefined; }", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/server-functions.ts", "mdFile": "router.routeactionqrl.md" }, diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index 9ef4be821c5..59b5f9762dc 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -784,6 +784,16 @@ export type DocumentStyle = Readonly< [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) +

Editable

+ +```typescript +export type Editable = { + -readonly [P in keyof T]: T[P]; +}; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) +

ErrorBoundary

```typescript @@ -1616,6 +1626,23 @@ _(Optional)_ When true, return null instead of rendering the 404 page, letting t +importEagerModules? + + + +`readonly` + + + +() => Promise<unknown> + + + +_(Optional)_ Imports the modules containing `server$` functions so their registration side effects run. Called by the request handler before serving; deliberately async so the config module itself evaluates without touching the runtime (see the import-cycle notes in `route-loaders.ts`). + + + + routes @@ -2148,10 +2175,63 @@ routeAction$: ActionConstructor;

routeActionQrl

+Hoisted function declarations (not consts) on purpose, like `routeLoaderQrl`: generated route modules call these factories back during their own evaluation via the `@qwik-router-config` cycle, where a `const` binding would throw a TDZ ReferenceError. + ```typescript -routeActionQrl: ActionConstructorQRL; +export declare function routeActionQrl( + actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, + ...rest: (ActionOptions | DataValidator)[] +): { + (): Editable>; + __brand: "server_action"; + __validators: DataValidator[]; + __qrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>; + __id: string; + __invalidate: string[] | undefined; +}; ``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +actionQrl + + + +QRL<(form: [JSONObject](#jsonobject), event: RequestEventAction) => unknown> + + + +
+ +rest + + + +([ActionOptions](#actionoptions) \| [DataValidator](#datavalidator))[] + + + +
+ +**Returns:** + +\{ (): [Editable](#editable)<[ActionStore](#actionstore)<unknown, unknown>>; \_\_brand: "server_action"; \_\_validators: [DataValidator](#datavalidator)[]; \_\_qrl: QRL<(form: [JSONObject](#jsonobject), event: RequestEventAction) => unknown>; \_\_id: string; \_\_invalidate: string[] \| undefined; } + [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/server-functions.ts)

RouteConfig

diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts index cb6cdbbd924..baad4e4a0f7 100644 --- a/packages/docs/vite.config.ts +++ b/packages/docs/vite.config.ts @@ -121,6 +121,8 @@ export default defineConfig(({ mode }) => { 'qwik-image', // optimizing breaks the wasm import '@rolldown/browser', + '@rolldown/browser/experimental', + 'oxc-walker', '@qwik.dev/devtools', ], }, @@ -178,6 +180,11 @@ export default defineConfig(({ mode }) => { find: '@docsearch/css', replacement: path.resolve(__dirname, 'node_modules/@docsearch/css/dist/style.css'), }, + { + // The REPL worker bundles oxc-walker, which statically imports node:module. + find: 'node:module', + replacement: path.resolve(__dirname, 'src', 'repl', 'bundler', 'node-module-shim.ts'), + }, ], }, diff --git a/packages/insights/package.json b/packages/insights/package.json index 861b918b05e..e2f9b66854e 100644 --- a/packages/insights/package.json +++ b/packages/insights/package.json @@ -21,7 +21,7 @@ "@qwik.dev/router": "workspace:*", "@tailwindcss/vite": "4.2.4", "@types/density-clustering": "1.3.3", - "@types/node": "24.10.0", + "@types/node": "25.6.0", "autoprefixer": "10.4.27", "eslint": "10.0.3", "eslint-plugin-qwik": "workspace:*", diff --git a/packages/optimizer/core/src/snapshots/qwik_core__test__example_3.snap b/packages/optimizer/core/src/snapshots/qwik_core__test__example_3.snap index 35b45fad2c0..48c5b2408c5 100644 --- a/packages/optimizer/core/src/snapshots/qwik_core__test__example_3.snap +++ b/packages/optimizer/core/src/snapshots/qwik_core__test__example_3.snap @@ -1,6 +1,5 @@ --- source: packages/optimizer/core/src/test.rs -assertion_line: 130 expression: output --- ==INPUT== @@ -15,7 +14,7 @@ export const App = () => { ); }); return Header; -}); +}; ============================= test.tsx_App_Header_component_B9F3YeqcO1w.tsx (ENTRY POINT)== @@ -92,7 +91,7 @@ export const App = ()=>{ }; -Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;AAEA,OAAO,MAAM,MAAM;IAClB,MAAM,uBAAS;IAMf,OAAO;AACR,EAAG\"}") +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;AAEA,OAAO,MAAM,MAAM;IAClB,MAAM,uBAAS;IAMf,OAAO;AACR,EAAE\"}") == DIAGNOSTICS == [] diff --git a/packages/optimizer/core/src/snapshots/qwik_core__test__example_immutable_analysis.snap b/packages/optimizer/core/src/snapshots/qwik_core__test__example_immutable_analysis.snap index 4a88e6df901..5a9bb96f341 100644 --- a/packages/optimizer/core/src/snapshots/qwik_core__test__example_immutable_analysis.snap +++ b/packages/optimizer/core/src/snapshots/qwik_core__test__example_immutable_analysis.snap @@ -1,6 +1,5 @@ --- source: packages/optimizer/core/src/test.rs -assertion_line: 2138 expression: output --- ==INPUT== @@ -40,7 +39,7 @@ export const App = component$((props) => { >

Hello Qwik

- [].map(() => ( + {[].map(() => ( { mutable2={(() => console.log(state.count))()} mutable3={[1, 2, state, null, {}]} /> - )); + ))} ); }); @@ -216,29 +215,27 @@ export const App_component_ckEPmXZlub0 = (props)=>{ {} ] }, /*#__PURE__*/ _jsxSorted("p", null, null, "Hello Qwik", 3, null), 2, "u6_0"), - "[].map(() => (", - /*#__PURE__*/ _jsxSorted(props.Model, { - mutable2: (()=>console.log(state.count))() - }, { - class: state, - remove$: remove, - mutable1: _fnSignal(_hf0, [ - state - ], _hf0_str), - mutable3: [ - 1, - 2, - state, - null, - {} - ] - }, null, 3, "u6_1"), - "));" + [].map(()=>/*#__PURE__*/ _jsxSorted(props.Model, { + mutable2: (()=>console.log(state.count))() + }, { + class: state, + remove$: remove, + mutable1: _fnSignal(_hf0, [ + state + ], _hf0_str), + mutable3: [ + 1, + 2, + state, + null, + {} + ] + }, null, 3, "u6_1")) ], 1, "u6_2"); }; -Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;;;;mBAuCe,CAAA;QACT,KAAK;QACL,KAAK,GAAM,KAAK,GAAG,OAAO;IAC3B,CAAA;;;;;;;;yCArCyB,CAAC;IAE9B,MAAM,QAAQ,SAAS;QAAC,OAAO;IAAC;IAChC,MAAM;;;IAON,qBACC;sBACC,WAAC;YAAgB,aAAU,MAAM,QAAQ;;YAAtC,OAAM;WAAkC;sBAC3C,WAAC;YAEA,UAAU,OAAO,QAAQ;YACzB,UAAU,MAAM,QAAQ;;YAFxB,OAAO,OAAO,GAAG;YAGjB,QAAQ;YACR,YAAY;YACZ,YAAW;YACX,YAAY;gBACX,KAAK;gBACL,KAAK,gBAAgB,OAAO;YAC7B;YACA,YAAY;YACZ,WAAW;;;YACX,YAAY;gBAAC;gBAAG;gBAAG;gBAAe;gBAAM,CAAC;aAAE;yBAE3C,WAAC,iBAAE;QACE;sBAEL,WA9Ba,MAAT;YAqCH,UAAU,CAAC,IAAM,QAAQ,GAAG,CAAC,MAAM,KAAK,CAAC;;YANzC,OAAO;YACP,SAAS;YACT,QAAQ;;;YAKR,UAAU;gBAAC;gBAAG;gBAAG;gBAAO;gBAAM,CAAC;aAAE;;QAChC;;AAIN\"}") +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;;;;mBAuCe,CAAA;QACT,KAAK;QACL,KAAK,GAAM,KAAK,GAAG,OAAO;IAC3B,CAAA;;;;;;;;yCArCyB,CAAC;IAE9B,MAAM,QAAQ,SAAS;QAAC,OAAO;IAAC;IAChC,MAAM;;;IAON,qBACC;sBACC,WAAC;YAAgB,aAAU,MAAM,QAAQ;;YAAtC,OAAM;WAAkC;sBAC3C,WAAC;YAEA,UAAU,OAAO,QAAQ;YACzB,UAAU,MAAM,QAAQ;;YAFxB,OAAO,OAAO,GAAG;YAGjB,QAAQ;YACR,YAAY;YACZ,YAAW;YACX,YAAY;gBACX,KAAK;gBACL,KAAK,gBAAgB,OAAO;YAC7B;YACA,YAAY;YACZ,WAAW;;;YACX,YAAY;gBAAC;gBAAG;gBAAG;gBAAe;gBAAM,CAAC;aAAE;yBAE3C,WAAC,iBAAE;QAEH,EAAE,CAAC,GAAG,CAAC,kBACP,WA9Ba,MAAT;gBAqCH,UAAU,CAAC,IAAM,QAAQ,GAAG,CAAC,MAAM,KAAK,CAAC;;gBANzC,OAAO;gBACP,SAAS;gBACT,QAAQ;;;gBAKR,UAAU;oBAAC;oBAAG;oBAAG;oBAAO;oBAAM,CAAC;iBAAE;;;AAKtC\"}") /* { "origin": "test.tsx", @@ -255,7 +252,7 @@ Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"ma "captures": false, "loc": [ 164, - 1148 + 1149 ], "paramNames": [ "props" diff --git a/packages/optimizer/core/src/snapshots/qwik_core__test__example_jsx_listeners.snap b/packages/optimizer/core/src/snapshots/qwik_core__test__example_jsx_listeners.snap index 084ee9ad163..28b251b0094 100644 --- a/packages/optimizer/core/src/snapshots/qwik_core__test__example_jsx_listeners.snap +++ b/packages/optimizer/core/src/snapshots/qwik_core__test__example_jsx_listeners.snap @@ -1,6 +1,5 @@ --- source: packages/optimizer/core/src/test.rs -assertion_line: 1195 expression: output --- ==INPUT== @@ -156,11 +155,15 @@ export const Foo_component_1_DvU6FitWglY = ()=>{ const handler = q_Foo_component_handler_H10xZtD0e7w; return /*#__PURE__*/ _jsxSorted("div", null, { "q-e:click": q_Foo_component_div_q_e_click_YEa2A5ADUOg, - "q-e:documentscroll": q_Foo_component_div_q_e_documentscroll_0FSbGzUROso, - "q-e:documentscroll": q_Foo_component_div_q_e_documentscroll_1_d0Zn04qNgs0, + "q-e:documentscroll": [ + q_Foo_component_div_q_e_documentscroll_0FSbGzUROso, + q_Foo_component_div_q_e_documentscroll_1_d0Zn04qNgs0 + ], "q-e:c-lick": q_Foo_component_div_q_e_c_lick_kX5SiYdz650, - "q-e:document--scroll": q_Foo_component_div_q_e_document_scroll_6qyBttefepU, - "q-e:document--scroll": q_Foo_component_div_q_e_document_scroll_1_wphyTkeintI, + "q-e:document--scroll": [ + q_Foo_component_div_q_e_document_scroll_6qyBttefepU, + q_Foo_component_div_q_e_document_scroll_1_wphyTkeintI + ], "host:onClick$": q_Foo_component_div_host_onClick_cPEH970JbEY, "host:onDocumentScroll$": q_Foo_component_div_host_onDocumentScroll_Zip7mifsjRY, "host:onDocumentScroll$": q_Foo_component_div_host_onDocumentScroll_1_Em1LspK7JVg, @@ -172,7 +175,7 @@ export const Foo_component_1_DvU6FitWglY = ()=>{ }; -Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;;;;;;;;;;;2CAKU;IACR,MAAM;IACN,qBACC,WAAC;QACA,WAAQ;QACR,oBAAiB;QACjB,oBAAiB;QAEjB,YAAS;QACT,sBAAkB;QAClB,sBAAkB;QAPnB,eAkBE;QAlBF,wBAkBE;QAlBF,wBAkBE;QALD,aAAU;QAbX,sBAcoB;QAdpB,oBAekB;QAEjB,OAAO;;AAGV\"}") +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;;;;;;;;;;;2CAKU;IACR,MAAM;IACN,qBACC,WAAC;QACA,WAAQ;;;;;QAIR,YAAS;;;;;QALV,eAkBE;QAlBF,wBAkBE;QAlBF,wBAkBE;QALD,aAAU;QAbX,sBAcoB;QAdpB,oBAekB;QAEjB,OAAO;;AAGV\"}") /* { "origin": "test.tsx", diff --git a/packages/optimizer/core/src/snapshots/qwik_core__test__relative_paths.snap b/packages/optimizer/core/src/snapshots/qwik_core__test__relative_paths.snap index 3e214ea0a46..ec1041b9dc8 100644 --- a/packages/optimizer/core/src/snapshots/qwik_core__test__relative_paths.snap +++ b/packages/optimizer/core/src/snapshots/qwik_core__test__relative_paths.snap @@ -1,8 +1,58 @@ --- source: packages/optimizer/core/src/test.rs -assertion_line: 3415 expression: output --- +==INPUT ../../node_modules/dep/dist/lib.mjs== + + +import { componentQrl, inlinedQrl, useStore, useLexicalScope } from "@qwik.dev/core"; +import { jsx, jsxs } from "@qwik.dev/core/jsx-runtime"; +import { state } from './sibling'; + +const useData = () => { + return useStore({ + count: 0 + }); +} + +export const App = /*#__PURE__*/ componentQrl(inlinedQrl(()=>{ + const store = useData(); + return /*#__PURE__*/ jsxs("div", { + children: [ + /*#__PURE__*/ jsxs("p", { + children: [ + "Count: ", + store.count + ] + }), + /*#__PURE__*/ jsx("p", { + children: /*#__PURE__*/ jsx("button", { + onClick$: inlinedQrl(()=>{ + const [store] = useLexicalScope(); + return store.count++; + }, "App_component_div_p_button_onClick_8dWUa0cJAr4", [ + store + ]), + children: "Click" + }) + }) + ] + }); +}, "App_component_AkbU84a8zes")); + + +==INPUT components/main.tsx== + + +import { component$, $ } from '@qwik.dev/core'; +import { state } from './sibling'; + +export const Local = component$(() => { + return ( +
{state}
+ ) +}); + ============================= ../../node_modules/dep/dist/lib.mjs_App_component_div_p_button_onClick_8dWUa0cJAr4.js (ENTRY POINT)== import { useLexicalScope } from "@qwik.dev/core"; diff --git a/packages/optimizer/core/src/test.rs b/packages/optimizer/core/src/test.rs index 0dca438e3d2..85b94387097 100644 --- a/packages/optimizer/core/src/test.rs +++ b/packages/optimizer/core/src/test.rs @@ -138,7 +138,7 @@ export const App = () => { ); }); return Header; -}); +}; "# .to_string(), ..TestInput::default() @@ -2251,7 +2251,7 @@ export const App = component$((props) => { >

Hello Qwik

- [].map(() => ( + {[].map(() => ( { mutable2={(() => console.log(state.count))()} mutable3={[1, 2, state, null, {}]} /> - )); + ))} ); }); @@ -3460,6 +3460,9 @@ export const Local = component$(() => { ) }); "#; + let snapshot_inputs = format!( + "==INPUT ../../node_modules/dep/dist/lib.mjs==\n\n{dep}\n==INPUT components/main.tsx==\n\n{code}" + ); let res = transform_modules(TransformModulesOptions { src_dir: "/path/to/app/src/thing".into(), root_dir: Some("/path/to/app/".into()), @@ -3492,7 +3495,7 @@ export const Local = component$(() => { reg_ctx_name: None, is_server: None, }); - snapshot_res!(&res, "".into()); + snapshot_res!(&res, snapshot_inputs); } #[test] fn consistent_hashes() { diff --git a/packages/optimizer/core/src/transform.rs b/packages/optimizer/core/src/transform.rs index 70fcf637402..c887f7e6e02 100644 --- a/packages/optimizer/core/src/transform.rs +++ b/packages/optimizer/core/src/transform.rs @@ -1735,15 +1735,12 @@ impl<'a> QwikTransform<'a> { } } - /// Helper function to merge an event handler with an existing one in the props list. - /// If a handler with the same key already exists, they are merged into an array. - /// Otherwise, the new handler is simply added. - fn merge_or_add_event_handler( + fn merge_event_handler( &mut self, props: &mut Vec, key: Atom, - new_handler: Box, - ) { + new_handler: &ast::Expr, + ) -> bool { // Check if there's already a handler with this key let existing_handler_index = props.iter().position(|prop| { if let ast::PropOrSpread::Prop(box ast::Prop::KeyValue(kv)) = prop { @@ -1763,7 +1760,7 @@ impl<'a> QwikTransform<'a> { // Existing handler is already an array, append to it existing_array.elems.push(Some(ast::ExprOrSpread { spread: None, - expr: new_handler.fold_with(self), + expr: Box::new(new_handler.clone().fold_with(self)), })); ast::Expr::Array(existing_array) } else { @@ -1777,7 +1774,7 @@ impl<'a> QwikTransform<'a> { }), Some(ast::ExprOrSpread { spread: None, - expr: new_handler.fold_with(self), + expr: Box::new(new_handler.clone().fold_with(self)), }), ], }) @@ -1794,7 +1791,19 @@ impl<'a> QwikTransform<'a> { }))); props.push(merged_prop); } + true } else { + false + } + } + + fn merge_or_add_event_handler( + &mut self, + props: &mut Vec, + key: Atom, + new_handler: Box, + ) { + if !self.merge_event_handler(props, key.clone(), &new_handler) { // Add the new handler let handler_prop = ast::PropOrSpread::Prop(Box::new(ast::Prop::KeyValue(ast::KeyValueProp { @@ -3413,7 +3422,7 @@ impl<'a> QwikTransform<'a> { } /// Helper to add a prop to the appropriate props list based on const-ness and spread props - /// Handles the special case of merging q-e:input handlers + /// Merges repeated event handlers instead of emitting duplicate object keys. fn add_prop_to_appropriate_list( &mut self, expr: Box, @@ -3426,37 +3435,44 @@ impl<'a> QwikTransform<'a> { let is_const = context.is_const; let is_fn = context.is_fn; let spread_props_count = context.spread_props_count; - // Check if this is an q-e:input handler that needs to be merged - if transformed_event_key.as_ref() == Some(&*ON_INPUT) { + if let Some(event_key) = transformed_event_key + .as_ref() + .filter(|key| key.as_ref().starts_with("q-")) + { let target_props = if is_fn || spread_props_count > 0 { if is_const && spread_props_count == 0 { - const_props + &mut *const_props } else { - var_props + &mut *var_props } } else if !is_const || spread_props_count > 0 { - var_props + &mut *var_props } else { - const_props + &mut *const_props }; - self.merge_or_add_event_handler(target_props, ON_INPUT.clone(), expr); - } else { - let converted_prop = - ast::PropOrSpread::Prop(Box::new(ast::Prop::KeyValue(ast::KeyValueProp { - value: expr, - key: final_key, - }))); - if is_fn || spread_props_count > 0 { - if is_const && spread_props_count == 0 { - const_props.push(converted_prop.fold_with(self)); - } else { - var_props.push(converted_prop.fold_with(self)); - } - } else if !is_const || spread_props_count > 0 { - var_props.push(converted_prop.fold_with(self)); - } else { + if event_key == &*ON_INPUT { + self.merge_or_add_event_handler(target_props, event_key.clone(), expr); + return; + } + if self.merge_event_handler(target_props, event_key.clone(), &expr) { + return; + } + } + let converted_prop = + ast::PropOrSpread::Prop(Box::new(ast::Prop::KeyValue(ast::KeyValueProp { + value: expr, + key: final_key, + }))); + if is_fn || spread_props_count > 0 { + if is_const && spread_props_count == 0 { const_props.push(converted_prop.fold_with(self)); + } else { + var_props.push(converted_prop.fold_with(self)); } + } else if !is_const || spread_props_count > 0 { + var_props.push(converted_prop.fold_with(self)); + } else { + const_props.push(converted_prop.fold_with(self)); } } } diff --git a/packages/optimizer/src/types.ts b/packages/optimizer/src/types.ts index 39052fcc5bb..b0dceefeab7 100644 --- a/packages/optimizer/src/types.ts +++ b/packages/optimizer/src/types.ts @@ -15,6 +15,12 @@ export interface OptimizerOptions { inlineStylesUpToBytes?: number; /** Enable sourcemaps */ sourcemap?: boolean; + /** + * Use the TypeScript optimizer (`@qwik.dev/ts-optimizer`) instead of the Rust one. + * + * Default `true` + */ + tsOptimizer?: boolean; _optimizer?: unknown; } diff --git a/packages/qwik-router/global.d.ts b/packages/qwik-router/global.d.ts index a2cd96baee5..8453b85e064 100644 --- a/packages/qwik-router/global.d.ts +++ b/packages/qwik-router/global.d.ts @@ -22,6 +22,9 @@ declare var __DEFAULT_LOADERS_SERIALIZATION_STRATEGY__: SerializationStrategy; /** Should routes not have a trailing slash? */ declare var __NO_TRAILING_SLASH__: boolean; +/** The app's base pathname, define-replaced at app build time. */ +declare var __QWIK_ROUTER_BASE_PATHNAME__: string | undefined; + /** Maximum number of SSR-rendered pages to keep in the in-memory cache. */ declare var __SSR_CACHE_SIZE__: number; diff --git a/packages/qwik-router/package.json b/packages/qwik-router/package.json index c2c1049c8f7..ce1e05d7ad5 100644 --- a/packages/qwik-router/package.json +++ b/packages/qwik-router/package.json @@ -35,7 +35,7 @@ "@microsoft/api-extractor": "7.57.6", "@qwik.dev/core": "workspace:*", "@types/mdast": "4.0.4", - "@types/node": "24.10.0", + "@types/node": "25.6.0", "@types/refractor": "4.0.0", "@types/set-cookie-parser": "2.4.10", "tsm": "2.3.0", diff --git a/packages/qwik-router/src/buildtime/context.ts b/packages/qwik-router/src/buildtime/context.ts index a61a16d85f3..855c3ad5ea2 100644 --- a/packages/qwik-router/src/buildtime/context.ts +++ b/packages/qwik-router/src/buildtime/context.ts @@ -42,6 +42,27 @@ export function resetBuildContext(ctx: RoutingContext | null) { } } +/** + * Resolve the app's base pathname from plugin options and vite's `base`. Shared by option + * normalization and the `config()`-hook `define` (which runs before the build context exists). + */ +export function resolveBasePathname( + userOpts: PluginOptions | undefined, + viteBasePath: string +): string { + let basePathname = + typeof (userOpts as any)?.baseUrl === 'string' + ? // baseUrl deprecated + ((userOpts as any).baseUrl as string) + : typeof userOpts?.basePathname === 'string' + ? userOpts.basePathname + : viteBasePath || '/'; + if (!basePathname.endsWith('/')) { + basePathname = ensureSlash(basePathname); + } + return new URL(basePathname, 'https://qwik.dev/').pathname; +} + function normalizeOptions( rootDir: string, viteBasePath: string, @@ -72,27 +93,17 @@ function normalizeOptions( } opts.serverPluginsDir = normalizePath(opts.serverPluginsDir); - if (typeof (opts as any).baseUrl === 'string') { - // baseUrl deprecated - opts.basePathname = (opts as any).baseUrl; - } - - if (typeof opts.basePathname !== 'string') { - // opts.basePathname is used internally - // but in most cases should be passed in by the vite config "base" property - opts.basePathname = viteBasePath; - } - if (!opts.basePathname.endsWith('/')) { + if ( + typeof (opts as any).baseUrl !== 'string' && + typeof opts.basePathname === 'string' && + !opts.basePathname.endsWith('/') + ) { // TODO: in v2 make this an error console.error( `Warning: qwik-router plugin basePathname must end with /. This will be an error in v2` ); - opts.basePathname = ensureSlash(opts.basePathname); } - - // cleanup basePathname - const url = new URL(opts.basePathname, 'https://qwik.dev/'); - opts.basePathname = url.pathname; + opts.basePathname = resolveBasePathname(userOpts, viteBasePath); opts.mdx = opts.mdx || {}; opts.platform = opts.platform || {}; diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts index 03c5a7a6f98..57df6463ffa 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts @@ -20,9 +20,13 @@ export function generateQwikRouterConfig( c.push(`\nimport { isDev } from '@qwik.dev/core/build';`); if (isSSR) { - // Eagerly import all modules containing server$ functions so their _regSymbol - // side effects run before any RPC request arrives - esmImports.push(`import 'virtual:qwik-router-server-fns';`); + // The request handler awaits this before serving so the `server$` modules' + // _regSymbol side effects run ahead of any RPC request — async on purpose, + // so the config module evaluates without importing the runtime eagerly. + esmImports.push( + `import { importEagerModules } from 'virtual:qwik-router-server-fns';`, + `export { importEagerModules } from 'virtual:qwik-router-server-fns';` + ); } createServerPlugins(ctx, qwikPlugin, c, esmImports, isSSR); @@ -46,6 +50,12 @@ export function generateQwikRouterConfig( c.push(`export const cacheModules = !isDev;`); - c.push(`export default { routes, serverPlugins, trailingSlash, basePathname, cacheModules };`); + if (isSSR) { + c.push( + `export default { routes, serverPlugins, trailingSlash, basePathname, cacheModules, importEagerModules };` + ); + } else { + c.push(`export default { routes, serverPlugins, trailingSlash, basePathname, cacheModules };`); + } return esmImports.join('\n') + c.join('\n'); } diff --git a/packages/qwik-router/src/buildtime/vite/plugin.ts b/packages/qwik-router/src/buildtime/vite/plugin.ts index f8b53a11bc3..04fd2113d89 100644 --- a/packages/qwik-router/src/buildtime/vite/plugin.ts +++ b/packages/qwik-router/src/buildtime/vite/plugin.ts @@ -27,7 +27,7 @@ import { removeExtension, } from '../../utils/fs'; import { parseRoutesDir } from '../build'; -import { createBuildContext, resetBuildContext } from '../context'; +import { createBuildContext, resetBuildContext, resolveBasePathname } from '../context'; import { createMdxTransformer, type MdxTransform } from '../markdown/mdx'; import { transformMenu } from '../markdown/menu'; import { generateQwikRouterEntries } from '../runtime-generation/generate-entries'; @@ -320,6 +320,9 @@ function qwikRouterPlugin( userOpts?.defaultLoadersSerializationStrategy || 'never' ), 'globalThis.__NO_TRAILING_SLASH__': JSON.stringify(userOpts?.trailingSlash === false), + 'globalThis.__QWIK_ROUTER_BASE_PATHNAME__': JSON.stringify( + resolveBasePathname(userOpts, _viteConfig.base || '/') + ), 'globalThis.__SSR_CACHE_SIZE__': JSON.stringify( viteEnv.command === 'serve' ? 0 : (userOpts?.ssrCacheSize ?? 50) ), @@ -499,6 +502,14 @@ function qwikRouterPlugin( buildStart() { resetBuildContext(ctx); + // The runtime reaches the config only via dynamic import (static imports + // would evaluate app route/serverPlugin modules during the runtime's own + // import phase — see route-loaders.ts). The client build still needs the + // config in its module graph for route discovery and symbol extraction, + // so emit it as an explicit entry chunk here. + if (this.environment.mode === 'build' && this.environment.config.consumer === 'client') { + this.emitFile({ type: 'chunk', id: QWIK_ROUTER_CONFIG_ID }); + } }, resolveId(id) { @@ -759,14 +770,16 @@ function serverFnsPlugin(buildContextRef: BuildContextRef): Plugin { await collectServerFnModules.call(this); } if (!isServerBuild || serverFnModules.size === 0) { - return '// No server$ functions'; + return 'export const importEagerModules = () => Promise.resolve();\n'; } - return [...serverFnModules] - .map( - (mod, index) => - `import * as serverFnModule${index} from ${JSON.stringify(mod)};\nObject.values(serverFnModule${index});` - ) - .join('\n'); + // Deliberately dynamic imports behind a function: a static import here + // would evaluate the server$ modules during the config's own import + // phase, before runtime module bodies initialize (TDZ in bundled SSR). + return ( + 'export const importEagerModules = () =>\n Promise.all([\n' + + [...serverFnModules].map((mod) => ` import(${JSON.stringify(mod)}),`).join('\n') + + '\n ]);\n' + ); } return null; }, diff --git a/packages/qwik-router/src/middleware/request-handler/request-handler.ts b/packages/qwik-router/src/middleware/request-handler/request-handler.ts index 06927104451..0c4b04b5bd2 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-handler.ts @@ -20,12 +20,19 @@ let qwikRouterConfig: QwikRouterConfig; async function getConfig(): Promise { if (isDev) { - return (await import('@qwik-router-config')) as any as QwikRouterConfig; + const config = (await import('@qwik-router-config')) as any as QwikRouterConfig; + // Dev re-imports the config each request for freshness, but server$ + // registration side effects must still run before serving. + await config.importEagerModules?.(); + return config; } if (!qwikRouterConfig) { // The production server build prunes this plan (drops prerendered server-free routes); full // when nothing is excluded. See the router config `load`. - qwikRouterConfig = (await import('@qwik-router-config')) as any as QwikRouterConfig; + const config = (await import('@qwik-router-config')) as any as QwikRouterConfig; + // Run the server$ modules' registration side effects before serving. + await config.importEagerModules?.(); + qwikRouterConfig = config; } return qwikRouterConfig; } diff --git a/packages/qwik-router/src/runtime/src/constants.ts b/packages/qwik-router/src/runtime/src/constants.ts index 53622b7d85e..323d2e0621a 100644 --- a/packages/qwik-router/src/runtime/src/constants.ts +++ b/packages/qwik-router/src/runtime/src/constants.ts @@ -12,5 +12,9 @@ export const QDATA_KEY = 'qdata'; /** @public */ export const Q_ROUTE = 'q:route'; -export const DEFAULT_LOADERS_SERIALIZATION_STRATEGY: SerializationStrategy = - globalThis.__DEFAULT_LOADERS_SERIALIZATION_STRATEGY__ || 'never'; +// Hoisted function, not a const: read during the `@qwik-router-config` import +// cycle before this module's consts initialize (route modules call +// `routeLoaderQrl` at their own eval). +export function DEFAULT_LOADERS_SERIALIZATION_STRATEGY(): SerializationStrategy { + return globalThis.__DEFAULT_LOADERS_SERIALIZATION_STRATEGY__ || 'never'; +} diff --git a/packages/qwik-router/src/runtime/src/index.ts b/packages/qwik-router/src/runtime/src/index.ts index f31e05c8409..c90c3a9d28f 100644 --- a/packages/qwik-router/src/runtime/src/index.ts +++ b/packages/qwik-router/src/runtime/src/index.ts @@ -22,6 +22,8 @@ export type { DocumentMeta, DocumentScript, DocumentStyle, + // Surfaces in routeActionQrl's inferred return type, so it belongs to the public API. + Editable, ExcludeControlFlow, FailReturn, HttpStatus as HttpErrorProps, diff --git a/packages/qwik-router/src/runtime/src/prefetch-route.ts b/packages/qwik-router/src/runtime/src/prefetch-route.ts index 966c69b7195..aec1431ba21 100644 --- a/packages/qwik-router/src/runtime/src/prefetch-route.ts +++ b/packages/qwik-router/src/runtime/src/prefetch-route.ts @@ -1,4 +1,4 @@ -import * as qwikRouterConfig from '@qwik-router-config'; +import { basePathname } from './qwik-router-config'; import { isBrowser, isDev } from '@qwik.dev/core'; // @ts-expect-error no types for preloader yet import { p as preload } from '@qwik.dev/core/preloader'; @@ -32,6 +32,8 @@ export async function prefetchRoute( } try { + // Dynamic import on purpose — see qwik-router-component.tsx. + const qwikRouterConfig = await import('@qwik-router-config'); const loadedRoute = await loadRoute( (qwikRouterConfig as any).routes, (qwikRouterConfig as any).cacheModules, @@ -77,7 +79,8 @@ export const prefetchLoaderData = (loadedRoute: LoadedRoute, url: URL, manifestH .filter((loader) => loader.__cacheControl === 'immutable') .map((loader) => loader.__id) ); - const basePath = (qwikRouterConfig as any).basePathname ?? '/'; + // Not the config namespace: a static @qwik-router-config import TDZs in bundled output. + const basePath = basePathname; for (const hash of loadedRoute.$loaders$) { if (immutableIds.has(hash)) { continue; diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index 207d76e9812..3390b5870ae 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -30,7 +30,6 @@ * update without navigation, the head is resolved in a separate Task that tracks the relevant * signals. */ -import * as qwikRouterConfig from '@qwik-router-config'; import { ensureSlash } from '../../utils/pathname'; import { $, @@ -551,6 +550,10 @@ export const useQwikRouter = (props?: QwikRouterProps) => { } else if (!globalThis.__NO_TRAILING_SLASH__) { trackUrl.pathname = ensureSlash(trackUrl.pathname); } + // Dynamic import on purpose: a static config import runs the app's + // route/serverPlugin modules during this package's own evaluation + // (they import this package back), which TDZs in bundled output. + const qwikRouterConfig = await import('@qwik-router-config'); const loadRoutePromise = loadRoute( qwikRouterConfig.routes, qwikRouterConfig.cacheModules, diff --git a/packages/qwik-router/src/runtime/src/qwik-router-config.ts b/packages/qwik-router/src/runtime/src/qwik-router-config.ts index 56cd74a5050..22b4a874d10 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-config.ts +++ b/packages/qwik-router/src/runtime/src/qwik-router-config.ts @@ -4,7 +4,8 @@ import type { RouteData, RouteModule } from './types'; export const routes: RouteData = {}; export const serverPlugins: RouteModule[] = []; export const trailingSlash = !globalThis.__NO_TRAILING_SLASH__; -export const basePathname = '/'; +// Build-time replaced by the router vite plugin's `define`; '/' outside app builds. +export const basePathname = globalThis.__QWIK_ROUTER_BASE_PATHNAME__ ?? '/'; export const cacheModules = false; export const fallthrough = false; diff --git a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md index fdbfd24869e..a707420bd1e 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md +++ b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md @@ -32,11 +32,11 @@ import type { ResolveSyncValue } from '@qwik.dev/router/middleware/request-handl import type { SerializationStrategy } from '@qwik.dev/core/internal'; import type { ServerError } from '@qwik.dev/router/middleware/request-handler'; import type { Signal } from '@qwik.dev/core'; -import type * as v from 'valibot'; -import type { ValueOrPromise } from '@qwik.dev/core'; +import * as v from 'valibot'; +import { ValueOrPromise } from '@qwik.dev/core'; import { ValueOrPromise as ValueOrPromise_2 } from '@qwik.dev/core/internal'; import { z } from 'zod'; -import type * as z_2 from 'zod'; +import * as z_2 from 'zod'; // @public (undocumented) export type Action, OPTIONAL extends boolean = true> = { @@ -193,6 +193,11 @@ export type DocumentStyle = Readonly<((Omit; +// @public (undocumented) +export type Editable = { + -readonly [P in keyof T]: T[P]; +}; + // Warning: (ae-forgotten-export) The symbol "ErrorBoundaryProps" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -248,11 +253,17 @@ export type GetValidatorType = GetValidato // @public (undocumented) export const globalAction$: ActionConstructor; -// Warning: (ae-forgotten-export) The symbol "ActionConstructorQRL" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "globalActionQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const globalActionQrl: ActionConstructorQRL; +export function globalActionQrl(actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, ...rest: (ActionOptions | DataValidator)[]): { + (): Editable>; + __brand: "server_action"; + __validators: DataValidator[]; + __qrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>; + __id: string; + __invalidate: string[] | undefined; +}; // @public (undocumented) export type HttpErrorProps = { @@ -373,6 +384,7 @@ export interface QwikRouterConfig { // (undocumented) readonly cacheModules?: boolean; readonly fallthrough?: boolean; + readonly importEagerModules?: () => Promise; // (undocumented) readonly routes: RouteData; // (undocumented) @@ -470,8 +482,15 @@ export type ResolvedDocumentHead = Recor // @public export const routeAction$: ActionConstructor; -// @public (undocumented) -export const routeActionQrl: ActionConstructorQRL; +// @public +export function routeActionQrl(actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, ...rest: (ActionOptions | DataValidator)[]): { + (): Editable>; + __brand: "server_action"; + __validators: DataValidator[]; + __qrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>; + __id: string; + __invalidate: string[] | undefined; +}; // @public export type RouteConfig = RouteConfigValue | ((props: DocumentHeadProps) => RouteConfigValue); @@ -511,11 +530,12 @@ export interface RouteData { // @public export const routeLoader$: LoaderConstructor; -// Warning: (ae-forgotten-export) The symbol "LoaderConstructorQRL" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "LoaderOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "LoaderInternal" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "routeLoaderQrl" should be prefixed with an underscore because the declaration is marked as @internal // -// @internal (undocumented) -export const routeLoaderQrl: LoaderConstructorQRL; +// @internal +export function routeLoaderQrl(loaderQrl: QRL<(event: RequestEventLoader) => unknown>, ...rest: (LoaderOptions | DataValidator)[]): LoaderInternal; // @public (undocumented) export interface RouteLocation { @@ -570,7 +590,7 @@ export type ServerQRL = QRL<((abort: AbortSignal, ...a // Warning: (ae-internal-missing-underscore) The name "serverQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const serverQrl: (qrl: QRL, options?: ServerConfig) => ServerQRL; +export function serverQrl(qrl: QRL, options?: ServerConfig): ServerQRL; // @public export const ServiceWorkerRegister: (props: { @@ -633,11 +653,10 @@ export const useQwikRouter: (props?: QwikRouterProps) => void; // @beta (undocumented) export const valibot$: ValibotConstructor; -// Warning: (ae-forgotten-export) The symbol "ValibotConstructorQRL" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "valibotQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const valibotQrl: ValibotConstructorQRL; +export function valibotQrl(qrl: QRL v.GenericSchema | v.GenericSchemaAsync)>): ValibotDataValidator; // Warning: (ae-forgotten-export) The symbol "ValidatorConstructor" needs to be exported by the entry point index.d.ts // @@ -659,11 +678,10 @@ export type ValidatorErrorType = { }>; }; -// Warning: (ae-forgotten-export) The symbol "ValidatorConstructorQRL" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "validatorQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const validatorQrl: ValidatorConstructorQRL; +export function validatorQrl(validator: QRL<(ev: RequestEvent, data: unknown) => ValueOrPromise>): DataValidator; // Warning: (ae-forgotten-export) The symbol "ValidatorReturnSuccess" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ValidatorReturnFail" needs to be exported by the entry point index.d.ts @@ -684,11 +702,10 @@ export type ZodConstructor = { (schema: (zod: typeof z_2.z, ev: RequestEvent) => T): ZodDataValidator; }; -// Warning: (ae-forgotten-export) The symbol "ZodConstructorQRL" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "zodQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const zodQrl: ZodConstructorQRL; +export function zodQrl(qrl: QRL z_2.ZodRawShape)>): ZodDataValidator; // (No @packageDocumentation comment for this package) diff --git a/packages/qwik-router/src/runtime/src/route-loaders.ts b/packages/qwik-router/src/runtime/src/route-loaders.ts index 8685a80fd7b..72995970843 100644 --- a/packages/qwik-router/src/runtime/src/route-loaders.ts +++ b/packages/qwik-router/src/runtime/src/route-loaders.ts @@ -1,4 +1,3 @@ -import * as qwikRouterConfig from '@qwik-router-config'; import { createComputed$, implicit$FirstArg, @@ -34,6 +33,7 @@ import { } from '../../middleware/request-handler/server-error'; import { ensureSlash } from '../../utils/pathname'; import { DEFAULT_LOADERS_SERIALIZATION_STRATEGY } from './constants'; +import { basePathname } from './qwik-router-config'; import { RouteLoaderCtxContext, RouteStateContext } from './contexts'; import type { DataValidator, @@ -322,7 +322,7 @@ const createRouteLoaderSignal = ( const pageSearch = trackedPageSearch || location.search; const pageUrl = new URL(pagePathname + pageSearch, location.href); const mHash = routeLoaderCtx.manifestHash || 'dev'; - const basePath = (qwikRouterConfig as any).basePathname ?? '/'; + const basePath = basePathname; const needsResumeFetch = stateValues[resumeValueKey] === _UNINITIALIZED; const fetchRoutePath = routePath || (needsResumeFetch ? pageUrl.pathname : undefined); // A loader that's never been on any route we've visited has no fetch path yet — @@ -418,9 +418,11 @@ export const filterSearchParams = (params: URLSearchParams, allowed: string[]): return filtered.toString() ? `?${filtered.toString()}` : ''; }; -const getLoaderOptions = (rest: (LoaderOptions | DataValidator)[]) => { +// Hoisted function: called from `routeLoaderQrl` during the config cycle, +// before this module's own consts are initialized. +function getLoaderOptions(rest: (LoaderOptions | DataValidator)[]) { let id: string | undefined; - let serializationStrategy: SerializationStrategy = DEFAULT_LOADERS_SERIALIZATION_STRATEGY; + let serializationStrategy: SerializationStrategy = DEFAULT_LOADERS_SERIALIZATION_STRATEGY(); let cacheControl: LoaderOptions['cacheControl'] | undefined; let eTag: LoaderOptions['eTag'] | undefined; let cacheKey: LoaderOptions['cacheKey'] | undefined; @@ -481,7 +483,7 @@ const getLoaderOptions = (rest: (LoaderOptions | DataValidator)[]) => { search, blockSSR, }; -}; +} /** * Returns the current RequestEvent if possible. Only usable on the server, and only during request @@ -908,11 +910,17 @@ export const getRouteLoaderResponse = async ( } }; -/** @internal */ -export const routeLoaderQrl = (( +/** + * A hoisted function declaration on purpose: this module imports `@qwik-router-config`, whose route + * modules call `routeLoaderQrl` back at their own eval — in a bundle that cycle executes the routes + * first, and a `const` binding would throw a TDZ ReferenceError. + * + * @internal + */ +export function routeLoaderQrl( loaderQrl: QRL<(event: RequestEventLoader) => unknown>, ...rest: (LoaderOptions | DataValidator)[] -): LoaderInternal => { +): LoaderInternal { const { id, validators, serializationStrategy, cacheControl, eTag, cacheKey, search, blockSSR } = getLoaderOptions(rest); @@ -939,7 +947,7 @@ export const routeLoaderQrl = (( loader.__blockSSR = blockSSR; Object.freeze(loader); return loader; -}) as LoaderConstructorQRL; +} /** * Define a route loader that fetches data before the route renders. @@ -968,7 +976,9 @@ export const routeLoaderQrl = (( * * @public */ -export const routeLoader$: LoaderConstructor = /*#__PURE__*/ implicit$FirstArg(routeLoaderQrl); +export const routeLoader$: LoaderConstructor = /*#__PURE__*/ implicit$FirstArg( + routeLoaderQrl as LoaderConstructorQRL +); async function runValidators( requestEv: RequestEvent, diff --git a/packages/qwik-router/src/runtime/src/server-functions.ts b/packages/qwik-router/src/runtime/src/server-functions.ts index a6d4ec90824..8f7da0bc5aa 100644 --- a/packages/qwik-router/src/runtime/src/server-functions.ts +++ b/packages/qwik-router/src/runtime/src/server-functions.ts @@ -46,11 +46,17 @@ import { useAction, useLocation } from './use-functions'; import { _asyncRequestStore } from '../../middleware/request-handler'; export { getRequestEvent } from './route-loaders'; -/** @public */ -export const routeActionQrl = (( +/** + * Hoisted function declarations (not consts) on purpose, like `routeLoaderQrl`: generated route + * modules call these factories back during their own evaluation via the `@qwik-router-config` + * cycle, where a `const` binding would throw a TDZ ReferenceError. + * + * @public + */ +export function routeActionQrl( actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, ...rest: (ActionOptions | DataValidator)[] -) => { +) { const { id, validators, invalidate } = getValidators(rest, actionQrl); function action() { const loc = useLocation() as Editable; @@ -149,13 +155,13 @@ Action.run() can only be called on the browser, for example when a user clicks a Object.freeze(action); return action satisfies ActionInternal; -}) as unknown as ActionConstructorQRL; +} /** @internal */ -export const globalActionQrl = (( +export function globalActionQrl( actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, ...rest: (ActionOptions | DataValidator)[] -) => { +) { const action = routeActionQrl(actionQrl, ...(rest as any)); if (isServer) { if (typeof globalThis._qwikActionsMap === 'undefined') { @@ -164,7 +170,7 @@ export const globalActionQrl = (( globalThis._qwikActionsMap!.set((action as ActionInternal).__id, action as ActionInternal); } return action; -}) as ActionConstructorQRL; +} /** * Define a route action that handles form submissions or programmatic invocations. @@ -187,7 +193,7 @@ export const globalActionQrl = (( * @public */ export const routeAction$: ActionConstructor = /*#__PURE__*/ implicit$FirstArg( - routeActionQrl + routeActionQrl as unknown as ActionConstructorQRL ) as any; /** @public */ @@ -196,19 +202,21 @@ export const globalAction$: ActionConstructor = /*#__PURE__*/ implicit$FirstArg( ) as any; /** @internal */ -export const validatorQrl = (( +export function validatorQrl( validator: QRL<(ev: RequestEvent, data: unknown) => ValueOrPromise> -): DataValidator => { +): DataValidator { if (isServer) { return { validate: validator, }; } return undefined as any; -}) as ValidatorConstructorQRL; +} /** @public */ -export const validator$: ValidatorConstructor = /*#__PURE__*/ implicit$FirstArg(validatorQrl); +export const validator$: ValidatorConstructor = /*#__PURE__*/ implicit$FirstArg( + validatorQrl as ValidatorConstructorQRL +); const flattenValibotIssues = (issues: v.GenericIssue[]) => { return issues.reduce>((acc, issue) => { @@ -235,13 +243,13 @@ const flattenValibotIssues = (issues: v.GenericIssue[]) => { }; /** @internal */ -export const valibotQrl: ValibotConstructorQRL = ( +export function valibotQrl( qrl: QRL< | v.GenericSchema | v.GenericSchemaAsync | ((ev: RequestEvent) => v.GenericSchema | v.GenericSchemaAsync) > -): ValibotDataValidator => { +): ValibotDataValidator { if (!__EXPERIMENTAL__.valibot) { throw new Error( 'Valibot is an experimental feature and is not enabled. Please enable the feature flag by adding `experimental: ["valibot"]` to your qwikVite plugin options.' @@ -278,10 +286,12 @@ export const valibotQrl: ValibotConstructorQRL = ( }; } return undefined as never; -}; +} /** @beta */ -export const valibot$: ValibotConstructor = /*#__PURE__*/ implicit$FirstArg(valibotQrl); +export const valibot$: ValibotConstructor = /*#__PURE__*/ implicit$FirstArg( + valibotQrl as ValibotConstructorQRL +); const flattenZodIssues = (issues: z.ZodIssue | z.ZodIssue[]) => { issues = Array.isArray(issues) ? issues : [issues]; @@ -308,11 +318,11 @@ const flattenZodIssues = (issues: z.ZodIssue | z.ZodIssue[]) => { }; /** @internal */ -export const zodQrl: ZodConstructorQRL = ( +export function zodQrl( qrl: QRL< z.ZodRawShape | z.Schema | ((z: typeof import('zod').z, ev: RequestEvent) => z.ZodRawShape) > -): ZodDataValidator => { +): ZodDataValidator { if (isServer) { return { __brand: 'zod', @@ -348,16 +358,16 @@ export const zodQrl: ZodConstructorQRL = ( }; } return undefined as never; -}; +} /** @public */ -export const zod$: ZodConstructor = /*#__PURE__*/ implicit$FirstArg(zodQrl); +export const zod$: ZodConstructor = /*#__PURE__*/ implicit$FirstArg(zodQrl as ZodConstructorQRL); /** @internal */ -export const serverQrl = ( +export function serverQrl( qrl: QRL, options?: ServerConfig -): ServerQRL => { +): ServerQRL { if (isServer) { const captured = qrl.getCaptured(); if (captured && captured.length > 0 && !_getContextHostElement()) { @@ -461,12 +471,12 @@ export const serverQrl = ( } } }) as ServerQRL; -}; +} /** @public */ export const server$ = /*#__PURE__*/ implicit$FirstArg(serverQrl); -const getValidators = (rest: (ActionOptions | DataValidator)[], qrl: QRL) => { +function getValidators(rest: (ActionOptions | DataValidator)[], qrl: QRL) { let id: string | undefined; let invalidate: string[] | undefined; const validators: DataValidator[] = []; @@ -506,7 +516,7 @@ const getValidators = (rest: (ActionOptions | DataValidator)[], qrl: QRL) => { id, invalidate, }; -}; +} const deserializeStream = async function* ( stream: ReadableStream, diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index 2a033d86a3d..4926fc0e264 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -355,6 +355,12 @@ export interface QwikRouterConfig { readonly cacheModules?: boolean; /** When true, return null instead of rendering the 404 page, letting the adapter handle it */ readonly fallthrough?: boolean; + /** + * Imports the modules containing `server$` functions so their registration side effects run. + * Called by the request handler before serving; deliberately async so the config module itself + * evaluates without touching the runtime (see the import-cycle notes in `route-loaders.ts`). + */ + readonly importEagerModules?: () => Promise; } /** @public */ @@ -501,6 +507,7 @@ export interface SimpleURL { hash: string; } +/** @public */ export type Editable = { -readonly [P in keyof T]: T[P]; }; diff --git a/packages/qwik-vite/api-extractor.json b/packages/qwik-vite/api-extractor.json index 9511dceba4e..6ff3822f9ca 100644 --- a/packages/qwik-vite/api-extractor.json +++ b/packages/qwik-vite/api-extractor.json @@ -1,6 +1,7 @@ { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "extends": "../qwik/src/api-extractor.json", + "bundledPackages": ["@qwik.dev/optimizer"], "mainEntryPointFilePath": "/../../dist-dev/dts-out/packages/qwik-vite/src/index.d.ts", "newlineKind": "lf", "apiReport": { diff --git a/packages/qwik-vite/package.json b/packages/qwik-vite/package.json index 8b3bc454b62..b51a212c07f 100644 --- a/packages/qwik-vite/package.json +++ b/packages/qwik-vite/package.json @@ -1,6 +1,8 @@ { "name": "@qwik.dev/qwik-vite", "devDependencies": { + "@qwik.dev/optimizer": "workspace:*", + "@qwik.dev/ts-optimizer": "workspace:*", "image-size": "2.0.2", "magic-string": "0.30.21", "rolldown": "1.2.4", diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index b68c69d7f2c..8c978f7c80d 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -100,6 +100,28 @@ export interface QwikPackages { path: string; } +/** + * Load the Rust optimizer, which `tsOptimizer: false` opts into. It is an optional peer dependency + * of `@qwik.dev/core`, so the common way to reach this is asking for it without installing it — a + * bare resolution failure would not say that. + */ +export async function loadRustOptimizer( + importRustOptimizer: () => Promise = () => + import('@qwik.dev/optimizer') +): Promise { + try { + return await importRustOptimizer(); + } catch (err) { + throw new Error( + `Qwik: "tsOptimizer: false" selects the Rust optimizer, but "@qwik.dev/optimizer" could not be loaded.\n` + + `It is an optional peer dependency, so install it:\n` + + ` npm i -D @qwik.dev/optimizer\n` + + `Or remove "tsOptimizer: false" from qwikVite() to use the default TypeScript optimizer.\n` + + `Original error: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { const id = `${Math.round(Math.random() * 899) + 100}`; @@ -152,10 +174,16 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { let maybeFs: typeof import('fs') | undefined | null; const init = async () => { if (!internalOptimizer) { - const createOptimizer = ( - (optimizerOptions._optimizer as typeof import('@qwik.dev/optimizer')) || - (await import('@qwik.dev/optimizer')) - ).createOptimizer; + const loadOptimizerModule = async () => { + if (optimizerOptions._optimizer) { + return optimizerOptions._optimizer as typeof import('@qwik.dev/optimizer'); + } + if (optimizerOptions.tsOptimizer !== false) { + return (await import('@qwik.dev/ts-optimizer')) as unknown as typeof import('@qwik.dev/optimizer'); + } + return loadRustOptimizer(); + }; + const createOptimizer = (await loadOptimizerModule()).createOptimizer; internalOptimizer = await createOptimizer(optimizerOptions); lazyNormalizePath = makeNormalizePath(internalOptimizer.sys); if ( diff --git a/packages/qwik-vite/src/plugins/plugin.unit.ts b/packages/qwik-vite/src/plugins/plugin.unit.ts index 6656fb8de17..c7767dbfe71 100644 --- a/packages/qwik-vite/src/plugins/plugin.unit.ts +++ b/packages/qwik-vite/src/plugins/plugin.unit.ts @@ -2,7 +2,7 @@ import path, { resolve } from 'node:path'; import { assert, describe, expect, test } from 'vitest'; import { normalizePath } from '../../../qwik/src/testing/util'; import type { QwikManifest } from '../types'; -import { ExperimentalFeatures, createQwikPlugin } from './plugin'; +import { ExperimentalFeatures, createQwikPlugin, loadRustOptimizer } from './plugin'; import { isServerOnlyModule } from './server-only-modules'; import { qwikVite } from './vite'; import type { ResolvedId } from 'rolldown'; @@ -833,3 +833,18 @@ describe('transform: globalThis.__QWIK_MANIFEST__ replacement', () => { expect(result!.map).toBeTruthy(); }); }); + +describe('rust optimizer opt-in', () => { + test('a missing @qwik.dev/optimizer explains how to install it', async () => { + const failing = () => Promise.reject(new Error("Cannot find package '@qwik.dev/optimizer'")); + await expect(loadRustOptimizer(failing)).rejects.toThrow(/npm i -D @qwik\.dev\/optimizer/); + await expect(loadRustOptimizer(failing)).rejects.toThrow(/optional peer dependency/); + // The underlying resolution failure stays visible. + await expect(loadRustOptimizer(failing)).rejects.toThrow(/Cannot find package/); + }); + + test('passes the module through when it is installed', async () => { + const fake = { createOptimizer: () => ({}) } as unknown as typeof import('@qwik.dev/optimizer'); + await expect(loadRustOptimizer(() => Promise.resolve(fake))).resolves.toBe(fake); + }); +}); diff --git a/packages/qwik-vite/src/plugins/vite.ts b/packages/qwik-vite/src/plugins/vite.ts index 45ef2370f5d..a5a8c2c0027 100644 --- a/packages/qwik-vite/src/plugins/vite.ts +++ b/packages/qwik-vite/src/plugins/vite.ts @@ -128,7 +128,10 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any { ? (id, type) => TRANSFORM_REGEX.test(id) || qwikViteOpts.fileFilter!(id, type) : () => true; const injections: GlobalInjections[] = []; - const qwikPlugin = createQwikPlugin(qwikViteOpts.optimizerOptions); + const qwikPlugin = createQwikPlugin({ + tsOptimizer: qwikViteOpts.tsOptimizer, + ...qwikViteOpts.optimizerOptions, + }); const bundleGraphAdders = new Set(); @@ -1013,6 +1016,15 @@ interface QwikVitePluginCommonOptions { * Default `undefined` */ optimizerOptions?: OptimizerOptions; + /** + * Use the TypeScript optimizer (`@qwik.dev/ts-optimizer`) instead of the Rust one. + * + * Setting this to `false` requires installing `@qwik.dev/optimizer`, which is an optional peer + * dependency of `@qwik.dev/core`. + * + * Default `true` + */ + tsOptimizer?: boolean; /** * Hook that's called after the build and provides all of the transformed modules that were used * before bundling. diff --git a/packages/qwik-vite/src/plugins/vite.unit.ts b/packages/qwik-vite/src/plugins/vite.unit.ts index 7d6fa4ced5f..71fc9970801 100644 --- a/packages/qwik-vite/src/plugins/vite.unit.ts +++ b/packages/qwik-vite/src/plugins/vite.unit.ts @@ -799,6 +799,20 @@ describe('worker qrl chunk rewrites', () => { ); }); + test('stops the placeholder match at an escaped quote', () => { + const resolver = createDevWorkerQrlChunkResolver('/e2e/src/routes/worker/index.tsx'); + + // A placeholder inside a double-quoted literal ends with \" — the + // backslash must not be swallowed into the import path. + const code = `const chunk = "\\"${QWIK_WORKER_QRL_SENTINEL}./index.tsx_incrementInWorker_worker_abcd.js\\";";`; + const rewritten = rewriteWorkerQrlChunkPlaceholders(code, resolver); + + assert.equal( + rewritten, + 'const chunk = "\\"/e2e/src/routes/worker/index.tsx_incrementInWorker_worker_abcd.js?worker_file&type=module\\";";' + ); + }); + test('rewrites worker chunk placeholders to dev served qrl urls with query suffixes', () => { const resolver = createDevWorkerQrlChunkResolver('/e2e/src/routes/worker/index.tsx'); diff --git a/packages/qwik-vite/src/plugins/worker-qrl-chunks.ts b/packages/qwik-vite/src/plugins/worker-qrl-chunks.ts index 84cb148662d..84bd21c075d 100644 --- a/packages/qwik-vite/src/plugins/worker-qrl-chunks.ts +++ b/packages/qwik-vite/src/plugins/worker-qrl-chunks.ts @@ -4,7 +4,8 @@ const QWIK_WORKER_QRL_SENTINEL_NAME = '__QWIK_WORKER_QRL__'; export const QWIK_WORKER_QRL_SENTINEL = `${QWIK_WORKER_QRL_SENTINEL_NAME}:`; export const QWIK_WORKER_CORE_SENTINEL = '__QWIK_WORKER_CORE__'; -const QWIK_WORKER_QRL_RE = new RegExp(`${QWIK_WORKER_QRL_SENTINEL}([^"'\\\`\\s]+)`, 'g'); +// Exclude backslash so escaped quotes (\") in string literals end the match. +const QWIK_WORKER_QRL_RE = new RegExp(`${QWIK_WORKER_QRL_SENTINEL}([^"'\\\`\\s\\\\]+)`, 'g'); const joinPublicPath = (basePathname: string, fileName: string) => { const base = basePathname.endsWith('/') ? basePathname : `${basePathname}/`; diff --git a/packages/qwik-vite/src/qwik.optimizer.api.md b/packages/qwik-vite/src/qwik.optimizer.api.md index 18877763e95..ddc20701da2 100644 --- a/packages/qwik-vite/src/qwik.optimizer.api.md +++ b/packages/qwik-vite/src/qwik.optimizer.api.md @@ -4,13 +4,7 @@ ```ts -import { EntryStrategy } from '@qwik.dev/optimizer'; -import { Optimizer } from '@qwik.dev/optimizer'; -import { OptimizerOptions } from '@qwik.dev/optimizer'; import type { Plugin as Plugin_2 } from 'vite'; -import { SegmentAnalysis } from '@qwik.dev/optimizer'; -import { TransformModule } from '@qwik.dev/optimizer'; -import { TransformModuleInput } from '@qwik.dev/optimizer'; // @public export type BundleGraphAdder = (manifest: QwikManifest) => Record Record; +// @public (undocumented) +export interface ComponentEntryStrategy { + // (undocumented) + manual?: Record; + // (undocumented) + type: 'component'; +} + +// @public (undocumented) +export const createOptimizer: (optimizerOptions?: OptimizerOptions) => Promise; + +// @public (undocumented) +export interface Diagnostic { + // (undocumented) + category: DiagnosticCategory; + // (undocumented) + code: string | null; + // (undocumented) + file: string; + // (undocumented) + highlights: SourceLocation[] | null; + // (undocumented) + message: string; + // (undocumented) + scope: string; + // (undocumented) + suggestions: string[] | null; +} + +// @public (undocumented) +export type DiagnosticCategory = 'error' | 'warning' | 'sourceError'; + +// @public (undocumented) +export type EmitMode = 'dev' | 'prod' | 'lib' | 'hmr'; + +// @public (undocumented) +export type EntryStrategy = InlineEntryStrategy | HoistEntryStrategy | SingleEntryStrategy | HookEntryStrategy | SegmentEntryStrategy | ComponentEntryStrategy | SmartEntryStrategy; + // @public export enum ExperimentalFeatures { blockSSR = "blockSSR", @@ -41,6 +73,110 @@ export interface GlobalInjections { tag: string; } +// @public (undocumented) +export interface HoistEntryStrategy { + // (undocumented) + type: 'hoist'; +} + +// @public @deprecated (undocumented) +export interface HookEntryStrategy { + // (undocumented) + manual?: Record; + // (undocumented) + type: 'hook'; +} + +// @public (undocumented) +export interface InlineEntryStrategy { + // (undocumented) + type: 'inline'; +} + +// @public (undocumented) +export type MinifyMode = 'simplify' | 'none'; + +// @public (undocumented) +export interface Optimizer { + sys: OptimizerSystem; + transformModules(opts: TransformModulesOptions): Promise; +} + +// @public (undocumented) +export interface OptimizerOptions { + // (undocumented) + binding?: any; + inlineStylesUpToBytes?: number; + // (undocumented) + _optimizer?: unknown; + sourcemap?: boolean; + // (undocumented) + sys?: OptimizerSystem; + tsOptimizer?: boolean; +} + +// @public (undocumented) +export interface OptimizerSystem { + // (undocumented) + cwd: () => string; + // (undocumented) + dynamicImport: (path: string) => Promise; + // (undocumented) + env: SystemEnvironment; + // (undocumented) + getInputFiles?: (rootDir: string) => Promise; + // (undocumented) + os: string; + // (undocumented) + path: Path; + // (undocumented) + strictDynamicImport: (path: string) => Promise; +} + +// @public (undocumented) +export interface Path { + // (undocumented) + basename(path: string, ext?: string): string; + // (undocumented) + readonly delimiter: string; + // (undocumented) + dirname(path: string): string; + // (undocumented) + extname(path: string): string; + // (undocumented) + format(pathObject: { + root: string; + dir: string; + base: string; + ext: string; + name: string; + }): string; + // (undocumented) + isAbsolute(path: string): boolean; + // (undocumented) + join(...paths: string[]): string; + // (undocumented) + normalize(path: string): string; + // (undocumented) + parse(path: string): { + root: string; + dir: string; + base: string; + ext: string; + name: string; + }; + // (undocumented) + readonly posix: Path; + // (undocumented) + relative(from: string, to: string): string; + // (undocumented) + resolve(...paths: string[]): string; + // (undocumented) + readonly sep: string; + // (undocumented) + readonly win32: null; +} + // @public (undocumented) export interface QwikAsset { name: string | undefined; @@ -205,17 +341,169 @@ export interface ResolvedManifest { mapper: SymbolMapper; } +// @public (undocumented) +export interface SegmentAnalysis { + // (undocumented) + canonicalFilename: string; + captureNames?: string[]; + // (undocumented) + captures: boolean; + // (undocumented) + ctxKind: 'eventHandler' | 'function'; + // (undocumented) + ctxName: string; + // (undocumented) + displayName: string; + // (undocumented) + entry: string | null; + // (undocumented) + extension: string; + // (undocumented) + hash: string; + // (undocumented) + loc: [number, number]; + // (undocumented) + name: string; + // (undocumented) + origin: string; + paramNames?: string[]; + // (undocumented) + parent: string | null; +} + +// @public (undocumented) +export interface SegmentEntryStrategy { + // (undocumented) + manual?: Record; + // (undocumented) + type: 'segment'; +} + // @public export type ServerQwikManifest = Pick; +// @public (undocumented) +export interface SingleEntryStrategy { + // (undocumented) + manual?: Record; + // (undocumented) + type: 'single'; +} + +// @public (undocumented) +export interface SmartEntryStrategy { + // (undocumented) + manual?: Record; + // (undocumented) + type: 'smart'; +} + +// @public (undocumented) +export interface SourceLocation { + // (undocumented) + endCol: number; + // (undocumented) + endLine: number; + // (undocumented) + hi: number; + // (undocumented) + lo: number; + // (undocumented) + startCol: number; + // (undocumented) + startLine: number; +} + // @public (undocumented) export type SymbolMapper = Record; // @public (undocumented) export type SymbolMapperFn = (symbolName: string, mapper: SymbolMapper | undefined, parent?: string) => readonly [symbol: string, chunk: string] | undefined; +// @public (undocumented) +export type SystemEnvironment = 'node' | 'deno' | 'bun' | 'webworker' | 'browsermain' | 'unknown'; + +// @public (undocumented) +export interface TransformModule { + // (undocumented) + code: string; + // (undocumented) + imports?: string[]; + // (undocumented) + isEntry: boolean; + // (undocumented) + map: string | null; + // (undocumented) + origPath: string | null; + // (undocumented) + path: string; + // (undocumented) + segment: SegmentAnalysis | null; +} + +// @public (undocumented) +export interface TransformModuleInput { + // (undocumented) + code: string; + // (undocumented) + devPath?: string; + // (undocumented) + path: string; +} + +// @public (undocumented) +export interface TransformModulesOptions extends TransformOptions { + // (undocumented) + input: TransformModuleInput[]; +} -export * from "@qwik.dev/optimizer"; +// @public (undocumented) +export interface TransformOptions { + // (undocumented) + entryStrategy?: EntryStrategy; + // (undocumented) + explicitExtensions?: boolean; + // (undocumented) + isServer?: boolean; + // (undocumented) + minify?: MinifyMode; + // (undocumented) + mode?: EmitMode; + // (undocumented) + preserveFilenames?: boolean; + // (undocumented) + regCtxName?: string[]; + // (undocumented) + rootDir?: string; + // (undocumented) + scope?: string; + // (undocumented) + sourceMaps?: boolean; + // (undocumented) + srcDir: string; + // (undocumented) + stripCtxName?: string[]; + // (undocumented) + stripEventHandlers?: boolean; + // (undocumented) + stripExports?: string[]; + // (undocumented) + transpileJsx?: boolean; + // (undocumented) + transpileTs?: boolean; +} + +// @public (undocumented) +export interface TransformOutput { + // (undocumented) + diagnostics: Diagnostic[]; + // (undocumented) + isJsx: boolean; + // (undocumented) + isTypeScript: boolean; + // (undocumented) + modules: TransformModule[]; +} // (No @packageDocumentation comment for this package) diff --git a/packages/qwik/package.json b/packages/qwik/package.json index 06596621c6e..bbb7cf83a6a 100644 --- a/packages/qwik/package.json +++ b/packages/qwik/package.json @@ -8,15 +8,23 @@ }, "bugs": "https://github.com/QwikDev/qwik/issues", "dependencies": { - "@qwik.dev/optimizer": "workspace:*", + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", "csstype": "^3.2.3", "launch-editor": "^2.14.1", - "magic-string": "0.30.21" + "magic-regexp": "^0.11.0", + "magic-string": "^0.30.21", + "oxc-parser": "^0.144.0", + "oxc-transform": "^0.144.0", + "oxc-walker": "^1.1.1", + "pathe": "^2.0.3", + "siphash": "^1.2.0" }, "devDependencies": { "@croct/json5-parser": "0.2.2", "@qwik.dev/core": "workspace:*", "@qwik.dev/dom": "workspace:*", + "@qwik.dev/optimizer": "workspace:*", "domino": "2.1.6", "ignore": "5.3.1", "image-size": "2.0.2", @@ -160,16 +168,20 @@ "license": "MIT", "main": "./src/index.ts", "peerDependencies": { + "@qwik.dev/optimizer": "workspace:*", "prettier": "*", "vite": ">=8 <9", "vitest": ">=2 <4" }, "peerDependenciesMeta": { - "vitest": { + "@qwik.dev/optimizer": { "optional": true }, "prettier": { "optional": true + }, + "vitest": { + "optional": true } }, "publishConfig": { diff --git a/packages/qwik/tsconfig.json b/packages/qwik/tsconfig.json index 63eb1001116..c1a1febd7d4 100644 --- a/packages/qwik/tsconfig.json +++ b/packages/qwik/tsconfig.json @@ -8,6 +8,7 @@ "@qwik.dev/core/jsx-dev-runtime": ["packages/qwik/src/jsx-runtime"], "@qwik.dev/core/build": ["packages/qwik/src/build"], "@qwik.dev/optimizer": ["packages/optimizer/src"], + "@qwik.dev/ts-optimizer": ["packages/ts-optimizer/src"], "@qwik.dev/core/optimizer": ["packages/qwik-vite/src"], "@qwik.dev/core/preloader": ["packages/qwik/src/preloader"], "@qwik.dev/core/server": ["packages/qwik/src/server"], @@ -18,6 +19,11 @@ "@qwik.dev/core/insights/vite": ["packages/qwik/src/insights/vite"] } }, - "include": ["src", "global.d.ts", "../qwik-vite/global.d.ts"], + "include": [ + "src", + "global.d.ts", + "../qwik-vite/global.d.ts", + "../ts-optimizer/src/hashing/siphash13.d.ts" + ], "exclude": ["dist"] } diff --git a/packages/ts-optimizer/.gitignore b/packages/ts-optimizer/.gitignore new file mode 100644 index 00000000000..380a955fca9 --- /dev/null +++ b/packages/ts-optimizer/.gitignore @@ -0,0 +1 @@ +ts-output/ diff --git a/packages/ts-optimizer/BENCHMARKS.md b/packages/ts-optimizer/BENCHMARKS.md new file mode 100644 index 00000000000..07628591549 --- /dev/null +++ b/packages/ts-optimizer/BENCHMARKS.md @@ -0,0 +1,261 @@ +# Performance Benchmark History + +Wall-time perf comparison of the TypeScript optimizer (this repo) against the SWC reference (`@qwik.dev/optimizer`'s NAPI binding). Use this doc to spot regressions and to track whether perf-targeted work is moving the needle. + +The numbers come from `tests/benchmark/optimizer-benchmark.test.ts`. Append a row whenever you've shipped (or are about to ship) a change that could plausibly affect throughput. + +--- + +## What's measured + +Two benchmarks defined in `tests/benchmark/optimizer-benchmark.test.ts`, both running over real Qwik source: + +| Benchmark | Input | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **BENCH-01** | `$QWIK_HOME/packages/**/*.{ts,tsx,js,jsx}` (excluding `node_modules`/`dist`/`.turbo`) — currently ~1391 files | "Whole monorepo" pass — exercises file-discovery + per-file batching cost | +| **BENCH-02** | `$QWIK_HOME/packages/qwik/src/core/tests/component.spec.tsx` — 3860 lines | Worst-case single file — a heavy component-spec that stresses extraction + segment generation | + +Each benchmark warms up once, then takes the **min wall-time** across `MEASURED_RUNS = 2` measured runs. The `Ratio` column is `TS time ÷ SWC time`. + +The current CI assertion caps are **1.15×** for BENCH-01 and **1.5×** for BENCH-02 — both pass as of 2026-08-15 (0.60× / 1.07×). + +--- + +## How to add a new data point + +1. Make sure the SWC reference binding is fresh: + + ``` + cd "$QWIK_HOME" && pnpm build.platform + ``` + + This rebuilds `$QWIK_HOME/packages/optimizer/bindings/qwik..node`. Skip if the qwik checkout hasn't moved since your last run. + +2. From this repo: + + ``` + BENCH=1 pnpm vitest run tests/benchmark/optimizer-benchmark.test.ts --no-file-parallelism + ``` + +3. Both benchmarks will print `SWC time:`, `TS time:`, and `Ratio:` lines. Both will assertion-fail (the caps haven't been met) — that's expected; the goal is to capture the numbers, not pass. + +4. Prepend a row to each table below with: date, the merge SHA your numbers describe, a short workstream label, the three numbers, and any notes. + +5. If your run targeted perf, append a one-line entry to the **"Trend so far"** section explaining what moved. + +> **If the SWC binding was rebuilt against a different qwik commit since the last row,** note this in the Notes column. Both numerator and denominator change in that case, so SWC and TS times across rows are no longer apples-to-apples — only Ratio remains comparable. + +--- + +## Methodology caveats + +- **Numbers carry ~5–15% machine-state variance.** Running the same commit twice on the same machine produced 1465ms vs 1526ms on BENCH-01 (4% spread) and 91ms vs 96ms on BENCH-02 (5% spread). Don't read narrow row-to-row deltas as signal — only deltas that hold across multiple runs, or that exceed the variance band, mean anything. +- The reference SWC binding is rebuilt rarely; SWC times across rows are expected to be roughly constant at ~550ms for BENCH-01 and ~20ms for BENCH-02. **TS times are the meaningful axis.** +- Measurements are taken on `darwin-arm64` (Apple M-series). Other platforms will have different absolute timings — ratios should be roughly comparable. If you add a row from a different platform, mark it in the Notes column. +- The benchmark does _not_ isolate CPU governor, freeze interrupts, or pin to performance cores. It's a quick wall-time check, not a microbenchmark. For perf-targeted work, run the benchmark several times and take the minimum. + +--- + +## BENCH-01 — Full monorepo (~1391 files) + +| Date | Commit | Workstream | SWC ms | TS ms | Ratio | Notes | +| -------------- | ----------------------------------- | -------------------------------------------------------------------------------- | ------ | ----- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **2026-08-15** | **fc65b6d83** | **post-parity cleanup: unified DCE pipeline + one env-fold owner + dedup** | 5410 | 3266 | **0.60×** | New machine/nix env vs the June rows and the monorepo grew to 1800 files, so absolute times are not comparable to earlier rows; the SWC binding was rebuilt from this branch. TS is now _faster_ than the NAPI reference on the batch bench (NAPI serialization overhead dominates SWC's side). Both CI caps pass for the first time. | +| **2026-06-11** | **OSS-496 branch** | **Phase-1 extraction fused into the gather walk + marker prefilter** | 568 | 1346 | **2.42×** | Interleaved same-session pairs (3×): branch 1346–1368ms vs `main` 1370–1477ms — consistent direction every pair, **−1.7% min wall** (at the variance edge, not over-claimed). An intermediate fusion-only build measured **+5%** (non-trigger modules newly paying tracker + buffers); the marker prefilter + lazy lexical frames + zero-extraction classification/scope-entry skips clawed it back below `main`. Census: walks 1,999 → **826** (−58.7%); trigger + jsx-only at 1 walk/module, 565/783 passthrough modules skip the walk entirely. Pre-merge SHA. | +| **2026-06-11** | **OSS-495 branch** | **ScopeTracker builds during the gather walk (build walk deleted)** | 553 | 1343 | **2.43×** | Min of 3 (1343–1414ms, 2.43–2.59×). Same-session `main`: 1366–1401ms, 2.48–2.54× — bands overlap, **within variance** (spike projected ≈neutral: eliminated-walk savings ~28ms vs ~16ms new unconditional bookkeeping). The win is the census: total program walks 2,477 → 1,999 (−19.3%); trigger modules 3 → 2 walks (478 standalone build walks gone). Pre-merge SHA. | +| **2026-06-11** | **OSS-491 branch** | **Phase-0.5 flatten prefilter + lazy MagicString** | 563 | 1387 | **2.46×** | Min of 3 (1387–1456ms, 2.46–2.58×). Same-session `main`: 1410–1431ms, 2.51–2.53× — bands overlap, **within variance**. The win is the census: monorepo Phase-0.5 walks 1,391 → 485 (−65%), MagicString ctors 1,391 → 12 (−99%). Pre-merge SHA. | +| **2026-06-11** | **OSS-490 branch** | **MagicString churn: JSX write memo + lazy session edits + skip-range index** | 562 | 1428 | **2.54×** | Min of 3 (1428–1445ms, 2.54–2.62×). Same-session `main`: 1487–1496ms, 2.63–2.66× — bands don't overlap; **−4.0% TS wall**. Pre-merge SHA. | +| **2026-06-11** | **OSS-489 branch** | **session churn: combined field-map extractor + memo cap 16 + w-call prefilter** | 554 | 1454 | **2.62×** | Min of 3 (1454–1486ms, 2.62–2.72×). Same-session `main`: 1464–1495ms, 2.69–2.72× — bands overlap, **within variance** (session parses were ~5% of self-time). The win is the census: monorepo session parses 6,289 → 5,646 (−10.2%), sessions 13,428 → 12,502. | +| **2026-06-10** | **OSS-488 branch** | **scope-bindings walk fused into gather walk** | 553 | 1456 | **2.63×** | Min of 3 (1456–1568ms, 2.63–2.85×). Same-session `main`: 1474–1600ms, 2.69–2.91× — bands overlap, **within variance** (as the ticket predicted for one walk per module). The win is the census: standalone bindings walks 1,994 → 1,404 (−590, one per parent JSX module). Pre-merge SHA. | +| **2026-06-10** | **OSS-487 branch** | **perf micros: segment-usage sweep + wireMigration subtree walk + regex hoists** | 549 | 1482 | **2.69×** | Min of 3 (1482–1495ms, 2.69–2.72×). Same-session `main`: 1528–1609ms, 2.80–2.91× — −3% TS wall, at the variance edge; not claimed as a win. Pre-merge SHA. | +| **2026-06-10** | **OSS-486 step-2 branch** | **group-2 session threading (parse memo + canonical wrapper)** | 548 | 1623 | **2.96×** | First wall movement of the track: all 3 runs (2.94–3.00×) below the baseline band (3.04–3.08×). Parses −47% (18,109 → 9,572 mono). Pre-merge SHA. | +| **2026-06-10** | **OSS-486 step-1 branch (PR #261)** | **group-1 walk fusion (`computeClosureFreeIdentifiers`)** | 550 | 1684 | **3.06×** | Wall-neutral (min of 3: 1684–1718ms). Walk invocations −35% (30,344 → 19,824 mono); the eliminated walks were closure-subtree-sized, so count ≠ wall. Pre-merge SHA. | +| **2026-06-10** | **`6204de8`** | **post #257 — OSS-485 fix; Track D baseline** | 546 | 1674 | **3.06×** | First measurable BENCH-01 run since 2026-05-13 (OSS-485's whole-buffer-overwrite crash blocked it). Min of 3 runs (TS 1674–1687ms, ratio 3.04–3.08×). ~7% TS-wall growth vs the last row is accumulated feature-work cost — see "Update 2026-06-10". | +| **2026-05-13** | **`b792bf6`** | **PR #56 branch HEAD — post-OSS-365 + F8c crash fix** | 552 | 1568 | **2.83×** | code-health #2: shared AST across module-cleanup post-process pipeline + substring re-parse eliminated. Includes flatten-destructures crash fix. Pre-merge — SHA will change on squash. | +| 2026-05-13 | `1df6b8d` | post #54 — OSS-364 | 547 | 1576 | 2.88× | code-health #1: event-capture-promotion walks 5→2 via pre-collection. Crash fix applied for measurement (same fix as the b792bf6 row). | +| 2026-05-13 | `ee3be65` | post #52 — F8c / OSS-363 | 552 | 1549 | 2.80× | F8c: new `flatten-destructures.ts` Phase 0.5 step + rawProps gate + compound-destructure const classification. Crash fix applied for measurement (BENCH-01 crashes on plain HEAD due to the magic-string overwrite bug). | +| 2026-05-09 | `dbe32d2` | post #36 — pre-F1b/F8/code-health | 549 | 1475 | **2.69×** | post-Sub-C — refactor track v2 fully closed; orchestrator at 34 lines | +| 2026-05-09 | `e8a705b` | post #34 — OSS-357 (Sub-B) | 552 | 1485 | 2.69× | migration-wiring + nested call-sites + nested QRL decls extracted | +| 2026-05-09 | `42ef260` | post #32 — OSS-356 (Sub-A) | 548 | 1549 | 2.83× | Prep + inline-strategy + shared rawProps helper extracted (single noisy run; Sub-B + Sub-C re-stabilise) | +| 2026-05-09 | `4673e07` | post #27 — pre-track-v2 baseline | 550 | 1465 | 2.66× | post-OSS-355 + post-merge-routine codification | +| 2026-05-09 | `854f04b` | post #23 — OSS-354 | 557 | 1514 | 2.72× | closure-form `resolveConstLiterals` + prod-rename sync | +| 2026-05-08 | `29a439e` | post #22 — OSS-353 | 582 | 1571 | 2.70× | closure-node threading; per-extraction body re-parse dropped | +| 2026-05-08 | `3b8eac6` | post #18 — OSS-350 | 574 | 1589 | 2.77× | `preParsedModule` plumbing — single shared parse | +| 2026-05-08 | `b903652` | post #14 — OSS-346 | 571 | 1618 | 2.83× | `generateSegmentCode` 9-phase sequencer extracted | +| 2026-05-07 | `d3f4387` | post #11 — OSS-340 | 575 | 1567 | 2.72× | refactor v1 close — predicates module | +| 2026-05-07 | `dd450a6` | post #7 — OSS-341 | 574 | 1572 | 2.74× | CI infrastructure landed | +| 2026-05-07 | `e98cbff` | post #5 — F1 fix | 572 | 1635 | 2.86× | first TS optimizer code change — `_ref` indirection | +| 2026-05-06 | `d3226c3` | pre-code baseline | 553 | 1558 | 2.82× | "Group All Convergence Failures" — last commit before code work | + +## BENCH-02 — Worst-case single file (`component.spec.tsx`, 3860 lines) + +| Date | Commit | Workstream | SWC ms | TS ms | Ratio | Notes | +| -------------- | ----------------------------------- | -------------------------------------------------------------------------------- | ------ | ----- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **2026-08-15** | **fc65b6d83** | **post-parity cleanup: unified DCE pipeline + one env-fold owner + dedup** | 124 | 133 | **1.07×** | Same environment caveat as the BENCH-01 row. Cap passes. | +| **2026-06-11** | **OSS-496 branch** | **Phase-1 extraction fused into the gather walk + marker prefilter** | 19 | 65 | **3.28×** | Interleaved same-session pairs (3×): branch 65–73ms vs `main` 67–69ms — bands overlap, **within variance** (the worst-case file is a single trigger module: its two walks become one, but handler work dominates traversal on one file). Pre-merge SHA. | +| **2026-06-11** | **OSS-495 branch** | **ScopeTracker builds during the gather walk (build walk deleted)** | 19 | 63 | **3.21×** | Min of 3 (63–71ms, 3.21–3.63×). Same-session `main`: 66–69ms, 3.31–3.47× — bands overlap, **within variance** (one eliminated walk on a single trigger file is below the noise floor). Pre-merge SHA. | +| **2026-06-11** | **OSS-491 branch** | **Phase-0.5 flatten prefilter + lazy MagicString** | 20 | 64 | **3.20×** | Min of 3 (64–67ms, 3.20–3.30×). Same-session `main`: 65–68ms, 3.33–3.42× — bands overlap, **within variance**, as the ticket predicted (the worst-case file is `component$`-dense, so its walk survives the prefilter; only the per-module ctor is saved). 30-iter harness identical (2132 vs 2124ms). Pre-merge SHA. | +| **2026-06-11** | **OSS-490 branch** | **MagicString churn: JSX write memo + lazy session edits + skip-range index** | 19 | 64 | **3.28×** | Min of 3 (64–66ms, 3.28–3.42×). Same-session `main`: 74–78ms, 3.93–3.99× — bands clearly separate; **−13.5% min wall**, matching the ~13% combined self-time the profile attributed to the three fixed sites. 30-iter harness: 2375 → 2108ms (−11.2%). Pre-merge SHA. | +| **2026-06-11** | **OSS-489 branch** | **session churn: combined field-map extractor + memo cap 16 + w-call prefilter** | 21 | 72 | **3.48×** | Min of 3 (72–76ms, 3.48–4.01×). Same-session `main`: 75ms ×3 (3.78–3.95×) — bands overlap, **within variance**. Worst-case file: session misses 487 → 417 (−14%), cap-eviction waste 80 → 10; the residual 407 are one-shot parses of genuinely distinct text versions (the architecture's floor without batching). | +| **2026-06-10** | **OSS-488 branch** | **scope-bindings walk fused into gather walk** | 20 | 75 | **3.72×** | Min of 3 (75–79ms, 3.72–4.31×). Same-session `main`: 76–82ms, 3.99–4.14× — bands overlap, **within variance**, expected: the per-segment bindings walks are untouched (each runs once per body; no duplication to eliminate — see OSS-488's segment-side verdict). | +| **2026-06-10** | **OSS-487 branch** | **perf micros: segment-usage sweep + wireMigration subtree walk + regex hoists** | 20 | 75 | **3.83×** | Min of 3 (75–79ms, 3.83–4.09×). Same-session `main`: 80–84ms, 4.29–4.38× — bands don't overlap; −6% min wall matches the 2026-06-10 profile's combined inclusive share for the two fixed sites on this shape (segment-usage 4.2% + wireMigration 2.3%). | +| **2026-06-10** | **OSS-486 step-2 branch** | **group-2 session threading (parse memo + canonical wrapper)** | 20 | 87 | **4.34×** | Parses −50% on this file (1,191 → 596). 30-iteration harness: 2,842 → 2,668ms (−6%; −9% cumulative from the Track D baseline). | +| **2026-06-10** | **OSS-486 step-1 branch (PR #261)** | **group-1 walk fusion (`computeClosureFreeIdentifiers`)** | 20 | 90 | **4.62×** | Wall-neutral (min of 3: 90–96ms; 30-iteration harness shows ~−3%, at the noise edge). Walk invocations −52% on this file (1,706 → 812). Pre-merge SHA. | +| **2026-06-10** | **`6204de8`** | **post #257 — OSS-485 fix; Track D baseline** | 20 | 89 | **4.56×** | Min of 3 runs (TS 89–92ms, ratio 4.48–4.60×). Slightly below the 2026-05-13 row (96ms) — within the historical 91–102ms band. | +| **2026-05-13** | **`b792bf6`** | **PR #56 branch HEAD — post-OSS-365 + F8c crash fix** | 19 | 96 | **5.11×** | code-health #2. Pre-merge — SHA will change on squash. | +| 2026-05-13 | `1df6b8d` | post #54 — OSS-364 | 20 | 97 | 5.02× | code-health #1: event-capture-promotion walks 5→2. | +| 2026-05-13 | `ee3be65` | post #52 — F8c / OSS-363 | 19 | 97 | 5.03× | F8c destructure-flattening landed. | +| 2026-05-09 | `dbe32d2` | post #36 — pre-F1b/F8/code-health | 19 | 94 | **4.87×** | post-Sub-C — refactor track v2 fully closed | +| 2026-05-09 | `e8a705b` | post #34 — OSS-357 (Sub-B) | 19 | 91 | 4.72× | migration-wiring + nested call-sites + nested QRL decls extracted | +| 2026-05-09 | `42ef260` | post #32 — OSS-356 (Sub-A) | 19 | 95 | 4.88× | Prep + inline-strategy + shared rawProps helper extracted | +| 2026-05-09 | `4673e07` | post #27 — pre-track-v2 baseline | 19 | 91 | 4.67× | post-OSS-355 + post-merge-routine codification | +| 2026-05-09 | `854f04b` | post #23 — OSS-354 | 20 | 93 | 4.65× | closure-form `resolveConstLiterals` + prod-rename sync | +| 2026-05-08 | `29a439e` | post #22 — OSS-353 | 20 | 102 | 5.09× | closure-node threading; per-extraction body re-parse dropped | +| 2026-05-08 | `3b8eac6` | post #18 — OSS-350 | 20 | 98 | 4.87× | `preParsedModule` plumbing | +| 2026-05-08 | `b903652` | post #14 — OSS-346 | 19 | 102 | 5.27× | `generateSegmentCode` sequencer extracted | +| 2026-05-07 | `d3f4387` | post #11 — OSS-340 | 20 | 98 | 5.03× | refactor v1 close — predicates module | +| 2026-05-07 | `dd450a6` | post #7 — OSS-341 | 20 | 101 | 5.15× | CI infrastructure landed | +| 2026-05-07 | `e98cbff` | post #5 — F1 fix | 21 | 102 | 4.93× | first code change — `_ref` indirection | +| 2026-05-06 | `d3226c3` | pre-code baseline | 20 | 95 | 4.75× | pre-code baseline | + +--- + +## Visual trend + +Both charts plot **TS / SWC ratio** (the dimensionless regression signal — lower is better) across the same commits the tables above describe, oldest → newest. The y-axes are intentionally narrow so within-noise movement is visible; widening them to start at 0 would flatten the trend and hide the ~10% spread. + +### BENCH-01 ratio over time + +```mermaid +xychart-beta + title "BENCH-01 — TS / SWC ratio (lower is better; CI cap = 1.15×)" + x-axis ["d3226c3", "e98cbff", "dd450a6", "d3f4387", "b903652", "3b8eac6", "29a439e", "854f04b", "4673e07", "42ef260", "e8a705b", "dbe32d2", "ee3be65", "1df6b8d", "b792bf6", "6204de8"] + y-axis "Ratio (×)" 2.5 --> 3.2 + line [2.82, 2.86, 2.74, 2.72, 2.83, 2.77, 2.70, 2.72, 2.66, 2.83, 2.69, 2.69, 2.80, 2.88, 2.83, 3.06] +``` + +### BENCH-02 ratio over time + +```mermaid +xychart-beta + title "BENCH-02 — TS / SWC ratio (lower is better; CI cap = 1.5×)" + x-axis ["d3226c3", "e98cbff", "dd450a6", "d3f4387", "b903652", "3b8eac6", "29a439e", "854f04b", "4673e07", "42ef260", "e8a705b", "dbe32d2", "ee3be65", "1df6b8d", "b792bf6", "6204de8"] + y-axis "Ratio (×)" 4.4 --> 5.5 + line [4.75, 4.93, 5.15, 5.03, 5.27, 4.87, 5.09, 4.65, 4.67, 4.88, 4.72, 4.87, 5.03, 5.02, 5.11, 4.56] +``` + +> The CI caps (1.15× and 1.5×) sit well below the visible y-axis ranges and aren't drawn. Mermaid's `xychart-beta` doesn't support reference lines — caps stay textual in each chart's title. The tables above remain the source of truth; the charts are a visual aid. + +--- + +## Trend so far + +**Over the 4 days from 2026-05-06 → 2026-05-09 the TS optimizer is unchanged in perf within noise.** Refactor track v2 (OSS-343) closed in this window — `generateAllSegmentModules` went from 580 lines to a 34-line orchestrator over named helpers, ~94% reduction — and the four final ratios (pre-Sub-A, post-Sub-A, post-Sub-B, post-Sub-C) all sit inside the variance band: + +- **BENCH-01 TS time** moves between 1465 and 1635 ms across all 12 rows (~10% spread). The four track-v2 boundary rows are 1465 / 1549 / 1485 / 1475 — Sub-A's 1549 reading is a single noisy run; Sub-B and Sub-C come back down to the pre-track baseline. +- **BENCH-02 TS time** moves between 91 and 102 ms across all 12 rows (~10% spread). The four track-v2 boundary rows are 91 / 95 / 91 / 94 — same pattern. +- **Ratios** (TS ÷ SWC) cluster at **~2.7×** for BENCH-01 and **~4.8×** for BENCH-02 — nowhere near the 1.15× and 1.5× CI caps. Track v2's start (4673e07) and end (dbe32d2) ratios are 2.66× → 2.69× and 4.67× → 4.87× — both inside noise. + +**The refactor track was explicitly not perf-targeted.** Its goal was code-quality / structural cleanup to make subsequent feature _and_ perf work cheaper. The flat-within-noise outcome is the expected outcome; if track v2 had moved either ratio meaningfully, it would have been a surprise (and worth investigating which extraction caused it). + +What track v2 _did_ do for future perf work: + +- **Named helpers expose seams for profiling.** Pre-track, `generateAllSegmentModules` was 580 lines of inline phases; post-track, it's six named helpers with documented mutation surfaces. Profiling can target a specific helper rather than narrowing into an opaque mega-function. +- **The immutable `SegmentGenerationPrep` record** confines per-call setup costs to one well-defined block; cache-line / memory-layout tweaks have a single owner. +- **Two backlog candidates surfaced** (eliminate per-iteration `ext` mutation; split the 28-field `SegmentGenerationContext`) that _could_ unlock perf wins by enabling structural sharing or reducing destructure overhead. Neither has been measured. + +To meaningfully move the ratios: + +- BENCH-02 (worst-case file) is dominated by per-extraction work — that's where AST-walking optimizations like OSS-353's body-reparse drop should show up most. The numbers hint at this; profiling would confirm. +- BENCH-01 is dominated by file-discovery + parsing across the batch. Throughput here is more about the parse → walk → emit cycle than any single phase. + +When perf-targeted tickets get filed, link them here and add a row before/after each one to make the impact visible. + +### Update 2026-05-13 — F8c + code-health pass + +Three new rows added today isolating F8c ([OSS-363](https://linear.app/kunai/issue/OSS-363), PR #52), OSS-364 (PR #54), OSS-365 + flatten-destructures crash fix (PR #56 branch HEAD). All measurements taken on the same machine, same SWC binding, in sequence. + +| Boundary | BENCH-01 ratio | BENCH-02 ratio | +| ---------------------------------------- | -------------- | -------------- | +| `dbe32d2` (pre-F8c baseline, 2026-05-09) | 2.69× | 4.87× | +| `ee3be65` (post-F8c) | 2.80× | 5.03× | +| `1df6b8d` (post-OSS-364, walks 5→2) | 2.88× | 5.02× | +| `b792bf6` (post-OSS-365, parse-sharing) | 2.83× | 5.11× | + +**Takeaways:** + +1. **F8c added ~4% to BENCH-01** (2.69× → 2.80×). The new `flatten-destructures.ts` Phase 0.5 step is a real cost: one extra `walk(program, ...)` to identify candidate decls and (when changes apply) a re-parse of the rewritten source. F8c was a parity fix, not perf-targeted; this overhead is expected. +2. **OSS-364 and OSS-365 (the code-health pass) had no measurable effect.** Both PRs reduced redundant walks / parses (5→2 in `event-capture-promotion`, 4→1 in the `module-cleanup` post-process), matching the rule's "parse once, walk once" intent — but the per-walk/per-parse cost is dwarfed by the rest of the pipeline. Final ratio 2.83× is inside the variance band relative to the 2.80× post-F8c row (3 runs each: 2.79–2.88 vs 2.79–2.88 vs 2.83–2.86 — all overlapping). The work was a code-quality investment, not a throughput win. +3. **BENCH-02 (worst-case single file) is essentially flat across all four points.** The savings from the code-health PRs don't show up here because the worst-case file doesn't exercise the redundant-walk code paths in `event-capture-promotion` or the post-process pipeline as heavily as the full monorepo does. +4. **Crash discovered.** Running BENCH-01 on the post-F8c commit (`ee3be65`) crashed in `flatten-destructures.ts` with `Cannot split a chunk that has already been edited (45:10 – "{ url }")` — a real bug that convergence tests missed because the failing shape (two flattenable decls in the same scope, second decl's pattern containing an Identifier whose name matches the first's substitution) wasn't in the snapshot corpus. Fix bundled in PR #56 (commit `b792bf6`); regression test pinned in `tests/optimizer/flatten-destructures.test.ts`. **The benchmark suite caught a correctness bug, not just a perf number** — useful precedent for adding benchmark-style runs to CI on real source corpora. + +### Update 2026-06-10 — Track D baseline + CPU profile + +First rows in ~4 weeks. BENCH-01 was **unmeasurable** for the whole gap: it crashed on `repl-console.tsx` from the moment the trigger shape existed in the corpus ("Cannot use replaced character … as slice start anchor" — the `applySignalHoistRenames` whole-buffer overwrite, fixed by OSS-485 / PR #257). Second time the benchmark corpus caught a correctness bug the convergence snapshots missed (first: the flatten-destructures crash above). + +Numbers at `6204de8` (min of 3 sequential runs, same machine, same SWC binding — SWC times still in the ~550ms / ~20ms bands, so rows remain comparable): + +| | SWC ms | TS ms | Ratio | vs last row (2026-05-13) | +| -------- | ------ | ----- | ----- | ----------------------------------------------------------- | +| BENCH-01 | 546 | 1674 | 3.06× | TS +7% (1568 → 1674); above the historical 1465–1635ms band | +| BENCH-02 | 20 | 89 | 4.56× | TS −7% (96 → 89); within the historical 91–102ms band | + +**The BENCH-01 growth is real, not noise** — all 4 runs (3.04–3.08×) sit above every prior row's ratio. It's the accumulated cost of a month of parity/feature work (F2/F5/F6/F9/F10 closures, router parity incl. the `_jsxDEV` Property-extraction branch, `repairInput` probe-parsing, q:p walks), none of it perf-targeted. BENCH-02's slight improvement is consistent with the hygiene refactors trimming per-extraction overhead on the worst-case file. + +**CPU profile (inclusive on-stack %, V8 sampling via `--cpu-prof` over the built dist, 30 worst-case iterations / 2 monorepo passes):** + +| Phase / cost center | BENCH-02 shape | BENCH-01 shape | +| ----------------------------------------- | -------------- | -------------- | +| `generateSegments` (Phase 5) | 37.2% | 35.5% | +| `rewriteParent` (Phase 4) | 28.5% | 16.9% | +| — `transformAllJsx` (both callers) | 20.2% | 10.3% | +| `analyzeModuleCaptures` (Phase 2) | 10.3% | 10.1% | +| `extractModuleSegments` (Phase 1) | 8.0% | 16.7% | +| `prepareModuleInput` (Phase 0/0.5) | 2.9% | 8.7% | +| **`parseWithRawTransfer` (all parses)** | **13.4%** | **16.5%** | +| **`_walk` (oxc-walker, all walks)** | **48.0%** | **40.9%** | +| `computeSegmentUsage` (catalogued D1) | 4.2% | 2.0% | +| `wireMigration` (catalogued D2b) | 2.3% | 0.6% | +| `countJsxKeyConsumption` (catalogued D2a) | 1.6% | 1.8% | + +**Group-1 walk fusion (OSS-486 step 1, same day):** replacing the per-closure `getUndeclaredIdentifiersInFunction` calls in capture analysis, C02 diagnostics, and event-handler capture promotion with one module-wide `computeClosureFreeIdentifiers` map cut walk _invocations_ by 52% on the worst-case file and 35% on the monorepo — but wall time stayed inside the variance band (~−3% at best on the 30-iteration harness). Lesson recorded: invocation count was a misleading proxy; the eliminated walks were closure-subtree-sized while the heavy traversals (program walks, JSX walks, per-segment text re-parses) remain. The wall expectations for OSS-486 sit with the per-segment parse consolidation (group 2) and the per-module walk fusion (group 3). + +**Group-2 session threading (OSS-486 step 2, same day):** unified every body-text edit/analysis helper onto the canonical `TransformSession` wrapper and added a last-4 parse memo keyed by the exact wrapped source, so consecutive helpers operating on the same body-text version share one parse. Parses −47–50% in both shapes; **first wall movement of the track** — BENCH-01 2.96× (all runs below the baseline band), BENCH-02 87ms min, 30-iteration worst-case harness −9% cumulative from the Track D baseline. The `countJsxKeyConsumption` pre-count also stopped re-parsing (walks the Phase-1 closure node), closing one of the catalogued micro items below. One behavior split made explicit along the way: previously-direct-parse analysis sites tolerate recoverable parse errors (`tolerateErrors`), edit-applying sites stay strict — pinned by unit test after a strip-mode fixture caught the difference. + +**Group-3 gather-walk fusion (OSS-486 step 3, same day):** the canonical per-module gather walk (`analysis/module-gather-walk.ts`) folds the five remaining per-module full-program walks — lexical scope chains, extraction loop map, scope entries, segment usage, passive-conflict detection — into the group-1 free-identifier walk (ScopeTracker build + one gather traversal; 7 program walks → 2 per extraction module), and the peer-tool jsx-call-transform drops from four program walks to two (gather + act). Census with identical chokepoint counters on both sides: monorepo walks **19,836 → 16,920 (−14.7%)**, worst-case file 812 → 801 (that shape's walks are dominated by per-segment body walks); parses unchanged, as expected for a walk-only step. Wall (same-session min-of-3 vs `main`): **BENCH-01 2.96× → 2.71×** (TS 1630 → 1519ms, −6.8%; main's three runs 2.91–2.97× all sit above the branch's 2.71–2.90×, so the movement is outside the overlap); BENCH-02 min 89 → 83ms with overlapping ratio bands (4.45–4.51 vs 4.27–4.58) — within variance, consistent with the single-module shape barely losing walks. Replaced walk functions are retained as differential oracles, pinned by a per-projection corpus parity test. + +**Profile verdict on the catalogued Track D items:** D1 + D2 + D3 combined have a ceiling under ~8% in either shape — not needle-movers at the current ratios (the roadmap's "profile to confirm" caveat confirmed in the negative). What actually dominates is structural: AST walking is on-stack for 40–48% of wall (many separate full-program walks per module across phases), and parsing is 13–17% (probe/repair parses, flatten re-parse, per-segment body parses, post-process transform parses). Perf-targeted work should aim there; D1 remains worth fixing eventually for its quadratic blowup _risk_ (it's input-shape-sensitive), not for its current cost. + +**OSS-489 session-churn step 1 (2026-06-11):** the churn census (per-site session counts with memo hit/miss and seen-text split) found 487 worst-case session parses decomposing into 407 one-shots (first parse of a genuinely new text version) and 80 cap-eviction re-parses — 60 of those from `extractDestructuredFieldDefaultsMap` re-parsing exactly the bodies its sibling `extractDestructuredFieldMap` had parsed one full loop earlier. Landed: a combined `extractDestructuredFieldInfo` (both projections from one parse; the two old extractors survive as thin wrappers), parse-memo cap 4 → 16 (the 4-cap thrashed under interleaved parent/nested pipelines), and a sound textual prefilter on `consolidateRawPropsInWCalls` (bare `_rawProps` + `.w(` — the dotted form would wrongly gate out computed members). Census: monorepo session parses **6,289 → 5,646 (−10.2%)**; worst-case misses 487 → 417, waste 80 → 10. Wall within variance both shapes, as expected at ~5% parse self-time. The 407 one-shot floor is the architecture's: each committed edit produces a new text version needing one parse — reducing it means batching transforms onto shared sessions (ticket stage 2, deferred pending appetite: correctness-bearing edit ordering for a bounded win). A `simplifyExpression` prefilter was evaluated and rejected — folds trigger on `true`/`false`/`null`/parenthesized operand shapes with no sound textual gate. + +**OSS-488 scope-bindings fusion (same day):** `transformAllJsx`'s standalone `collectScopeAwareBindings` pre-walk became a projection of the canonical gather walk on the parent path (per-node logic shared via `createScopeBindingsCollector`; standalone walk retained as the differential oracle, compared on the consumer contract — `classify()` at every identifier position). Census: standalone bindings walks **1,994 → 1,404 (−590)** on the monorepo pass — one per parent JSX module. Wall: within variance on both shapes (same-session bands overlap), as the ticket predicted for a one-walk-per-module reduction. Segment-side memo investigated and dropped: each segment/inline body collects bindings exactly once, so a per-text-version memo has nothing to dedupe. + +**OSS-487 micros (same day):** the catalogued principle/risk fixes landed — the segment-usage projection's per-visit scan over every extraction (the quadratic the gather walk inherited from `computeSegmentUsage`) replaced by a sorted range-stack sweep in `classifySegmentUsage`, `wireMigration`'s per-move-decision full-program walk narrowed to the decl's enclosing top-level statement, `ifBracelessPattern` hoisted out of the DCE iteration loop, and the sCall-placement `\b\b` testers cached. Same-session A/B vs `main`: **BENCH-02 −6% min wall** (80 → 75ms; ratio bands 4.29–4.38 vs 3.83–4.09, outside overlap) — consistent with the profile's combined 4.2% + 2.3% inclusive share for the two fixed sites on this shape; BENCH-01 −3% (1528 → 1482ms), at the variance edge, not claimed. The `countJsxKeyConsumption` re-parse item was already closed by group 2; the quadratic fix is bounded-behavior-pinned by a Proxy-counted unit test (200-extraction synthetic input: ~7k range reads vs ~600k+ for the per-visit scan). + +**OSS-491 Phase-0.5 flatten prefilter (2026-06-11):** the last unfiled OSS-486 census deferral closed. `flattenDestructureUseCalls` ran one full program walk + an eager `MagicString` construction per module, but can only act on modules containing a _literal_ `component$` callee (the name check is exact; renamed imports never flattened) — so a bare `source.includes('component$')` gate is exactly sound, and the MagicString now materializes on first overwrite. Census: monorepo Phase-0.5 walks **1,391 → 485 (−65%)**, ctors **1,391 → 12 (−99%)**; the worst-case file keeps its walk (component$-dense) but drops the ctor. Wall: **within variance both shapes** (same-session bands overlap; 30-iter harness identical) — recorded honestly: the flatten walk's per-node work is two type checks and the saved ctors are mostly small files. Prefilter soundness pinned by renamed-import + token-in-comment tests. + +**OSS-496 extraction fusion + marker prefilter (2026-06-11, closes the OSS-492 arc):** the Phase-1 extraction walk became a composable collector hosted in the canonical gather walk — one program traversal per module produces the extraction set and every gathered fact; the Phase-1/2 boundary in `transformOneModule` merged. The fusion-only intermediate build cost **+5% BENCH-01 wall** (783 passthrough modules newly paying tracker bookkeeping, identifier buffering, and per-node projection dispatch they never had under the lean extract walk) — clawed back below `main` by three gates: a sound marker prefilter (`sourceMayContainMarkers`: a token-final `$` is never followed by `{` in valid syntax, so template-only modules skip the walk _entirely_), lazy lexical-scope frame materialization, and zero-extraction skips for usage classification + scope-entry building. Final census: monorepo program walks **1,999 → 826 (−58.7% this step; 2,477 → 826, −66.6% across the arc — beating the umbrella's −43.8% full-fusion projection because the prefilter takes most passthrough modules to zero walks)**. Wall: BENCH-01 **−1.7% min** with consistent direction across 3 interleaved same-session pairs (variance edge, not over-claimed); BENCH-02 within variance. Standalone `extractSegments` retained as the differential oracle, pinned by a corpus parity test over every `ExtractionResult` field + `closureNodes` identity × three flag combos. + +**OSS-490 MagicString churn (2026-06-11):** the post-OSS-489 profile's three MagicString cost centers fixed — (1) `processOneChild` and the prop-value slicers no longer pay `MagicString.slice`'s chunk-list walk to read back already-rewritten JSX subtrees: `writeJsxCall` records every overwrite in an exact-range memo (`start → {end, content}`) that `sliceTransformed` consults first (sound because the walk writes bottom-up through a single write path — an exact-range hit is the last edit inside that range); (2) `TransformSession.edits` constructs its MagicString lazily on first access, so read-only sessions skip it and unedited `toSource()` short-circuits to the original text; (3) `transformAllJsx` builds a sorted contained-range-free skip-range index once and `isInSkipRange` does one binary-search probe per node (differential unit test pins parity with the linear scan over nested/overlapping/duplicate sets). After-profile (same methodology, built dist, 30 worst-case iterations): `MagicString.slice` **8.1% → 2.6%** self, `MagicString` ctor **4.0% → 1.0%**, `isInSkipRange` **2.5% → 0.2%**. Wall (same-session min-of-3 vs `main`): **BENCH-02 74 → 64ms (−13.5%)**, bands clearly separate, matching the addressed self-time share; **BENCH-01 1487 → 1428ms (−4.0%)**, bands non-overlapping; 30-iter worst-case harness 2375 → 2108ms (−11.2%). + +--- + +## Hardware / environment context + +| | | +| ------------- | ------------------------------------------------------------------------------------------------------------- | +| Hardware | Apple M-series (`darwin-arm64`) | +| Node | ≥22 (per `package.json` `engines.node`) | +| pnpm | v10.x | +| SWC binding | `qwik_napi` v0.1.0 / `qwik-core` v2.0.0, `release` profile, built via `pnpm build.platform` from `$QWIK_HOME` | +| Qwik checkout | `$QWIK_HOME` ([`.claude/rules/GENERAL.md`](.claude/rules/GENERAL.md)) | + +Future rows from different hardware should mark the platform in the Notes column. If the SWC binding has been rebuilt against a different qwik commit, also note that — only the Ratio column remains comparable across rebuilds. + +--- + +## Methodology — backfilling history + +This doc was bootstrapped on 2026-05-09 by checking out 9 historical commits in an isolated `git worktree`, replacing each commit's `tests/benchmark/optimizer-benchmark.test.ts` with the current portable version (older versions of the file had hard-coded paths and a different env-var contract that wouldn't run on a fresh machine), running `pnpm install --frozen-lockfile` per commit, and capturing the numbers. + +The same procedure works for any future backfill. Don't try to backfill commits older than `d3226c3` — earlier commits predate the convergence-failures grouping work and the optimizer surface may diverge. diff --git a/packages/ts-optimizer/DESIGN.md b/packages/ts-optimizer/DESIGN.md new file mode 100644 index 00000000000..1f1d900bacb --- /dev/null +++ b/packages/ts-optimizer/DESIGN.md @@ -0,0 +1,3 @@ +The idea is that we should hold operations performed in a virtual representation, then at the end we do a write with magic string. This allows us to hold all the AST information and only need to parse once. + +We should try to avoid cases where we have to reparse or do strange cases of looping over characters and words to make diffs. On the other hand, we should avoid using a printer approach with codegen that is exhaustive and leads to complexity and difficult changes. diff --git a/packages/ts-optimizer/LICENSE b/packages/ts-optimizer/LICENSE new file mode 100644 index 00000000000..5a8a0e25c4f --- /dev/null +++ b/packages/ts-optimizer/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 QwikDev +Copyright (c) 2021 BuilderIO + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ts-optimizer/README.md b/packages/ts-optimizer/README.md new file mode 100644 index 00000000000..861268aa2dc --- /dev/null +++ b/packages/ts-optimizer/README.md @@ -0,0 +1,170 @@ +# @qwik.dev/ts-optimizer + +A TypeScript implementation of the [Qwik](https://qwik.dev) optimizer. + +It takes a Qwik source file, finds every `$()` closure, lifts each one into its +own lazy-loadable module, and rewrites the original file so the closures become +`qrl(() => import(...))` references — the runtime then loads each chunk only +when the user actually triggers it. + +> **Status: experimental.** This is a from-scratch TypeScript port of the Rust +> SWC optimizer that ships with Qwik core, built on [OXC](https://oxc.rs). It +> is measured against the reference optimizer's live snapshot suite +> (see `tests/README.md`; comparisons read the Rust snapshots in place). +> APIs may change before a 1.0. + +## Why + +The optimizer is the heart of Qwik's resumability model: every `$()` boundary +becomes a separately loadable chunk, so the browser downloads code only as +interaction demands it. This package +makes that transform available as a pure-TypeScript, [OXC](https://oxc.rs)-based +library — no native SWC binding required for the transform logic itself (the +parser/transformer it builds on, `oxc-parser` / `oxc-transform`, do ship native +bindings). + +## Install + +```sh +npm install @qwik.dev/ts-optimizer +# or +pnpm add @qwik.dev/ts-optimizer +``` + +**Requires Node `>=22`** — `oxc-parser`'s raw-transfer path throws on Node 20. +ESM-only. + +## Usage + +There are two entry points: a synchronous core (`transformModule`) and an +async, SWC-NAPI-compatible factory (`createOptimizer`) for drop-in use inside a +bundler. + +### `transformModule` — synchronous core + +```ts +import { transformModule } from '@qwik.dev/ts-optimizer'; + +const result = transformModule({ + input: [ + { + path: 'components/counter.tsx', + code: ` + import { component$, useSignal } from '@qwik.dev/core'; + export const Counter = component$(() => { + const count = useSignal(0); + return ; + }); + `, + }, + ], + srcDir: '/app/src/', + entryStrategy: { type: 'segment' }, + minify: 'simplify', +}); + +for (const mod of result.modules) { + console.log(mod.path, mod.kind); // 'parent' | 'segment' + // mod.code — emitted source + // mod.segment — segment metadata (name, hash, ctxKind, captures, …) on segments +} + +console.log(result.diagnostics); // [] when clean +``` + +Each input file is transformed independently into **one parent module** (the +original file rewritten so its `$()` calls become `qrl(...)` references) plus +**zero or more segment modules** (each lifted closure body as its own +lazy-loadable file). + +### `createOptimizer` — async, bundler-facing + +Mirrors the surface of Qwik's SWC optimizer (`@qwik.dev/optimizer`'s +`createOptimizer().transformModules(...)`), so it can be swapped in by a bundler +adapter. Takes and returns plain strings (no branded types at the boundary): + +```ts +import { createOptimizer } from '@qwik.dev/ts-optimizer'; + +const optimizer = createOptimizer(); +const output = await optimizer.transformModules({ + srcDir: '/app/src', + input: [{ path: 'components/counter.tsx', code: source }], + entryStrategy: { type: 'smart' }, +}); +``` + +## What it emits — before / after + +**Input** (`test.tsx`): + +```tsx +import { $, component } from '@qwik.dev/core'; + +export const renderHeader = $(() => { + return
console.log(ctx))} />; +}); +``` + +**Output** — a rewritten parent plus one segment per `$()` body: + +```ts +// test.tsx (parent — closures replaced by lazy QRL references) +import { qrl } from '@qwik.dev/core'; +const q_renderHeader_jMxQsjbyDss = /*#__PURE__*/ qrl( + () => import('./test.tsx_renderHeader_jMxQsjbyDss'), + 'renderHeader_jMxQsjbyDss' +); +export const renderHeader = q_renderHeader_jMxQsjbyDss; +``` + +```ts +// test.tsx_renderHeader_jMxQsjbyDss.tsx (segment — the lifted body) +export const renderHeader_jMxQsjbyDss = () => { + return
; +}; +``` + +The nested `onClick` closure is lifted again into its own leaf segment, loaded +only after the user clicks. That granularity — each `$()` boundary a separate +chunk — is the whole point. + +## How it works + +`transformModule` runs each file through a fixed pipeline: + +| Phase | Does | +| ------------------------- | --------------------------------------------------------------------------------------------------- | +| **0 — Prepare** | Parse with OXC and flatten destructures | +| **1 — Extract** | Walk the AST, find every `$(...)` / marker call, capture each closure body + naming context | +| **2 — Captures** | Determine which outer-scope variables each closure closes over | +| **3 — Migrate** | Decide where each module-level binding lives: stay in the parent, move into a segment, or re-export | +| **4 — Rewrite parent** | Replace each `$(closure)` with a generated `qrl(...)` reference; apply migration | +| **5 — Generate segments** | Emit one lazy-loadable module per extracted closure | +| **6 — Post-process** | TypeScript strip, dead-code elimination, unused-import cleanup | + +Symbol names are content-addressed: a closure's exported name is composed from +its call-site context (`renderHeader_div_onClick`) plus an 11-character +SipHash-1-3 suffix, so the same source always produces the same chunk names the +runtime will fetch. + +The marker family the optimizer recognizes (`component$`, `useTask$`, +`useStyles$`, `useVisibleTask$`, `server$`, event handlers like `onClick$`, …) +is detected **structurally** — any call whose callee's imported name ends in `$` +extracts — so library-defined `name$` functions work automatically. + +## Public API + +| Export | Kind | Purpose | +| ------------------------------------------------------------------ | -------- | ---------------------------------------------- | +| `transformModule(options)` | function | Synchronous core transform → `TransformOutput` | +| `createOptimizer()` | function | Async, SWC-NAPI-compatible optimizer instance | +| `TransformModulesOptions`, `TransformOutput`, `TransformModule`, … | types | Input/output contracts | +| `SymbolName`, `Hash`, `RelativePath`, … | types | Branded output metadata types | + +All other modules are internal and not part of the public contract. + +## License + +[MIT](./LICENSE) — matching [Qwik](https://github.com/QwikDev/qwik) core, of +which this is a derivative. Copyright © QwikDev and BuilderIO. diff --git a/packages/ts-optimizer/package.json b/packages/ts-optimizer/package.json new file mode 100644 index 00000000000..2c4d0d98415 --- /dev/null +++ b/packages/ts-optimizer/package.json @@ -0,0 +1,57 @@ +{ + "name": "@qwik.dev/ts-optimizer", + "description": "A TypeScript implementation of the Qwik optimizer.", + "version": "0.0.1", + "bugs": "https://github.com/QwikDev/qwik/issues", + "dependencies": { + "@oxc-project/types": "0.144.0", + "entities": "7.0.1", + "magic-string": "^0.30.21", + "oxc-parser": "^0.144.0", + "oxc-transform": "^0.144.0", + "oxc-walker": "^1.1.1", + "pathe": "^2.0.3", + "siphash": "^1.2.0" + }, + "devDependencies": { + "@types/node": "25.6.0", + "tsdown": "0.22.3", + "typescript": "5.9.3", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=22" + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "homepage": "https://github.com/QwikDev/qwik/tree/main/packages/ts-optimizer#readme", + "keywords": [ + "ast", + "bundler", + "optimizer", + "oxc", + "qrl", + "qwik" + ], + "license": "MIT", + "main": "./src/index.ts", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/QwikDev/qwik.git", + "directory": "packages/ts-optimizer" + }, + "scripts": { + "build": "tsdown", + "prepublishOnly": "pnpm build && pnpm test", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "sideEffects": false, + "type": "module" +} diff --git a/packages/ts-optimizer/src/ast-types.ts b/packages/ts-optimizer/src/ast-types.ts new file mode 100644 index 00000000000..8d998deb7f5 --- /dev/null +++ b/packages/ts-optimizer/src/ast-types.ts @@ -0,0 +1,102 @@ +import type { EcmaScriptModule, ParseResult, ParserOptions } from 'oxc-parser'; +import * as oxcParser from 'oxc-parser'; + +export type { + ArrayExpression, + ArrayPattern, + ArrowFunctionExpression, + AssignmentPattern, + BindingIdentifier, + BindingPattern, + BindingProperty, + BindingRestElement, + BlockStatement, + CallExpression, + ChainExpression, + Class, + Declaration, + Directive, + DoWhileStatement, + ExportNamedDeclaration, + ExportSpecifier, + Expression, + ForInStatement, + ForOfStatement, + ForStatement, + Function, + FunctionBody, + IdentifierName, + IdentifierReference, + ImportAttribute, + ImportDeclaration, + ImportDeclarationSpecifier, + ImportDefaultSpecifier, + ImportNamespaceSpecifier, + ImportSpecifier, + JSXAttribute, + JSXAttributeItem, + JSXAttributeName, + JSXChild, + JSXElement, + JSXElementName, + JSXExpression, + JSXExpressionContainer, + JSXFragment, + JSXIdentifier, + JSXNamespacedName, + JSXOpeningElement, + JSXSpreadAttribute, + JSXText, + MemberExpression, + ModuleDeclaration, + ModuleExportName, + Node, + ObjectPattern, + ParamPattern, + Program, + PropertyKey, + Statement, + BooleanLiteral, + ComputedMemberExpression, + NullLiteral, + NumericLiteral, + ObjectProperty, + SpreadElement, + StaticMemberExpression, + StringLiteral, + TSEnumDeclaration, + VariableDeclaration, + VariableDeclarator, + WhileStatement, +} from '@oxc-project/types'; + +export type AstNode = import('@oxc-project/types').Node; +export type AstProgram = import('@oxc-project/types').Program; +export type AstParamPattern = import('@oxc-project/types').ParamPattern; +export type AstFunction = + | import('@oxc-project/types').Function + | import('@oxc-project/types').ArrowFunctionExpression; +export type AstMaybeNode = AstNode | null | undefined; +export type AstParentNode = AstNode | null; +export type AstCompatNode = { + type: string; + start?: number; + end?: number; + [key: string]: unknown; +}; +export type AstCompatMaybeNode = AstCompatNode | AstNode | null | undefined; +export type AstRawTransferParserOptions = ParserOptions & { + experimentalRawTransfer?: boolean; +}; +export type AstParseResult = ParseResult; +export type AstEcmaScriptModule = EcmaScriptModule; + +// Raw transfer is a native-binding fast path; the wasm (browser) binding +// parses the same AST without it. +export const RAW_TRANSFER_PARSER_OPTIONS: AstRawTransferParserOptions = { + experimentalRawTransfer: + typeof (oxcParser as { rawTransferSupported?: () => boolean }).rawTransferSupported === + 'function' + ? (oxcParser as { rawTransferSupported: () => boolean }).rawTransferSupported() + : false, +}; diff --git a/packages/ts-optimizer/src/create-optimizer.ts b/packages/ts-optimizer/src/create-optimizer.ts new file mode 100644 index 00000000000..ff2ee30551c --- /dev/null +++ b/packages/ts-optimizer/src/create-optimizer.ts @@ -0,0 +1,252 @@ +// `createOptimizer` factory. +// +// Provides an async factory plus a `transformModules(opts): Promise<...>` +// instance so a bundler call site like +// +// const result = await (await getOptimizer()).transformModules(opts); +// +// reads through unchanged. Internally everything wraps the synchronous +// `transformModule`; the Promise returns satisfy the async contract without any +// real async work. + +import * as nodePath from 'pathe'; + +import { + createTransformWorkerPool, + getSharedTransformPool, + resolvePoolSize, +} from './worker-pool.js'; +import { runTransform } from './transform-run.js'; + +import type { TransformModuleInput, TransformModulesOptions } from './optimizer/types/types.js'; + +/** + * Runtime environment the optimizer is executing in. Default `'node'` — the only environment this + * optimizer has been exercised in. + */ +export type SystemEnvironment = 'node' | 'deno' | 'bun' | 'webworker' | 'browsermain' | 'unknown'; + +/** + * Path utilities, shaped like Node's `path` module; `pathe` provides the implementation. `win32` is + * intentionally `null` — `pathe` normalises separators, so the platform-specific variant isn't + * needed at this boundary. + */ +export interface Path { + resolve(...paths: string[]): string; + normalize(path: string): string; + isAbsolute(path: string): boolean; + join(...paths: string[]): string; + relative(from: string, to: string): string; + dirname(path: string): string; + basename(path: string, ext?: string): string; + extname(path: string): string; + format(pathObject: { + root: string; + dir: string; + base: string; + ext: string; + name: string; + }): string; + parse(path: string): { + root: string; + dir: string; + base: string; + ext: string; + name: string; + }; + readonly sep: string; + readonly delimiter: string; + readonly win32: null; + readonly posix: Path; +} + +/** + * Host-system surface the optimizer can call back into. The transform pipeline never reads `sys` + * today — the field exists for compatibility at the public boundary so provider-swapping consumers + * find the same field set. + */ +export interface OptimizerSystem { + cwd: () => string; + env: SystemEnvironment; + os: string; + dynamicImport: (path: string) => Promise; + strictDynamicImport: (path: string) => Promise; + path: Path; +} + +/** + * Options for `createOptimizer`. All fields are passthrough: `sys` is preserved on the instance if + * provided (otherwise a default stub is built); the others + * (`binding`/`inlineStylesUpToBytes`/`sourcemap`/`_optimizer`) are accepted for type-compatibility + * but not read. The bundler call site currently passes `undefined`; the passthrough fields let an + * existing options object be reused unmodified when swapping providers. + */ +export interface OptimizerOptions { + sys?: OptimizerSystem; + binding?: unknown; + inlineStylesUpToBytes?: number; + sourcemap?: boolean; + _optimizer?: unknown; + /** + * Number of worker threads for transforms (Node only). Workers isolate transform allocations from + * the host heap and run independent calls in parallel. `0`/`1` runs in-process. Default: `min(4, + * cores - 1)`, overridable via `QWIK_TS_OPTIMIZER_WORKERS`; disabled under vitest. + */ + workers?: number; +} + +export type NapiTransformModuleInput = TransformModuleInput; +export type NapiTransformModulesOptions = TransformModulesOptions; + +/** Plain-string mirror of `SegmentAnalysis`. */ +export interface NapiSegmentAnalysis { + origin: string; + name: string; + entry: string | null; + displayName: string; + hash: string; + canonicalFilename: string; + path: string; + extension: string; + parent: string | null; + ctxKind: 'eventHandler' | 'function' | 'jSXProp'; + ctxName: string; + captures: boolean; + loc: [number, number]; + paramNames?: string[]; + captureNames?: string[]; +} + +/** + * Module record with no `kind` discriminant — the `segment`/`origPath` null-arms distinguish the + * two shapes (parents carry `origPath`, segments carry `segment`). + */ +export interface NapiTransformModule { + path: string; + isEntry: boolean; + code: string; + map: string | null; + segment: NapiSegmentAnalysis | null; + origPath: string | null; + /** Import sources removed as unused (e.g. only consumed by stripped segments). */ + imports?: string[]; +} + +/** Plain-number mirror of `DiagnosticHighlightFlat`. */ +export interface NapiSourceLocation { + lo: number; + hi: number; + startLine: number; + startCol: number; + endLine: number; + endCol: number; +} + +/** + * Diagnostic record. `category` includes `'sourceError'` for boundary compatibility, though this + * implementation only emits `'error' | 'warning'`. + */ +export interface NapiDiagnostic { + scope: string; + category: 'error' | 'warning' | 'sourceError'; + code: string | null; + file: string; + message: string; + highlights: NapiSourceLocation[] | null; + suggestions: string[] | null; +} + +/** Transform result: fresh mutable arrays, NAPI module records. */ +export interface NapiTransformOutput { + modules: NapiTransformModule[]; + diagnostics: NapiDiagnostic[]; + isTypeScript: boolean; + isJsx: boolean; +} + +/** + * Optimizer instance. `transformModules` wraps the synchronous `transformModule` and returns a + * Promise so the call site can `await` it; it speaks the raw NAPI-parity types (inputs branded + * internally, outputs mapped to the public shape). `sys` is the host-system surface (see + * {@link OptimizerSystem}). + */ +export interface QwikOptimizer { + transformModules(opts: NapiTransformModulesOptions): Promise; + sys: OptimizerSystem; + /** Terminates the worker pool, if one is active. Safe to omit — workers never block exit. */ + dispose?(): Promise; +} + +/** + * `pathe` provides a full Node-`path`-shaped module. Cast through the structural-subset boundary + * once here so consumers need no per-call casts. `win32` is narrowed to `null`; separator + * normalisation is sufficient. + */ +function buildDefaultPath(): Path { + const path: Path = { + resolve: nodePath.resolve, + normalize: nodePath.normalize, + isAbsolute: nodePath.isAbsolute, + join: nodePath.join, + relative: nodePath.relative, + dirname: nodePath.dirname, + basename: nodePath.basename, + extname: nodePath.extname, + format: nodePath.format, + parse: nodePath.parse, + sep: nodePath.sep, + delimiter: nodePath.delimiter, + win32: null, + // `posix` self-references `path`; a getter defers so the closure sees the built object. + get posix(): Path { + return path; + }, + }; + return path; +} + +function buildDefaultSystem(): OptimizerSystem { + const hasProcess = typeof process === 'object' && typeof process.cwd === 'function'; + const isWebWorker = + !hasProcess && typeof (globalThis as { importScripts?: unknown }).importScripts === 'function'; + return { + cwd: () => (hasProcess ? process.cwd() : '/'), + env: hasProcess ? 'node' : isWebWorker ? 'webworker' : 'browsermain', + os: hasProcess ? process.platform : 'unknown', + dynamicImport: (p) => import(p), + strictDynamicImport: (p) => import(p), + path: buildDefaultPath(), + }; +} + +/** + * Build an optimizer instance. Returns a Promise so a `await createOptimizer(...)` call site can + * await it, though the underlying `transformModule` is synchronous. `options.sys` is preserved if + * provided; otherwise a default stub is built. Other `OptimizerOptions` fields are accepted for + * type-compatibility but not read. + */ +export async function createOptimizer(options?: OptimizerOptions): Promise { + const sys = options?.sys ?? buildDefaultSystem(); + // Explicit `workers` gets a private, disposable pool; the default shares one + // process-wide pool so per-build optimizer instances don't multiply workers. + const isPrivatePool = options?.workers !== undefined; + const pool = isPrivatePool + ? await createTransformWorkerPool(resolvePoolSize(options.workers)) + : await getSharedTransformPool(resolvePoolSize(undefined)); + return { + sys, + transformModules(opts) { + if (pool) { + return pool.transformModules(opts); + } + try { + return Promise.resolve(runTransform(opts)); + } catch (err) { + return Promise.reject(err instanceof Error ? err : new Error(String(err))); + } + }, + dispose() { + return pool && isPrivatePool ? pool.dispose() : Promise.resolve(); + }, + }; +} diff --git a/packages/ts-optimizer/src/hashing/naming.ts b/packages/ts-optimizer/src/hashing/naming.ts new file mode 100644 index 00000000000..ec450d05f2a --- /dev/null +++ b/packages/ts-optimizer/src/hashing/naming.ts @@ -0,0 +1,78 @@ +/** Display-name and symbol-name construction for Qwik segments. */ + +import { qwikHash } from './siphash.js'; +import { getBasename } from '../paths.js'; +import { + type DisplayName, + type SymbolName, + mkDisplayName, + mkSymbolName, +} from '../optimizer/types/brands.js'; + +/** + * Escape a string to contain only alphanumeric characters and underscores: non-alphanumerics become + * underscores, leading/trailing ones are dropped, and consecutive non-alphanumerics collapse to a + * single underscore. + */ +export function escapeSymbol(str: string): string { + let result = ''; + let pendingUnderscore = false; + let hasContent = false; + + for (const ch of str) { + const isAlnum = + (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'); + if (isAlnum) { + if (pendingUnderscore && hasContent) { + result += '_'; + } + result += ch; + hasContent = true; + pendingUnderscore = false; + } else { + if (hasContent) { + pendingUnderscore = true; + } + } + } + return result; +} + +/** + * Build the display name `{fileStem}_{escapedContext}` from a file stem and context stack. An empty + * stack yields `{fileStem}_s_`; a result starting with a digit gets a leading underscore. + */ +export function buildDisplayName(fileStem: string, contextStack: string[]): DisplayName { + const joined = contextStack.length === 0 ? 's_' : contextStack.join('_'); + + let escaped = escapeSymbol(joined); + + if (escaped.length > 0 && escaped[0] >= '0' && escaped[0] <= '9') { + escaped = '_' + escaped; + } + + if (contextStack.length === 0) { + return mkDisplayName(fileStem + '_s_'); + } + + return mkDisplayName(fileStem + '_' + escaped); +} + +/** + * Build the symbol name `{contextPortion}_{hash}`, where `contextPortion` is the displayName with + * the `{fileStem}_` prefix removed and `hash` is `qwikHash(scope, relPath, contextPortion)`. + */ +export function buildSymbolName( + displayName: DisplayName, + scope: string | undefined, + relPath: string +): SymbolName { + const basename = getBasename(relPath); + const prefix = basename + '_'; + const contextPortion = displayName.startsWith(prefix) + ? displayName.slice(prefix.length) + : displayName; + + const hash = qwikHash(scope, relPath, contextPortion); + return mkSymbolName(contextPortion + '_' + hash); +} diff --git a/packages/ts-optimizer/src/hashing/siphash.ts b/packages/ts-optimizer/src/hashing/siphash.ts new file mode 100644 index 00000000000..0f44f6a10bb --- /dev/null +++ b/packages/ts-optimizer/src/hashing/siphash.ts @@ -0,0 +1,47 @@ +/** + * SipHash-1-3 (zero keys) hashing for Qwik symbol names, with URL-safe base64 encoding: no padding, + * `-`/`_` replaced with `0`. + */ + +import SipHash13 from 'siphash/lib/siphash13.js'; +import { type Hash, mkHash } from '../optimizer/types/brands.js'; + +const ZERO_KEY: [number, number, number, number] = [0, 0, 0, 0]; + +/** Hash a symbol from `scope + relPath + displayName` to an 11-char base64 string. */ +export function qwikHash(scope: string | undefined, relPath: string, displayName: string): Hash { + const input = (scope ?? '') + relPath + displayName; + return encodeHash(input); +} + +/** + * Hash a raw seed string directly, without the `scope + relPath + displayName` concat. The + * import-aware naming path uses it so `useStyles$(css3)` with `import css3 from './style.css'` + * hashes the seed `./style.css#default`, keeping the segment hash stable across files importing the + * same asset under the same name. + */ +export function qwikHashFromSeed(seed: string): Hash { + return encodeHash(seed); +} + +function encodeHash(input: string): Hash { + const result = SipHash13.hash(ZERO_KEY, input); + + const bytes = new Uint8Array(8); + bytes[0] = result.l & 0xff; + bytes[1] = (result.l >>> 8) & 0xff; + bytes[2] = (result.l >>> 16) & 0xff; + bytes[3] = (result.l >>> 24) & 0xff; + bytes[4] = result.h & 0xff; + bytes[5] = (result.h >>> 8) & 0xff; + bytes[6] = (result.h >>> 16) & 0xff; + bytes[7] = (result.h >>> 24) & 0xff; + + const base64 = btoa(String.fromCharCode(...bytes)); + const encoded = base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + .replace(/[-_]/g, '0'); + return mkHash(encoded); +} diff --git a/packages/ts-optimizer/src/hashing/siphash13.d.ts b/packages/ts-optimizer/src/hashing/siphash13.d.ts new file mode 100644 index 00000000000..0805073b5ae --- /dev/null +++ b/packages/ts-optimizer/src/hashing/siphash13.d.ts @@ -0,0 +1,8 @@ +// Also listed in packages/qwik/tsconfig.json: without it that program resolves the specifier to +// siphash's shipped .ts source, which is a plain script rather than a module. +declare module 'siphash/lib/siphash13.js' { + const SipHash13: { + hash(key: [number, number, number, number], message: string): { h: number; l: number }; + }; + export default SipHash13; +} diff --git a/packages/ts-optimizer/src/index.ts b/packages/ts-optimizer/src/index.ts new file mode 100644 index 00000000000..0b78e2b1e51 --- /dev/null +++ b/packages/ts-optimizer/src/index.ts @@ -0,0 +1,82 @@ +// Public API surface for @qwik.dev/ts-optimizer. +// +// Only the names re-exported below are part of the package's public contract; +// modules under ./optimizer/** remain internal. + +export { transformModule } from './optimizer/transform/index.js'; + +export { createOptimizer } from './create-optimizer.js'; + +// Pre-parsed AST input types. The contract is structural — any +// ESTree/TS-ESTree-compatible Program satisfies the type at runtime. +export type { + AstProgram as Program, + AstEcmaScriptModule as EcmaScriptModule, +} from './ast-types.js'; + +export type { + NapiDiagnostic, + NapiSegmentAnalysis, + NapiSourceLocation, + NapiTransformModule, + NapiTransformModuleInput, + NapiTransformModulesOptions, + NapiTransformOutput, + OptimizerOptions, + OptimizerSystem, + Path, + QwikOptimizer, + SystemEnvironment, +} from './create-optimizer.js'; + +export type { + Diagnostic, + DiagnosticHighlightFlat, + EmitMode, + EntryStrategy, + MinifyMode, + SegmentAnalysis, + TransformModule, + TransformModuleInput, + TransformModuleParent, + TransformModuleSegment, + TransformModulesOptions, + TransformOutput, + WithManualEntryMap, +} from './optimizer/types/types.js'; + +export { hasManualEntryMap } from './optimizer/types/types.js'; + +// Brand types + smart constructors — consumers building options from raw strings +// need these to satisfy the type system without casting at every call site. +export type { + BodyText, + ByteOffset, + CanonicalFilename, + ColumnNumber, + CtxName, + DisplayName, + FilePath, + Hash, + LineNumber, + Origin, + RelativePath, + SourceText, + SymbolName, +} from './optimizer/types/brands.js'; + +export { + mkBodyText, + mkByteOffset, + mkCanonicalFilename, + mkColumnNumber, + mkCtxName, + mkDisplayName, + mkFilePath, + mkHash, + mkLineNumber, + mkOrigin, + mkRelativePath, + mkSourceText, + mkSymbolName, +} from './optimizer/types/brands.js'; diff --git a/packages/ts-optimizer/src/optimizer/analysis/capture-analysis.ts b/packages/ts-optimizer/src/optimizer/analysis/capture-analysis.ts new file mode 100644 index 00000000000..8f3fa9a9af5 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/analysis/capture-analysis.ts @@ -0,0 +1,235 @@ +import { walk } from 'oxc-walker'; +import type { + AstFunction, + AstMaybeNode, + AstNode, + AstParamPattern, + AstProgram, + BlockStatement, + FunctionBody, + Statement, + VariableDeclaration, +} from '../../ast-types.js'; +import { + addBindingNamesFromPatternToSet, + appendBindingNamesFromPattern, +} from '../ast/binding-pattern.js'; + +export interface CaptureAnalysisResult { + captureNames: string[]; + captures: boolean; + paramNames: string[]; +} + +export function analyzeCaptures( + closureNode: AstFunction, + parentScopeIdentifiers: Set, + freeIdentifiers: readonly string[] +): CaptureAnalysisResult { + const paramNames = collectParamNames(closureNode.params ?? []); + const undeclared = freeIdentifiers; + + // Parent-scope membership wins even when a same-name import exists: same-scope + // import + decl is illegal, so the name must be an inner-scope shadow whose + // value crosses the boundary — excluding it would drop a real capture. + const captureNames = [ + ...new Set(undeclared.filter((name) => parentScopeIdentifiers.has(name)).sort()), + ]; + + return { + captureNames, + captures: captureNames.length > 0, + paramNames, + }; +} + +export function excludeNestedExtractionCaptures( + closureNode: AstFunction, + captureNames: readonly string[], + childRanges: ReadonlyArray, + moduleScopeNames: ReadonlySet +): string[] { + if (captureNames.length === 0 || childRanges.length === 0) { + return [...captureNames]; + } + const moduleLevelCaptures = new Set(captureNames.filter((n) => moduleScopeNames.has(n))); + if (moduleLevelCaptures.size === 0) { + return [...captureNames]; + } + + const usedOutsideAnyChild = new Set(); + walk(closureNode, { + enter(node: AstNode) { + if (node.type !== 'Identifier' && node.type !== 'JSXIdentifier') { + return; + } + const name = node.name; + if (!moduleLevelCaptures.has(name) || usedOutsideAnyChild.has(name)) { + return; + } + if (!childRanges.some(([s, e]) => node.start >= s && node.start < e)) { + usedOutsideAnyChild.add(name); + } + }, + }); + return captureNames.filter((n) => !moduleLevelCaptures.has(n) || usedOutsideAnyChild.has(n)); +} + +function collectParamNames(params: AstParamPattern[]): string[] { + const names: string[] = []; + for (const param of params) { + appendBindingNamesFromPattern(param, names); + } + return names; +} + +export function collectScopeIdentifiers( + containerNode: AstProgram | BlockStatement | FunctionBody | AstFunction, + _source: string, + _relPath: string +): Set { + const ids = new Set(); + collectDeclarationsFromNode(containerNode, ids); + return ids; +} + +function collectDeclarationsFromNode( + node: AstMaybeNode | Statement | FunctionBody | AstFunction, + ids: Set +): void { + if (!node) { + return; + } + + if (node.type === 'VariableDeclaration') { + const declarationNode = node as VariableDeclaration; + for (const decl of declarationNode.declarations ?? []) { + if (decl.id) { + addBindingNamesFromPatternToSet(decl.id, ids); + } + } + return; + } + + if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') { + ids.add(node.id.name); + return; + } + + if (node.type === 'BlockStatement' || node.type === 'Program') { + for (const stmt of node.body ?? []) { + collectDeclarationsFromNode(stmt, ids); + } + return; + } + + const isFunctionNode = + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration'; + + if (isFunctionNode) { + for (const param of node.params ?? []) { + addBindingNamesFromPatternToSet(param, ids); + } + if (node.body) { + collectDeclarationsFromNode(node.body, ids); + } + } +} + +/** + * Differential oracle for the gather walk's lexical-scope projection: for each tracked closure, the + * flat union of every enclosing scope plus the module scope. The per-closure union is deferred + * until after the walk so enclosing declarations that textually follow the closure (hoisted names, + * later `const`s) are still included. + */ +export function buildClosureLexicalScopes( + program: AstProgram, + closureNodes: ReadonlyMap +): Map> { + const result = new Map>(); + + const nodeToSymbol = new Map(); + for (const [sym, fn] of closureNodes) { + nodeToSymbol.set(fn, sym); + } + + // Index 0 is module scope; each pushed set is one enclosing function scope. + const scopeStack: Set[] = [new Set()]; + const pending: Array<{ sym: string; scopes: Set[] }> = []; + + walk(program, { + enter(node: AstNode) { + addScopeDeclarations(node, scopeStack[scopeStack.length - 1]); + if (!isFunctionLikeNode(node)) { + return; + } + + const fn = node as AstFunction; + const sym = nodeToSymbol.get(fn); + // Snapshot enclosing scopes by reference; they keep filling, union taken post-walk. + if (sym !== undefined) { + pending.push({ sym, scopes: [...scopeStack] }); + } + + const ownScope = new Set(); + for (const param of fn.params ?? []) { + addBindingNamesFromPatternToSet(param, ownScope); + } + scopeStack.push(ownScope); + }, + leave(node: AstNode) { + if (isFunctionLikeNode(node)) { + scopeStack.pop(); + } + }, + }); + + for (const { sym, scopes } of pending) { + const union = new Set(); + for (const scope of scopes) { + for (const id of scope) { + union.add(id); + } + } + result.set(sym, union); + } + return result; +} + +function isFunctionLikeNode(node: AstNode): boolean { + return ( + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' + ); +} + +/** + * Add the binding names a single node declares to its enclosing function scope. Non-recursive: the + * caller's walk provides traversal, so a function/class declaration name lands in the enclosing + * scope while its params/body are a separate scope the walk visits under its own frame. + */ +export function addScopeDeclarations(node: AstNode, ids: Set): void { + switch (node.type) { + case 'VariableDeclaration': + for (const d of node.declarations ?? []) { + if (d.id) { + addBindingNamesFromPatternToSet(d.id, ids); + } + } + return; + case 'FunctionDeclaration': + case 'ClassDeclaration': + if (node.id?.type === 'Identifier') { + ids.add(node.id.name); + } + return; + case 'CatchClause': + if (node.param) { + addBindingNamesFromPatternToSet(node.param, ids); + } + return; + } +} diff --git a/packages/ts-optimizer/src/optimizer/analysis/closure-free-identifiers.ts b/packages/ts-optimizer/src/optimizer/analysis/closure-free-identifiers.ts new file mode 100644 index 00000000000..2d1c82a8d7b --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/analysis/closure-free-identifiers.ts @@ -0,0 +1,18 @@ +import type { AstFunction } from '../../ast-types.js'; +import type { AstProgram } from '../../ast-types.js'; +import { gatherModuleFacts } from './module-gather-walk.js'; + +/** + * For each closure in `closureNodes`, the identifiers referenced inside it that don't resolve to a + * declaration in its own subtree — ordered by first reference, deduplicated, one entry per tracked + * node (possibly empty). Keyed by node identity so the map survives the prod symbol rename. + */ +export function computeClosureFreeIdentifiers( + program: AstProgram, + closureNodes: ReadonlyMap +): ReadonlyMap { + if (closureNodes.size === 0) { + return new Map(); + } + return gatherModuleFacts({ program, closureNodes }).closureFreeIdentifiers; +} diff --git a/packages/ts-optimizer/src/optimizer/analysis/module-gather-walk.ts b/packages/ts-optimizer/src/optimizer/analysis/module-gather-walk.ts new file mode 100644 index 00000000000..a137c28ec4f --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/analysis/module-gather-walk.ts @@ -0,0 +1,861 @@ +/** + * Canonical per-module gather walk: one traversal over the parsed program — building the + * ScopeTracker as it goes — produces every per-module fact (free identifiers, lexical scopes, loop + * contexts, scope entries, segment/root usage, passive-directive conflicts, scope-aware JSX + * bindings) plus the Phase-1 extraction set. Each fact is a projection; the standalone functions + * they replace are retained as differential oracles. + * + * Projections needing whole-program knowledge before they can act (segment usage must see hoisted + * declarations; free-identifier resolution needs the complete scope tree) buffer during enter and + * resolve after the walk returns. That post-walk resolution is what lets the tracker build during + * this walk rather than in a separate pass before it. + */ + +import { isBindingIdentifier } from 'oxc-walker'; +import type { ScopeTrackerNode } from 'oxc-walker'; +import { ScopeQueryTracker } from './scope-query-tracker.js'; +import type { + AstEcmaScriptModule, + AstFunction, + AstNode, + AstParentNode, + AstProgram, +} from '../../ast-types.js'; +import { + createExtractionCollector, + type ExtractedSegment, + type ExtractionCollector, +} from '../extraction/extract.js'; +import { walkWithProtocol } from '../ast/walk-with-protocol.js'; +import { addBindingNamesFromPatternToSet } from '../ast/binding-pattern.js'; +import { getJsxAttributeName } from '../jsx/jsx-attr-name.js'; +import { detectLoopContext, type LoopContext } from '../jsx/loop-hoisting.js'; +import { + buildForLoopScopeEntry, + buildFunctionScopeEntry, + loopBodyKey, + type LoopBodyVarDeclMap, + type ScopeEntry, +} from '../jsx/event-capture-promotion.js'; +import { addScopeDeclarations } from './capture-analysis.js'; +import { + DECLARATION_TYPES, + addDeclaredNamesFromNode, + collectRootDeclPositions, + isNonReferenceIdentifier, +} from './variable-migration.js'; +import { + createScopeBindingsCollector, + type ScopeAwareCollectResult, + type ScopeBindingsCollector, +} from '../jsx/jsx.js'; + +export interface UsageExtractionRange { + readonly symbolName: string; + readonly argStart: number; + readonly argEnd: number; +} + +export interface CallExtractionRange { + readonly symbolName: string; + readonly callStart: number; + readonly callEnd: number; +} + +/** + * One passive:/preventdefault: conflict site; emission is deferred to Phase 4 to preserve + * diagnostic order. + */ +export interface PassiveConflict { + readonly eventName: string; + readonly start: number; + readonly end: number; +} + +export interface ExtractionGatherInputs { + readonly source: string; + readonly relPath: string; + readonly scope?: string; + readonly transpileJsx?: boolean; + readonly explicitTranspileJsx?: boolean; + readonly parserModule?: AstEcmaScriptModule; + readonly closureNodesOut?: Map; +} + +/** + * Which facts to gather: each projection runs iff its input field is present — an empty array/map + * still runs it. Two host modes: standalone-facts (caller passes `closureNodes` / + * `usageExtractions` / `loopExtractions` for existing extractions) and fused-extraction + * (`extraction` is set; the walk hosts the Phase-1 collector and the projections key off the + * discovered set, so the three standalone inputs must be omitted). + */ +export interface ModuleGatherInputs { + readonly program: AstProgram; + readonly extraction?: ExtractionGatherInputs; + readonly closureNodes?: ReadonlyMap; + readonly usageExtractions?: ReadonlyArray; + readonly loopExtractions?: ReadonlyArray; + readonly repairedCode?: string; + readonly scopeEntries?: boolean; + readonly passiveConflicts?: boolean; + readonly scopeBindings?: boolean; +} + +/** + * Every gathered fact; fields for disabled projections are empty. The closure-keyed maps key by + * node identity, not symbolName — names aren't final until post-walk disambiguation. + */ +export interface ModuleGatherFacts { + readonly extractions: readonly ExtractedSegment[]; + readonly closureFreeIdentifiers: ReadonlyMap; + readonly closureLexicalScopes: Map>; + readonly nonFunctionCaptures: ReadonlyMap< + string, + { readonly names: readonly string[]; readonly scopeNode: AstFunction | null } + >; + readonly extractionLoopMap: Map; + readonly loopBodyVarDecls: LoopBodyVarDeclMap; + readonly allScopeEntries: ScopeEntry[]; + readonly segmentUsage: Map>; + readonly rootUsage: Set; + readonly passiveConflicts: PassiveConflict[]; + readonly scopeAwareBindings: ScopeAwareCollectResult | undefined; +} + +/** + * Scope-key containment: is `scope` equal to or nested under `ancestor`? Keys are dash-joined index + * paths; root is `""`. Segment-wise, not a bare `startsWith`, which would make `"0-11"` a child of + * `"0-1"`. + */ +function isScopeWithin(scope: string, ancestor: string): boolean { + if (ancestor === '') { + return true; + } + return scope === ancestor || scope.startsWith(`${ancestor}-`); +} + +function isFunctionLike(node: AstNode): node is AstFunction { + return ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' || + node.type === 'FunctionDeclaration' + ); +} + +/** One enclosing function scope; `node === null` is the module scope. */ +interface LexicalScopeFrame { + readonly node: AstFunction | null; + readonly set: Set; +} + +/** + * A closure whose free identifiers are being buffered. `ownScope` (its outermost pushed scope key) + * treats a `FunctionExpression`'s own name as internal — it sits above the param scope. `seen` + * dedups `(scopeKey, name)` pairs without disturbing first-occurrence order. + */ +interface OpenClosure { + readonly fn: AstFunction; + readonly names: string[]; + readonly dedupe: Set; + readonly ownScope: string; + readonly seen: Set; +} + +interface PendingResolution { + readonly oc: OpenClosure; + readonly name: string; + readonly scopeKey: string; +} + +/** Shared mutable walk state — the gather buffers every projection fills. */ +interface GatherEnterContext { + readonly tracker: ScopeQueryTracker; + + readonly freeIdentNames: ReadonlyMap; + readonly freeIdentDedupes: ReadonlyMap>; + readonly openClosures: readonly OpenClosure[]; + readonly pushOpenClosure: (oc: OpenClosure) => void; + readonly pendingResolutions: PendingResolution[]; + + readonly lexicalEnabled: boolean; + readonly lexicalClosureNodes: ReadonlySet; + readonly program: AstProgram; + readonly scopeStack: readonly LexicalScopeFrame[]; + readonly pushScope: (frame: LexicalScopeFrame) => void; + readonly closureLexicalScopes: Map>; + readonly pendingLexicalUnions: Array<{ node: AstFunction; frames: readonly LexicalScopeFrame[] }>; + + readonly loopEnabled: boolean; + readonly loopExtractions: ReadonlyArray; + readonly repairedCode: string; + readonly loopStack: readonly LoopContext[]; + readonly pushLoop: (loopCtx: LoopContext) => void; + readonly extractionLoopMap: Map; + readonly loopBodyVarDecls: LoopBodyVarDeclMap; + + readonly scopeEntriesEnabled: boolean; + readonly scopeEntryNodes: AstNode[]; + + readonly usageEnabled: boolean; + readonly bufferDeclVisits: boolean; + readonly identifierVisits: Array<{ pos: number; name: string }>; + readonly declVisits: AstNode[]; + + readonly passiveEnabled: boolean; + readonly passiveConflicts: PassiveConflict[]; + + readonly scopeBindingsCollector: ScopeBindingsCollector | undefined; + + readonly extractionCollector: ExtractionCollector | undefined; +} + +/** Exit view: adds the stack-pop act-helpers. */ +interface GatherExitContext extends GatherEnterContext { + readonly popOpenClosureIfMatches: (node: AstNode) => void; + readonly popScopeIfFunction: (node: AstNode) => void; + readonly popLoopIfMatches: (node: AstNode) => void; +} + +/** + * Run the canonical gather walk — one program traversal. The ScopeTracker is attached unfrozen and + * frozen on return before free-identifier resolution. + */ +export function gatherModuleFacts(inputs: ModuleGatherInputs): ModuleGatherFacts { + const { program } = inputs; + + const freeIdentNames = new Map(); + const freeIdentDedupes = new Map>(); + for (const fn of inputs.closureNodes?.values() ?? []) { + if (!freeIdentNames.has(fn)) { + freeIdentNames.set(fn, []); + freeIdentDedupes.set(fn, new Set()); + } + } + // preserveExitedScopes: post-walk resolution reads scopes the walk already left. + const tracker = new ScopeQueryTracker({ preserveExitedScopes: true }); + const openClosures: OpenClosure[] = []; + const pendingResolutions: PendingResolution[] = []; + + const lexicalEnabled = inputs.closureNodes !== undefined || inputs.extraction !== undefined; + const lexicalClosureNodes = new Set(inputs.closureNodes?.values() ?? []); + const closureLexicalScopes = new Map>(); + const pendingLexicalUnions: Array<{ node: AstFunction; frames: readonly LexicalScopeFrame[] }> = + []; + const scopeStack: LexicalScopeFrame[] = []; + if (lexicalEnabled) { + scopeStack.push({ node: null, set: new Set() }); + } + + const loopEnabled = inputs.loopExtractions !== undefined || inputs.extraction !== undefined; + const loopExtractions = inputs.loopExtractions ?? []; + const repairedCode = inputs.repairedCode ?? ''; + const loopStack: LoopContext[] = []; + const extractionLoopMap = new Map(); + const loopBodyVarDecls: LoopBodyVarDeclMap = new Map(); + + const scopeEntriesEnabled = inputs.scopeEntries === true; + const scopeEntryNodes: AstNode[] = []; + const allScopeEntries: ScopeEntry[] = []; + + const usageEnabled = inputs.usageExtractions !== undefined || inputs.extraction !== undefined; + const usageExtractions = inputs.usageExtractions ?? []; + const segmentUsage = new Map>(); + const rootUsage = new Set(); + const identifierVisits: Array<{ pos: number; name: string }> = []; + const declVisits: AstNode[] = []; + + const passiveEnabled = inputs.passiveConflicts === true; + const passiveConflicts: PassiveConflict[] = []; + + let scopeBindingsCollector: ScopeBindingsCollector | undefined; + if (inputs.scopeBindings === true) { + scopeBindingsCollector = createScopeBindingsCollector(program); + } + + const extractionLoopRefs = new Map(); + const nonFunctionScopeRefs = new Map< + ExtractedSegment, + { bindings: Set; scopeNode: AstFunction | null } + >(); + let extractionCollector: ExtractionCollector | undefined; + if (inputs.extraction !== undefined) { + const ex = inputs.extraction; + extractionCollector = createExtractionCollector({ + source: ex.source, + relPath: ex.relPath, + program, + parserModule: ex.parserModule, + scope: ex.scope, + transpileJsx: ex.transpileJsx, + explicitTranspileJsx: ex.explicitTranspileJsx, + closureNodesOut: ex.closureNodesOut, + onExtraction: (extraction, closureNode) => { + if (closureNode && !freeIdentNames.has(closureNode)) { + freeIdentNames.set(closureNode, []); + freeIdentDedupes.set(closureNode, new Set()); + lexicalClosureNodes.add(closureNode); + } + if (loopStack.length > 0) { + extractionLoopRefs.set(extraction, [...loopStack]); + } + if (!closureNode) { + const bindings = new Set(); + for (const frame of scopeStack.slice(1)) { + for (const name of frame.set) { + bindings.add(name); + } + } + nonFunctionScopeRefs.set(extraction, { + bindings, + scopeNode: scopeStack[scopeStack.length - 1]?.node ?? null, + }); + } + }, + }); + } + + const enterCtx: GatherEnterContext = { + tracker, + freeIdentNames, + freeIdentDedupes, + openClosures, + pushOpenClosure: (oc) => { + openClosures.push(oc); + }, + pendingResolutions, + lexicalEnabled, + lexicalClosureNodes, + program, + scopeStack, + pushScope: (frame) => { + scopeStack.push(frame); + }, + closureLexicalScopes, + pendingLexicalUnions, + loopEnabled, + loopExtractions, + repairedCode, + loopStack, + pushLoop: (loopCtx) => { + loopStack.push(loopCtx); + }, + extractionLoopMap, + loopBodyVarDecls, + scopeEntriesEnabled, + scopeEntryNodes, + usageEnabled, + bufferDeclVisits: usageExtractions.length > 0 || inputs.extraction !== undefined, + identifierVisits, + declVisits, + passiveEnabled, + passiveConflicts, + scopeBindingsCollector, + extractionCollector, + }; + + const exitCtx: GatherExitContext = { + ...enterCtx, + popOpenClosureIfMatches: (node) => { + if (openClosures.length > 0 && openClosures[openClosures.length - 1].fn === node) { + openClosures.pop(); + } + }, + popScopeIfFunction: (node) => { + if (isFunctionLike(node)) { + scopeStack.pop(); + } + }, + popLoopIfMatches: (node) => { + if (loopStack.length > 0 && loopStack[loopStack.length - 1].loopNode === node) { + loopStack.pop(); + } + }, + }; + + walkWithProtocol( + program, + enterCtx, + exitCtx, + { + enter(node, parent, ctx) { + enterFreeIdentifiers(node, parent, ctx); + enterLexicalScopes(node, ctx); + enterLoopMap(node, ctx); + enterScopeEntries(node, ctx); + enterSegmentUsage(node, parent, ctx); + enterPassiveConflicts(node, parent, ctx); + ctx.scopeBindingsCollector?.enter(node); + ctx.extractionCollector?.enter(node, parent); + }, + leave(node, _parent, ctx) { + ctx.extractionCollector?.leave(node); + ctx.popOpenClosureIfMatches(node); + if (ctx.lexicalEnabled) { + ctx.popScopeIfFunction(node); + } + if (ctx.loopEnabled) { + ctx.popLoopIfMatches(node); + } + ctx.scopeBindingsCollector?.leave(node); + }, + }, + { scopeTracker: tracker } + ); + + const extractions = extractionCollector?.finish() ?? []; + const nonFunctionCaptures = new Map(); + for (const [extraction, scope] of nonFunctionScopeRefs) { + const names = [ + ...new Set( + identifierVisits + .filter( + (visit) => + visit.pos >= extraction.argStart && + visit.pos < extraction.argEnd && + scope.bindings.has(visit.name) + ) + .map((visit) => visit.name) + ), + ]; + if (names.length > 0) { + nonFunctionCaptures.set(extraction.symbolName, { names, scopeNode: scope.scopeNode }); + } + } + for (const [ext, stack] of extractionLoopRefs) { + extractionLoopMap.set(ext.symbolName, stack); + } + + if (scopeEntriesEnabled && (inputs.extraction === undefined || extractions.length > 0)) { + for (const node of scopeEntryNodes) { + if (isFunctionLike(node)) { + allScopeEntries.push(buildFunctionScopeEntry(node)); + } else { + const entry = buildForLoopScopeEntry(node); + if (entry) { + allScopeEntries.push(entry); + } + } + } + } + + tracker.freeze(); + resolveFreeIdentifiers(pendingResolutions, tracker); + + // Deferred to post-walk: a later `const` in an enclosing scope is still a capture. + for (const { node, frames } of pendingLexicalUnions) { + const union = new Set(); + for (const frame of frames) { + for (const id of frame.set) { + union.add(id); + } + } + closureLexicalScopes.set(node, union); + } + + const classifyUsage = usageEnabled && (inputs.extraction === undefined || extractions.length > 0); + if (classifyUsage) { + const usageRanges: ReadonlyArray = + inputs.extraction !== undefined ? extractions : usageExtractions; + const extractionLocals = new Map>(); + for (const ext of usageRanges) { + segmentUsage.set(ext.symbolName, new Set()); + extractionLocals.set(ext.symbolName, new Set()); + } + classifySegmentUsage( + declVisits, + identifierVisits, + usageRanges, + extractionLocals, + collectRootDeclPositions(program), + segmentUsage, + rootUsage + ); + } + + return { + extractions, + closureFreeIdentifiers: freeIdentNames, + closureLexicalScopes, + nonFunctionCaptures, + extractionLoopMap, + loopBodyVarDecls, + allScopeEntries, + segmentUsage, + rootUsage, + passiveConflicts, + scopeAwareBindings: scopeBindingsCollector?.result(), + }; +} + +// A computed key (`obj[x]`, `{ [x]: v }`) references `x`, but oxc-walker's +// `isBindingIdentifier` ignores `computed` and reports it as a binding. +function isComputedKeyReference(node: AstNode, parent: AstNode | null): boolean { + if (parent === null) { + return false; + } + if (parent.type === 'MemberExpression') { + return parent.computed === true && parent.property === node; + } + if ( + parent.type === 'Property' || + parent.type === 'MethodDefinition' || + parent.type === 'PropertyDefinition' || + parent.type === 'AccessorProperty' + ) { + return parent.computed === true && parent.key === node; + } + return false; +} + +// Labels live in their own namespace, so `loop:`, `break loop`, and `continue +// loop` name a statement rather than reading a binding. +function isLabelIdentifier(node: AstNode, parent: AstNode | null): boolean { + if (parent === null) { + return false; + } + if ( + parent.type !== 'LabeledStatement' && + parent.type !== 'BreakStatement' && + parent.type !== 'ContinueStatement' + ) { + return false; + } + return parent.label === node; +} + +/** + * Free-identifier projection: buffer `(name, scopeKey)` per open closure; resolution happens + * post-walk in {@link resolveFreeIdentifiers}. The same name can resolve free at one reference and + * internal at another (shadowing), so the buffer keys on the scope key, not the name alone. + */ +function enterFreeIdentifiers( + node: AstNode, + parent: AstNode | null, + ctx: GatherEnterContext +): void { + const { tracker, openClosures } = ctx; + + if (isFunctionLike(node) && ctx.freeIdentNames.has(node)) { + const current = tracker.getCurrentScope(); + let ownScope = current; + if (node.type === 'FunctionExpression') { + // A FunctionExpression's own name lives one scope out from its body. + const cut = current.lastIndexOf('-'); + ownScope = cut === -1 ? '' : current.slice(0, cut); + } + ctx.pushOpenClosure({ + fn: node, + names: ctx.freeIdentNames.get(node)!, + dedupe: ctx.freeIdentDedupes.get(node)!, + ownScope, + seen: new Set(), + }); + } + + if (openClosures.length === 0) { + return; + } + if (node.type !== 'Identifier') { + // A capitalized JSX tag (``) references a scope binding like any + // identifier; attribute names, namespaced parts, and member properties + // don't, and lowercase tags are intrinsic elements. + if (node.type !== 'JSXIdentifier') { + return; + } + if (parent?.type === 'JSXAttribute' || parent?.type === 'JSXNamespacedName') { + return; + } + if (parent?.type === 'JSXMemberExpression' && parent.property === node) { + return; + } + if (!/^[A-Z]/.test(node.name)) { + return; + } + } else if ( + isLabelIdentifier(node, parent) || + (isBindingIdentifier(node, parent) && !isComputedKeyReference(node, parent)) + ) { + return; + } + + const name = node.name; + const scopeKey = tracker.getCurrentScope(); + const seenKey = `${scopeKey}\u0000${name}`; + for (const oc of openClosures) { + if (oc.seen.has(seenKey)) { + continue; + } + oc.seen.add(seenKey); + ctx.pendingResolutions.push({ oc, name, scopeKey }); + } +} + +/** + * Resolve the buffered free-identifier visits in occurrence order against the frozen tracker. + * Memoized per `(name, scopeKey)`: the chain-walk is the expensive step and identical pairs resolve + * identically. + */ +function resolveFreeIdentifiers( + pending: readonly PendingResolution[], + tracker: ScopeQueryTracker +): void { + const memo = new Map(); + for (const { oc, name, scopeKey } of pending) { + if (oc.dedupe.has(name)) { + continue; + } + const memoKey = `${scopeKey}\u0000${name}`; + let decl: ScopeTrackerNode | null; + if (memo.has(memoKey)) { + decl = memo.get(memoKey)!; + } else { + decl = tracker.getDeclarationFromScope(name, scopeKey); + memo.set(memoKey, decl); + } + let free: boolean; + if (decl === null) { + free = true; + } else if (decl.node === (oc.fn as unknown)) { + // A FunctionDeclaration closure referencing its own name: declared at + // its own root scope, so it is internal, not free. + free = false; + } else { + free = !isScopeWithin(decl.scope, oc.ownScope); + } + if (free) { + oc.dedupe.add(name); + oc.names.push(name); + } + } +} + +function enterLexicalScopes(node: AstNode, ctx: GatherEnterContext): void { + if (!ctx.lexicalEnabled) { + return; + } + + // Collect before pushing this node's own frame, so a function/class + // declaration name lands in the enclosing scope, not its own. + addScopeDeclarations(node, ctx.scopeStack[ctx.scopeStack.length - 1].set); + + if (!isFunctionLike(node)) { + return; + } + + if (ctx.lexicalClosureNodes.has(node)) { + ctx.pendingLexicalUnions.push({ node, frames: [...ctx.scopeStack] }); + } + + const set = new Set(); + for (const param of node.params ?? []) { + addBindingNamesFromPatternToSet(param, set); + } + ctx.pushScope({ node, set }); +} + +function enterLoopMap(node: AstNode, ctx: GatherEnterContext): void { + if (!ctx.loopEnabled) { + return; + } + + const loopCtx = detectLoopContext(node, ctx.repairedCode); + if (loopCtx) { + ctx.pushLoop(loopCtx); + if (!ctx.loopBodyVarDecls.has(loopBodyKey(loopCtx.loopBodyStart, loopCtx.loopBodyEnd))) { + ctx.loopBodyVarDecls.set(loopBodyKey(loopCtx.loopBodyStart, loopCtx.loopBodyEnd), []); + } + } + if (node.type === 'VariableDeclaration' && ctx.loopStack.length > 0 && node.start !== undefined) { + const innermost = ctx.loopStack[ctx.loopStack.length - 1]; + if ( + node.start >= innermost.loopBodyStart && + node.end !== undefined && + node.end <= innermost.loopBodyEnd + ) { + const bucket = ctx.loopBodyVarDecls.get( + loopBodyKey(innermost.loopBodyStart, innermost.loopBodyEnd) + )!; + for (const decl of node.declarations ?? []) { + if (decl.id?.type === 'Identifier') { + bucket.push({ name: decl.id.name, declStart: decl.start ?? node.start }); + } + } + } + } + if (node.start !== undefined && node.end !== undefined && ctx.loopStack.length > 0) { + for (const ext of ctx.loopExtractions) { + if (node.start <= ext.callStart && node.end >= ext.callEnd) { + if ( + !ctx.extractionLoopMap.has(ext.symbolName) || + ctx.extractionLoopMap.get(ext.symbolName)!.length < ctx.loopStack.length + ) { + ctx.extractionLoopMap.set(ext.symbolName, [...ctx.loopStack]); + } + } + } + } +} + +/** + * Scope-entry projection: buffer nodes; entry records build post-walk, skipped when a fused walk + * found no extractions (the sole consumer iterates extractions). + */ +function enterScopeEntries(node: AstNode, ctx: GatherEnterContext): void { + if (!ctx.scopeEntriesEnabled) { + return; + } + + if ( + (isFunctionLike(node) || + node.type === 'ForOfStatement' || + node.type === 'ForInStatement' || + node.type === 'ForStatement') && + node.start !== undefined && + node.end !== undefined + ) { + ctx.scopeEntryNodes.push(node); + } +} + +/** + * Segment-usage projection: buffer decl/identifier visits; attribution happens post-walk in + * {@link classifySegmentUsage}. + */ +function enterSegmentUsage(node: AstNode, parent: AstNode | null, ctx: GatherEnterContext): void { + if (!ctx.usageEnabled) { + return; + } + + if (ctx.bufferDeclVisits && DECLARATION_TYPES.has(node.type)) { + ctx.declVisits.push(node); + } + // JSXIdentifier matters too: references module-level bindings + if ( + (node.type === 'Identifier' || node.type === 'JSXIdentifier') && + !isNonReferenceIdentifier(node, parent) + ) { + ctx.identifierVisits.push({ pos: node.start, name: node.name }); + } +} + +function enterPassiveConflicts( + node: AstNode, + parent: AstParentNode, + ctx: GatherEnterContext +): void { + if (!ctx.passiveEnabled) { + return; + } + if (node.type !== 'JSXOpeningElement') { + return; + } + + const attrs = node.attributes ?? []; + const passiveEvents = new Set(); + const preventdefaultEvents = new Set(); + + for (const attr of attrs) { + if (attr.type !== 'JSXAttribute') { + continue; + } + + const name = getJsxAttributeName(attr); + + if (name.startsWith('passive:')) { + passiveEvents.add(name.slice('passive:'.length)); + } else if (name.startsWith('preventdefault:')) { + preventdefaultEvents.add(name.slice('preventdefault:'.length)); + } + } + + for (const eventName of passiveEvents) { + if (preventdefaultEvents.has(eventName)) { + ctx.passiveConflicts.push({ + eventName, + start: node.start, + end: parent?.type === 'JSXElement' ? parent.end : node.end, + }); + } + } +} + +/** + * A sweep cursor over extraction arg ranges: fed ascending positions, it keeps the stack of ranges + * containing the current position. Arg ranges are AST node ranges, so they nest or are disjoint — + * the stack top is always the innermost containing range. + */ +class ExtractionRangeSweep { + private readonly sorted: UsageExtractionRange[]; + private next = 0; + readonly stack: UsageExtractionRange[] = []; + + constructor(extractions: ReadonlyArray) { + // Ascending argStart; ties open the wider (outer) range first so the + // stack stays outer-below-inner. + this.sorted = [...extractions].sort((a, b) => a.argStart - b.argStart || b.argEnd - a.argEnd); + } + + /** + * Advance to `pos`; afterwards `stack` holds exactly the ranges with `argStart <= pos < argEnd`, + * innermost on top. + */ + advanceTo(pos: number): void { + const { sorted, stack } = this; + while (stack.length > 0 && stack[stack.length - 1].argEnd <= pos) { + stack.pop(); + } + while (this.next < sorted.length && sorted[this.next].argStart <= pos) { + const ext = sorted[this.next]; + this.next++; + if (ext.argEnd > pos) { + stack.push(ext); + } + } + } +} + +/** + * Post-walk classification of buffered declaration and identifier visits. Attribution waits for the + * full locals map because in DFS order an identifier reference can be visited before its hoisted + * declaration (`function f() { g(); function g() {} }`), so visits are buffered and classified + * post-walk. Both buffers are classified by a sorted range-stack sweep, so the sweep is independent + * of walker visit order. + */ +function classifySegmentUsage( + declVisits: readonly AstNode[], + identifierVisits: ReadonlyArray<{ pos: number; name: string }>, + extractions: ReadonlyArray, + extractionLocals: ReadonlyMap>, + rootDeclPositions: ReadonlySet, + segmentUsage: Map>, + rootUsage: Set +): void { + const declSweep = new ExtractionRangeSweep(extractions); + const sortedDecls = [...declVisits].sort((a, b) => a.start - b.start); + for (const node of sortedDecls) { + declSweep.advanceTo(node.start); + for (const ext of declSweep.stack) { + if (node.end > ext.argEnd) { + continue; + } + addDeclaredNamesFromNode(node, extractionLocals.get(ext.symbolName)!); + } + } + + const identSweep = new ExtractionRangeSweep(extractions); + const sortedVisits = [...identifierVisits].sort((a, b) => a.pos - b.pos); + for (const { pos, name } of sortedVisits) { + identSweep.advanceTo(pos); + const { stack } = identSweep; + + const innermostExt = stack.length > 0 ? stack[stack.length - 1] : null; + if (innermostExt) { + const locals = extractionLocals.get(innermostExt.symbolName)!; + if (!locals.has(name)) { + segmentUsage.get(innermostExt.symbolName)!.add(name); + } + } else if (!rootDeclPositions.has(pos)) { + rootUsage.add(name); + } + } +} diff --git a/packages/ts-optimizer/src/optimizer/analysis/scope-query-tracker.ts b/packages/ts-optimizer/src/optimizer/analysis/scope-query-tracker.ts new file mode 100644 index 00000000000..c6ad7f79c92 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/analysis/scope-query-tracker.ts @@ -0,0 +1,34 @@ +/** + * `ScopeTracker` with a cursor-free declaration query. + * + * Upstream `getDeclaration(name)` resolves against the tracker's current scope (the walk cursor), + * coupling resolution to replay time. `getDeclarationFromScope` takes the cursor as an explicit + * `scopeKey`: on a tracker built with `preserveExitedScopes: true` and then frozen, the full scope + * tree is retained, so it returns for any `(name, scopeKey)` exactly what `getDeclaration` would + * with the cursor there. This lets a walk buffer `(name, currentScope)` pairs and resolve them + * post-walk — which is what lets the tracker build during the gather walk instead of in a pass + * before it. + */ + +import { ScopeTracker } from 'oxc-walker'; +import type { ScopeTrackerNode } from 'oxc-walker'; + +export class ScopeQueryTracker extends ScopeTracker { + /** + * Resolve `name` as if the walk cursor were at `scopeKey`. The root scope's key is the empty + * string. + */ + getDeclarationFromScope(name: string, scopeKey: string): ScopeTrackerNode | null { + if (!scopeKey) { + return this.scopes.get('')?.get(name) ?? null; + } + const indices = scopeKey.split('-').map(Number); + for (let i = indices.length; i >= 0; i--) { + const node = this.scopes.get(indices.slice(0, i).join('-'))?.get(name); + if (node) { + return node; + } + } + return null; + } +} diff --git a/packages/ts-optimizer/src/optimizer/analysis/variable-migration.ts b/packages/ts-optimizer/src/optimizer/analysis/variable-migration.ts new file mode 100644 index 00000000000..674928f92b0 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/analysis/variable-migration.ts @@ -0,0 +1,990 @@ +/** + * Variable migration analysis: decide whether each module-level declaration is moved into a + * segment, re-exported as `_auto_`, or kept at root. + */ + +import { walk } from 'oxc-walker'; +import type { AstMaybeNode, AstNode, AstProgram } from '../../ast-types.js'; +import { + addBindingNamesFromPatternToSet, + collectBindingNamesFromPattern, + type BindingPatternLike, +} from '../ast/binding-pattern.js'; + +export interface MigrationDecision { + readonly action: 'move' | 'reexport' | 'keep' | 'drop'; + readonly varName: string; + readonly targetSegment?: string; + readonly reason: string; + readonly useAutoExport?: boolean; +} + +export interface ModuleLevelDecl { + readonly name: string; + readonly declStart: number; + readonly declEnd: number; + readonly declText: string; + // Mutable: set false during the initial decl walk, flipped true by the later + // pass that scans `export { name }` specifiers. + isExported: boolean; + readonly isDirectlyExported: boolean; + readonly hasSideEffects: boolean; + readonly isPartOfSharedDestructuring: boolean; + readonly kind: string; + /** Initializer is a direct `routeLoader$`/`routeAction$`/`globalAction$` (or Qrl-form) call. */ + readonly hasRouterMarkerInit: boolean; +} + +const ROUTER_MARKER_CALLEES = new Set([ + 'routeLoader$', + 'routeLoaderQrl', + 'routeAction$', + 'routeActionQrl', + 'globalAction$', + 'globalActionQrl', +]); + +function isRouterMarkerInit(init: AstMaybeNode): boolean { + let node = init; + while ( + node && + (node.type === 'ParenthesizedExpression' || + node.type === 'TSAsExpression' || + node.type === 'TSNonNullExpression' || + node.type === 'TSTypeAssertion' || + node.type === 'TSSatisfiesExpression' || + node.type === 'TSInstantiationExpression') + ) { + node = node.expression; + } + return ( + node?.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + ROUTER_MARKER_CALLEES.has(node.callee.name) + ); +} + +/** Conservative purity check: true only for expressions that provably have no side effects. */ +function isInitializerSafe(node: AstMaybeNode): boolean { + if (!node) { + return true; + } + + switch (node.type) { + case 'Literal': + return true; + + case 'ArrowFunctionExpression': + case 'FunctionExpression': + return true; + + case 'Identifier': + // Reading a binding is side-effect-free. + return true; + + case 'TemplateLiteral': + return (node.expressions ?? []).every((expr) => isInitializerSafe(expr)); + + case 'ObjectExpression': + for (const prop of node.properties ?? []) { + if (prop.type === 'SpreadElement') { + return false; + } + if (prop.type === 'Property') { + if (prop.computed) { + return false; + } + if (!isInitializerSafe(prop.value)) { + return false; + } + } + } + return true; + + case 'ArrayExpression': + for (const elem of node.elements ?? []) { + if (!elem) { + continue; + } + if (elem.type === 'SpreadElement') { + return false; + } + if (!isInitializerSafe(elem)) { + return false; + } + } + return true; + + case 'UnaryExpression': + return isInitializerSafe(node.argument); + + case 'BinaryExpression': + case 'LogicalExpression': + return isInitializerSafe(node.left) && isInitializerSafe(node.right); + + case 'ConditionalExpression': + return ( + isInitializerSafe(node.test) && + isInitializerSafe(node.consequent) && + isInitializerSafe(node.alternate) + ); + + case 'MemberExpression': + // Could trigger getters, but treated as safe for migration. + return !node.computed && isInitializerSafe(node.object); + + case 'MetaProperty': + return true; + + case 'CallExpression': { + // Marker calls (`component$`, `useTask$`, the bare `$`, etc.) are pure for + // migration: the parent rewrite replaces them with a non-mutating QRL + // form. Treating them as safe lets a single-segment marker decl move + // (MIG-01) instead of being trapped as side-effecty (MIG-04). Renamed + // marker imports don't match and stay conservative — that only loses a + // move optimization; reexport is still correct. + if (node.callee?.type === 'Identifier') { + const name = node.callee.name; + if (name === '$' || name.endsWith('$')) { + return true; + } + } + return false; + } + + default: + return false; + } +} + +function countBindings(node: BindingPatternLike | null | undefined): number { + return collectBindingNamesFromPattern(node).length; +} + +function unwrapExport(stmt: AstProgram['body'][number]): { + declaration: AstNode; + isExported: boolean; +} { + if ( + (stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration') && + stmt.declaration + ) { + return { declaration: stmt.declaration, isExported: true }; + } + return { declaration: stmt, isExported: false }; +} + +export function collectModuleLevelDecls(program: AstProgram, source: string): ModuleLevelDecl[] { + const decls: ModuleLevelDecl[] = []; + + for (const stmt of program.body ?? []) { + const { declaration, isExported } = unwrapExport(stmt); + const declStart = stmt.start; + const declEnd = stmt.end; + const declText = source.slice(declStart, declEnd); + + if (declaration.type === 'VariableDeclaration') { + const kind = declaration.kind; + for (const declarator of declaration.declarations ?? []) { + const id = declarator.id; + if (!id) { + continue; + } + + const hasSideEffects = !isInitializerSafe(declarator.init); + const isDestructuring = id.type === 'ObjectPattern' || id.type === 'ArrayPattern'; + const isShared = isDestructuring && countBindings(id) > 1; + const hasRouterMarkerInit = isRouterMarkerInit(declarator.init); + + const names = collectBindingNamesFromPattern(id); + + for (const name of names) { + decls.push({ + name, + declStart, + declEnd, + declText, + isExported, + isDirectlyExported: isExported, + hasSideEffects, + isPartOfSharedDestructuring: isShared, + kind, + hasRouterMarkerInit, + }); + } + } + } else if (declaration.type === 'FunctionDeclaration') { + const name = declaration.id?.name; + if (name) { + decls.push({ + name, + declStart, + declEnd, + declText, + isExported, + isDirectlyExported: isExported, + hasSideEffects: false, + isPartOfSharedDestructuring: false, + hasRouterMarkerInit: false, + kind: 'function', + }); + } + } else if (declaration.type === 'ClassDeclaration') { + const name = declaration.id?.name; + if (name) { + decls.push({ + name, + declStart, + declEnd, + declText, + isExported, + isDirectlyExported: isExported, + hasSideEffects: false, + isPartOfSharedDestructuring: false, + hasRouterMarkerInit: false, + kind: 'class', + }); + } + } else if (declaration.type === 'TSEnumDeclaration') { + const name = declaration.id?.name; + if (name) { + decls.push({ + name, + declStart, + declEnd, + declText, + isExported, + isDirectlyExported: isExported, + hasSideEffects: false, + isPartOfSharedDestructuring: false, + hasRouterMarkerInit: false, + kind: 'const', // enums behave like const for migration + }); + } + } + } + + // Handle `export { name }` specifiers declared separately from their binding. + for (const stmt of program.body ?? []) { + if (stmt.type === 'ExportNamedDeclaration' && !stmt.declaration && stmt.specifiers) { + for (const spec of stmt.specifiers) { + const localName = spec.local?.type === 'Identifier' ? spec.local.name : spec.local?.value; + if (!localName) { + continue; + } + const decl = decls.find((d) => d.name === localName); + if (decl) { + decl.isExported = true; + } + } + } + } + + return decls; +} + +/** + * Add the binding names a single node declares to `target`. Shared with the gather walk's + * segment-usage projection. + */ +export function addDeclaredNamesFromNode(node: AstNode, target: Set): void { + const type = node.type; + + if (type === 'ArrowFunctionExpression' && node.params) { + for (const param of node.params) { + addBindingNamesFromPatternToSet(param, target); + } + } + + if (type === 'FunctionExpression' || type === 'FunctionDeclaration') { + if (node.id?.name) { + target.add(node.id.name); + } + for (const param of node.params ?? []) { + addBindingNamesFromPatternToSet(param, target); + } + } + + if (type === 'VariableDeclaration') { + for (const decl of node.declarations ?? []) { + if (decl.id) { + addBindingNamesFromPatternToSet(decl.id, target); + } + } + } + + if (type === 'ClassDeclaration' && node.id?.name) { + target.add(node.id.name); + } + + if (type === 'CatchClause' && node.param) { + addBindingNamesFromPatternToSet(node.param, target); + } +} + +export const DECLARATION_TYPES = new Set([ + 'ArrowFunctionExpression', + 'FunctionExpression', + 'FunctionDeclaration', + 'VariableDeclaration', + 'ClassDeclaration', + 'CatchClause', +]); + +/** + * True when an Identifier is not a binding reference: a non-computed member property (`obj.x`), a + * non-shorthand non-computed object-property key (`{ x: v }`), or a non-computed class-member key. + * Usage attribution is reference-semantic and must skip these — counting them fabricates phantom + * usage for any module-level decl whose name collides with a property name (e.g. a root + * `startViewTransition` vs `document.startViewTransition`), which wrongly demotes a single-segment + * MOVE to a dual-use REEXPORT. Shared with the gather walk's segment-usage projection. + */ +export function isNonReferenceIdentifier( + node: AstNode, + parent: AstNode | null | undefined +): boolean { + if (!parent) { + return false; + } + if (parent.type === 'MemberExpression') { + return !parent.computed && parent.property === node; + } + if (parent.type === 'Property') { + return !parent.computed && !parent.shorthand && parent.key === node; + } + if (parent.type === 'MethodDefinition' || parent.type === 'PropertyDefinition') { + return !parent.computed && parent.key === node; + } + // Labels live in their own namespace, so `loop:`, `break loop`, and `continue + // loop` name a statement rather than reading a binding. + if ( + parent.type === 'LabeledStatement' || + parent.type === 'BreakStatement' || + parent.type === 'ContinueStatement' + ) { + return parent.label === node; + } + return false; +} + +/** + * Positions of top-level declaration-site identifiers, skipped during usage attribution so they + * don't count as root usage. Shared with the gather walk's segment-usage projection. + */ +export function collectRootDeclPositions(program: AstProgram): Set { + const positions = new Set(); + + for (const stmt of program.body ?? []) { + const { declaration } = unwrapExport(stmt); + + if (declaration.type === 'VariableDeclaration') { + for (const decl of declaration.declarations ?? []) { + if (decl.id) { + collectBindingPositions(decl.id, positions); + } + } + } else if (declaration.type === 'FunctionDeclaration' && declaration.id) { + positions.add(declaration.id.start); + } else if (declaration.type === 'ClassDeclaration' && declaration.id) { + positions.add(declaration.id.start); + } + } + + return positions; +} + +function collectBindingPositions( + node: BindingPatternLike | null | undefined, + positions: Set +): void { + if (!node) { + return; + } + + switch (node.type) { + case 'Identifier': + positions.add(node.start); + break; + + case 'ObjectPattern': + for (const prop of node.properties ?? []) { + if (prop.type === 'RestElement') { + collectBindingPositions(prop.argument, positions); + } else { + collectBindingPositions(prop.value, positions); + } + } + break; + + case 'ArrayPattern': + for (const elem of node.elements ?? []) { + collectBindingPositions(elem, positions); + } + break; + + case 'RestElement': + collectBindingPositions(node.argument, positions); + break; + + case 'AssignmentPattern': + collectBindingPositions(node.left, positions); + break; + + case 'TSParameterProperty': + collectBindingPositions(node.parameter, positions); + break; + + default: { + const _exhaustive: never = node; + throw new Error(`unhandled binding-pattern node: ${(_exhaustive as { type?: string }).type}`); + } + } +} + +/** + * Attribute every identifier reference to a segment or root scope, filtering locally-declared names + * within segments and root declaration-site identifiers. Classification can't happen inline during + * `enter`: in DFS order a reference can be visited before its hoisted declaration (`function f() { + * g(); function g() {} }`), so visits are buffered and classified post-walk once the locals map is + * complete. + * + * Retained as the differential oracle for the gather walk's segment-usage projection. + */ +export function computeSegmentUsage( + program: AstProgram, + extractions: Array<{ symbolName: string; argStart: number; argEnd: number }> +): { segmentUsage: Map>; rootUsage: Set } { + const segmentUsage = new Map>(); + const rootUsage = new Set(); + const extractionLocals = new Map>(); + + for (const ext of extractions) { + segmentUsage.set(ext.symbolName, new Set()); + extractionLocals.set(ext.symbolName, new Set()); + } + + const rootDeclPositions = collectRootDeclPositions(program); + const identifierVisits: Array<{ pos: number; name: string }> = []; + + walk(program, { + enter(node: AstNode, parent) { + if (DECLARATION_TYPES.has(node.type) && extractions.length > 0) { + const nodeStart = node.start; + const nodeEnd = node.end; + for (const ext of extractions) { + if (nodeStart < ext.argStart || nodeEnd > ext.argEnd) { + continue; + } + addDeclaredNamesFromNode(node, extractionLocals.get(ext.symbolName)!); + } + } + // JSXIdentifier matters too: references module-level bindings + if ( + (node.type === 'Identifier' || node.type === 'JSXIdentifier') && + !isNonReferenceIdentifier(node, parent as AstNode | null) + ) { + identifierVisits.push({ pos: node.start, name: node.name }); + } + }, + }); + + for (const { pos, name } of identifierVisits) { + // When nested extractions overlap (e.g., $() inside component$()), + // attribute to the innermost (smallest) range. + let inSegment = false; + let innermostExt: (typeof extractions)[0] | null = null; + let smallestSize = Infinity; + for (const ext of extractions) { + if (pos >= ext.argStart && pos < ext.argEnd) { + const size = ext.argEnd - ext.argStart; + if (size < smallestSize) { + smallestSize = size; + innermostExt = ext; + } + inSegment = true; + } + } + + if (innermostExt) { + const locals = extractionLocals.get(innermostExt.symbolName)!; + if (!locals.has(name)) { + segmentUsage.get(innermostExt.symbolName)!.add(name); + } + } + + if (!inSegment && !rootDeclPositions.has(pos)) { + rootUsage.add(name); + } + } + + return { segmentUsage, rootUsage }; +} + +/** + * Reasons returned in `MigrationDecision.reason`. Centralised so the same MIG code never appears as + * a free-floating string in two places. Keys follow `_` so the action is + * visible at the call site. + */ +export const MIG_REASON = { + MOVE_SINGLE_SEGMENT: 'single-use safe variable (MIG-01)', + MOVE_SHARED_DESTRUCTURE_UNIFIED: + 'all bindings of shared destructure flow to same single segment (MIG-05a)', + MOVE_TRANSITIVE_DEP: + 'dependency of a moved decl, used only by movers to the same segment (MIG-06a)', + REEXPORT_EXPORTED: 'exported variable used by segment (MIG-03)', + REEXPORT_DUAL_USE: 'used by both root code and segment(s)', + REEXPORT_MULTI_SEGMENT: 'used by multiple segments (MIG-02)', + REEXPORT_SIDE_EFFECTS: 'declaration has side effects (MIG-04)', + REEXPORT_SHARED_DESTRUCTURE: 'part of shared destructuring pattern (MIG-05)', + REEXPORT_MOVED_DECL_DEP: 'still referenced by a declaration migrating into a segment (MIG-06)', + REEXPORT_ROUTER_MARKER: 'router loader/action reached through its _auto_ export (MIG-07)', + KEEP_EXPORTED: 'exported but not used by any segment', + KEEP_ROUTER_MARKER: 'router loader/action, exported as _auto_ even when unused', + KEEP_ROOT_USED: 'used only by root code', + KEEP_UNUSED: 'not used by any segment', + DROP_UNREFERENCED: 'pure init unused by root and segments (dead after extraction)', + DROP_STRIPPED_ONLY: 'only consumed by stripped segments (side effects dropped)', +} as const; + +const INLINE_STRATEGY_REEXPORT_REASONS: ReadonlySet = new Set([ + MIG_REASON.REEXPORT_EXPORTED, + MIG_REASON.REEXPORT_DUAL_USE, + MIG_REASON.REEXPORT_MULTI_SEGMENT, +]); + +/** + * Inline/hoist strategy keeps segment bodies in the parent, so a decl a segment consumes is already + * in scope. Only reexports needed beyond this module survive — exported (MIG-03) and multi-consumer + * (MIG-02). Side-effect (MIG-04), shared-destructure (MIG-05) and moved-dep (MIG-06) reexports are + * redundant here and dropped, as is every `move` (which would delete a decl the in-parent body + * still references). + */ +export function filterInlineStrategyMigrations( + decisions: readonly MigrationDecision[] +): MigrationDecision[] { + return decisions.filter( + (d) => d.action === 'reexport' && INLINE_STRATEGY_REEXPORT_REASONS.has(d.reason) + ); +} + +/** + * Identifier names referenced within a module-level declaration's byte range. Walks the enclosing + * top-level statement; the range filter stays because the decl range can be narrower than the + * statement (one declarator of many). `referencesOnly` skips property-position identifiers (for + * binding-level decisions); the default keeps the name-harvest semantics `wireMigration` uses for + * import wiring. + */ +export function collectDeclIdentifiers( + program: AstProgram, + decl: Pick, + referencesOnly = false +): Set { + const names = new Set(); + const enclosingStmt = (program.body ?? []).find( + (stmt) => stmt.start <= decl.declStart && stmt.end >= decl.declEnd + ); + walk(enclosingStmt ?? program, { + enter(node: AstNode, parent) { + if ( + node.type === 'Identifier' && + node.start >= decl.declStart && + node.end <= decl.declEnd && + !(referencesOnly && isNonReferenceIdentifier(node, parent as AstNode | null)) + ) { + names.add(node.name); + } + // JSX tag names (``) reference bindings too — only + // opening/closing tag positions, not attribute names. + if ( + node.type === 'JSXIdentifier' && + node.start >= decl.declStart && + node.end <= decl.declEnd && + (parent as { type?: string; name?: unknown } | null)?.type !== 'JSXAttribute' && + (parent as { type?: string } | null)?.type !== 'JSXNamespacedName' + ) { + names.add(node.name); + } + }, + }); + return names; +} + +function usingSegmentsOf(name: string, segmentUsage: Map>): string[] { + const result: string[] = []; + for (const [segName, usedNames] of segmentUsage) { + if (usedNames.has(name)) { + result.push(segName); + } + } + return result; +} + +/** + * Decision tree for each module-level declaration (order matters): + * + * 1. Exported + used by segment -> reexport + * 2. Exported + unused by segments -> keep + * 3. Used by root + segment -> reexport + * 4. Used by multiple segments -> reexport + * 5. Has side effects -> reexport + * 6. Shared destructuring -> reexport + * 7. Used by exactly one segment -> move + * 8. Unused by any segment -> keep + * + * Post-pass MIG-05a: when every binding in a shared-destructure declaration is `reexport`-ed solely + * because of MIG-05 _and_ they all flow to the same single segment with no + * root/multi-segment/export/side-effect interference, the whole destructure can be moved together + * into that segment instead. + * + * Post-pass MIG-06: a `move`d declaration's body can still reference other module-level + * declarations after it leaves the parent. Any such dependency that would otherwise stay + * un-exported (`keep`) — or would move to a different segment — flips to `reexport` so the migrated + * body can import it. + */ +export function analyzeMigration( + decls: ModuleLevelDecl[], + segmentUsage: Map>, + rootUsage: Set, + program?: AstProgram +): MigrationDecision[] { + const decisions = decls.map((decl) => decideMigration(decl, segmentUsage, rootUsage)); + promoteSharedDestructureGroups(decls, decisions, segmentUsage, rootUsage); + if (program) { + reexportMovedDeclDependencies(decls, decisions, program, segmentUsage); + } + return decisions; +} + +/** + * MIG-06 post-pass — see {@link analyzeMigration}. Mutates `decisions` in place, in two stages: + * + * 1. Demote to fixpoint: a `move` whose decl is referenced by another `move` targeting a _different_ + * segment can't leave the parent — demote it to `reexport`. Iterated because each demotion can + * strand a previously- compatible mover. + * 2. Flip orphaned keeps: any `keep` dependency of a surviving `move` flips to `reexport` so the + * migrated body can import it from the parent. + */ +function reexportMovedDeclDependencies( + decls: ModuleLevelDecl[], + decisions: MigrationDecision[], + program: AstProgram, + segmentUsage: Map> +): void { + const indexByName = new Map(); + for (let i = 0; i < decls.length; i++) { + indexByName.set(decls[i].name, i); + } + + const depsOfMover = new Map>(); + const moverDeps = (i: number): Set => { + let deps = depsOfMover.get(i); + if (!deps) { + deps = collectDeclIdentifiers(program, decls[i], true); + depsOfMover.set(i, deps); + } + return deps; + }; + + const flipToReexport = (depIdx: number): void => { + decisions[depIdx] = { + action: 'reexport', + varName: decls[depIdx].name, + reason: MIG_REASON.REEXPORT_MOVED_DECL_DEP, + }; + }; + + let changed = true; + while (changed) { + changed = false; + for (let i = 0; i < decisions.length; i++) { + const decision = decisions[i]; + if (decision.action !== 'move') { + continue; + } + for (const dep of moverDeps(i)) { + if (dep === decision.varName) { + continue; + } + const depIdx = indexByName.get(dep); + if (depIdx === undefined || decls[depIdx].isExported) { + continue; + } + const depDecision = decisions[depIdx]; + if (depDecision.action === 'move' && depDecision.targetSegment !== decision.targetSegment) { + flipToReexport(depIdx); + changed = true; + } + } + } + } + + const referencedByDecls = new Map>(); + for (let j = 0; j < decls.length; j++) { + for (const dep of collectDeclIdentifiers(program, decls[j], true)) { + if (dep === decls[j].name) { + continue; + } + let refs = referencedByDecls.get(dep); + if (!refs) { + refs = new Set(); + referencedByDecls.set(dep, refs); + } + refs.add(j); + } + } + const rootStmtRefs = collectTopLevelStatementRefs(program, decls); + + // A keep-dep moves in with its consumer only when used *exclusively* by + // movers to the same segment; anything else (export, top-level use, another + // segment) keeps it in the parent to reexport. + const canMoveInto = (depIdx: number, segment: string): boolean => { + const d = decls[depIdx]; + if (d.isExported || rootStmtRefs.has(d.name)) { + return false; + } + if (usingSegmentsOf(d.name, segmentUsage).some((s) => s !== segment)) { + return false; + } + const refs = referencedByDecls.get(d.name); + if (!refs || refs.size === 0) { + return false; + } + for (const j of refs) { + const dec = decisions[j]; + if (dec.action !== 'move' || dec.targetSegment !== segment) { + return false; + } + } + return true; + }; + + let changed2 = true; + while (changed2) { + changed2 = false; + for (let i = 0; i < decisions.length; i++) { + const decision = decisions[i]; + if (decision.action !== 'move' || decision.targetSegment === undefined) { + continue; + } + const targetSegment = decision.targetSegment; + for (const dep of moverDeps(i)) { + if (dep === decision.varName) { + continue; + } + const depIdx = indexByName.get(dep); + if (depIdx === undefined || decls[depIdx].isExported) { + continue; + } + if (decisions[depIdx].action !== 'keep') { + continue; + } + if (canMoveInto(depIdx, targetSegment)) { + decisions[depIdx] = { + action: 'move', + varName: decls[depIdx].name, + targetSegment, + reason: MIG_REASON.MOVE_TRANSITIVE_DEP, + }; + changed2 = true; + } else { + flipToReexport(depIdx); + } + } + } + } +} + +function collectTopLevelStatementRefs(program: AstProgram, decls: ModuleLevelDecl[]): Set { + const ranges = decls.map((d) => [d.declStart, d.declEnd] as const); + const inDecl = (pos: number): boolean => ranges.some(([s, e]) => pos >= s && pos < e); + const refs = new Set(); + walk(program, { + enter(node: AstNode, parent) { + if ( + (node.type === 'Identifier' || node.type === 'JSXIdentifier') && + !isNonReferenceIdentifier(node, parent as AstNode | null) && + !inDecl(node.start) + ) { + refs.add(node.name); + } + }, + }); + return refs; +} + +/** + * Names `assembleOutput` appends `export { X as _auto_X }` for, in the order it appends them. The + * rewrite pass must keep these bindings alive even when nothing in the module body reads them, so + * both sides read this one list — a name exported here but stripped there dangles the export. + */ +export function autoExportedNames( + migrationDecisions: readonly MigrationDecision[] | undefined, + moduleLevelDecls: readonly ModuleLevelDecl[] | undefined +): string[] { + const decls = moduleLevelDecls ?? []; + const alreadyExported = new Set(decls.filter((d) => d.isExported).map((d) => d.name)); + const names: string[] = []; + const taken = new Set(); + const add = (name: string, force = false): void => { + if ((!force && alreadyExported.has(name)) || taken.has(name)) { + return; + } + taken.add(name); + names.push(name); + }; + + for (const decision of migrationDecisions ?? []) { + if (decision.action === 'reexport') { + add(decision.varName, decision.useAutoExport); + } + } + // The router discovers un-exported loaders/actions only through these exports. + for (const decl of decls + .filter((d) => d.hasRouterMarkerInit) + .map((d) => d.name) + .sort()) { + add(decl); + } + return names.sort(); +} + +function decideMigration( + decl: ModuleLevelDecl, + segmentUsage: Map>, + rootUsage: Set +): MigrationDecision { + const usingSegments = usingSegmentsOf(decl.name, segmentUsage); + const usedByAnySegment = usingSegments.length > 0; + const usedByRoot = rootUsage.has(decl.name); + + // The router finds un-exported loaders/actions through the `_auto_` re-export + // output-assembly appends, so moving or dropping one dangles that export. + if (decl.hasRouterMarkerInit && !decl.isExported) { + return usedByAnySegment + ? { action: 'reexport', varName: decl.name, reason: MIG_REASON.REEXPORT_ROUTER_MARKER } + : { action: 'keep', varName: decl.name, reason: MIG_REASON.KEEP_ROUTER_MARKER }; + } + if (decl.isExported && usedByAnySegment) { + return { action: 'reexport', varName: decl.name, reason: MIG_REASON.REEXPORT_EXPORTED }; + } + if (decl.isExported) { + return { action: 'keep', varName: decl.name, reason: MIG_REASON.KEEP_EXPORTED }; + } + if (usedByRoot && usedByAnySegment) { + return { action: 'reexport', varName: decl.name, reason: MIG_REASON.REEXPORT_DUAL_USE }; + } + if (usingSegments.length > 1) { + return { action: 'reexport', varName: decl.name, reason: MIG_REASON.REEXPORT_MULTI_SEGMENT }; + } + if (decl.hasSideEffects && usedByAnySegment) { + return { action: 'reexport', varName: decl.name, reason: MIG_REASON.REEXPORT_SIDE_EFFECTS }; + } + if (decl.isPartOfSharedDestructuring && usedByAnySegment) { + return { + action: 'reexport', + varName: decl.name, + reason: MIG_REASON.REEXPORT_SHARED_DESTRUCTURE, + }; + } + if (usingSegments.length === 1) { + return { + action: 'move', + varName: decl.name, + targetSegment: usingSegments[0], + reason: MIG_REASON.MOVE_SINGLE_SEGMENT, + }; + } + if ( + !usedByRoot && + !usedByAnySegment && + !decl.hasSideEffects && + !decl.isPartOfSharedDestructuring + ) { + return { action: 'drop', varName: decl.name, reason: MIG_REASON.DROP_UNREFERENCED }; + } + if (usedByRoot) { + return { action: 'keep', varName: decl.name, reason: MIG_REASON.KEEP_ROOT_USED }; + } + return { action: 'keep', varName: decl.name, reason: MIG_REASON.KEEP_UNUSED }; +} + +/** + * MIG-05a post-pass. When every binding in a shared-destructure declaration flows to the same + * single segment with no root/multi-segment/export/side-effect interference, promote them all from + * `reexport` to `move` targeting that segment. Mutates `decisions` in place; no-op otherwise. + * + * Preconditions per binding: not exported, no side effects, not used by root; used by exactly one + * segment; all siblings target the same segment. + */ +function promoteSharedDestructureGroups( + decls: ModuleLevelDecl[], + decisions: MigrationDecision[], + segmentUsage: Map>, + rootUsage: Set +): void { + const groupsByDeclSpan = new Map(); + for (let i = 0; i < decls.length; i++) { + if (!decls[i].isPartOfSharedDestructuring) { + continue; + } + const key = `${decls[i].declStart}:${decls[i].declEnd}`; + let group = groupsByDeclSpan.get(key); + if (!group) { + group = []; + groupsByDeclSpan.set(key, group); + } + group.push(i); + } + + for (const indices of groupsByDeclSpan.values()) { + if (indices.length < 2) { + continue; + } + const target = unifiedSingleSegmentTarget(indices, decls, segmentUsage, rootUsage); + if (!target) { + continue; + } + + for (const i of indices) { + decisions[i] = { + action: 'move', + varName: decls[i].name, + targetSegment: target, + reason: MIG_REASON.MOVE_SHARED_DESTRUCTURE_UNIFIED, + }; + } + } +} + +/** + * Returns the single segment all siblings flow to, or null if any sibling fails the MIG-05a + * preconditions. + */ +function unifiedSingleSegmentTarget( + indices: number[], + decls: ModuleLevelDecl[], + segmentUsage: Map>, + rootUsage: Set +): string | null { + let target: string | null = null; + + for (const i of indices) { + const d = decls[i]; + if (d.isExported || d.hasSideEffects || rootUsage.has(d.name)) { + return null; + } + + const using = usingSegmentsOf(d.name, segmentUsage); + if (using.length !== 1) { + return null; + } + + if (target === null) { + target = using[0]; + } else if (target !== using[0]) { + return null; + } + } + + return target; +} diff --git a/packages/ts-optimizer/src/optimizer/ast/binding-pattern.ts b/packages/ts-optimizer/src/optimizer/ast/binding-pattern.ts new file mode 100644 index 00000000000..01de564c2a2 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/ast/binding-pattern.ts @@ -0,0 +1,138 @@ +import type { + BindingPattern, + BindingRestElement, + FormalParameterRest, + TSParameterProperty, +} from '@oxc-project/types'; + +/** + * `BindingRestElement` and `FormalParameterRest` both surface as `type: "RestElement"` at runtime, + * so `visitBindingNames` handles them in one arm. + */ +export type BindingPatternLike = + | BindingPattern + | BindingRestElement + | FormalParameterRest + | TSParameterProperty; + +function formatPropertyKey(key: import('@oxc-project/types').PropertyKey): string | undefined { + if (key.type === 'Identifier') { + return key.name; + } + if (key.type === 'Literal' && ['string', 'number', 'bigint'].includes(typeof key.value)) { + return String(key.value); + } + return undefined; +} + +export function formatBindingPattern(pattern: BindingPatternLike): string | undefined { + switch (pattern.type) { + case 'Identifier': + return pattern.name; + case 'RestElement': { + const argument = formatBindingPattern(pattern.argument); + return argument === undefined ? undefined : `...${argument}`; + } + case 'ArrayPattern': { + if (pattern.elements.length === 0) { + return undefined; + } + return `[${pattern.elements + .map((element) => (element === null ? '' : formatBindingPattern(element))) + .filter((element) => element !== undefined) + .join(', ')}]`; + } + case 'ObjectPattern': { + const properties = pattern.properties.flatMap((property) => { + if (property.type === 'RestElement') { + return []; + } + const key = formatPropertyKey(property.key); + if (key === undefined) { + return []; + } + if (property.shorthand) { + return [key]; + } + const value = formatBindingPattern(property.value); + return value === undefined ? [] : [`${key}: ${value}`]; + }); + return properties.length === 0 ? undefined : `{${properties.join(', ')}}`; + } + case 'TSParameterProperty': + return formatBindingPattern(pattern.parameter); + case 'AssignmentPattern': + return undefined; + default: { + const _exhaustive: never = pattern; + throw new Error(`unhandled binding-pattern node: ${(_exhaustive as { type?: string }).type}`); + } + } +} + +function visitBindingNames( + node: BindingPatternLike | null | undefined, + visit: (name: string) => void +): void { + if (!node) { + return; + } + + switch (node.type) { + case 'Identifier': + visit(node.name); + break; + + case 'ObjectPattern': + for (const prop of node.properties ?? []) { + const target = prop.type === 'RestElement' ? prop.argument : prop.value; + visitBindingNames(target, visit); + } + break; + + case 'ArrayPattern': + for (const elem of node.elements ?? []) { + visitBindingNames(elem, visit); + } + break; + + case 'RestElement': + visitBindingNames(node.argument, visit); + break; + + case 'AssignmentPattern': + visitBindingNames(node.left, visit); + break; + + case 'TSParameterProperty': + visitBindingNames(node.parameter, visit); + break; + + default: { + const _exhaustive: never = node; + throw new Error(`unhandled binding-pattern node: ${(_exhaustive as { type?: string }).type}`); + } + } +} + +export function collectBindingNamesFromPattern( + pattern: BindingPatternLike | null | undefined +): string[] { + const names: string[] = []; + visitBindingNames(pattern, (name) => names.push(name)); + return names; +} + +export function appendBindingNamesFromPattern( + pattern: BindingPatternLike | null | undefined, + target: string[] +): void { + visitBindingNames(pattern, (name) => target.push(name)); +} + +export function addBindingNamesFromPatternToSet( + pattern: BindingPatternLike | null | undefined, + target: Set +): void { + visitBindingNames(pattern, (name) => target.add(name)); +} diff --git a/packages/ts-optimizer/src/optimizer/ast/guards.ts b/packages/ts-optimizer/src/optimizer/ast/guards.ts new file mode 100644 index 00000000000..391ff7b7997 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/ast/guards.ts @@ -0,0 +1,181 @@ +import type { + AssignmentPattern, + BindingIdentifier, + BindingProperty, + BindingRestElement, + IdentifierName, + IdentifierReference, + AstCompatMaybeNode, + AstCompatNode, + AstNode, + VariableDeclarator, +} from '../../ast-types.js'; + +const DEFAULT_META_KEYS = new Set(['type', 'start', 'end', 'loc', 'range']); + +export function isAstNode(value: unknown): value is AstCompatNode { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as { type?: unknown }; + return typeof candidate.type === 'string'; +} + +export type AstIdentifierNode = IdentifierName | IdentifierReference | BindingIdentifier; +export type AstRangedNode = AstCompatNode & { start: number; end: number }; + +export function hasRange(node: unknown): node is AstRangedNode { + return isAstNode(node) && typeof node.start === 'number' && typeof node.end === 'number'; +} + +export function isIdentifierNode(node: unknown): node is AstIdentifierNode { + return isAstNode(node) && node.type === 'Identifier' && typeof node.name === 'string'; +} + +export function isRangedIdentifierNode(node: unknown): node is AstIdentifierNode & AstRangedNode { + return isIdentifierNode(node) && hasRange(node); +} + +export function isAssignmentPatternNode(node: unknown): node is AssignmentPattern { + return isAstNode(node) && node.type === 'AssignmentPattern'; +} + +export function isPropertyNode(node: unknown): node is BindingProperty { + return isAstNode(node) && node.type === 'Property'; +} + +export function isRestElementNode(node: unknown): node is BindingRestElement { + return isAstNode(node) && node.type === 'RestElement'; +} + +export function isVariableDeclaratorNode(node: unknown): node is VariableDeclarator { + return isAstNode(node) && node.type === 'VariableDeclarator'; +} + +export function getPatternProperties(node: unknown): AstNode[] { + if (!isAstNode(node) || !Array.isArray(node.properties)) { + return []; + } + return node.properties.filter(isAstNode) as AstNode[]; +} + +export function getObjectPropertyKeyName(key: unknown): string | null { + if (isIdentifierNode(key)) { + return key.name; + } + if (isAstNode(key) && (key.type === 'StringLiteral' || key.type === 'Literal')) { + return key.value == null ? null : String(key.value); + } + return null; +} + +export function memberStaticPropName(node: AstNode): string | null { + if (node.type !== 'MemberExpression' || !node.property) { + return null; + } + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name; + } + if ( + node.computed && + node.property.type === 'Literal' && + typeof node.property.value === 'string' + ) { + return node.property.value; + } + return null; +} + +export function getAssignedIdentifierName(value: unknown): string | null { + if (isIdentifierNode(value)) { + return value.name; + } + if (isAssignmentPatternNode(value) && isIdentifierNode(value.left)) { + return value.left.name; + } + return null; +} + +export function forEachAstChild( + node: AstCompatMaybeNode, + visitor: (child: AstNode, key: string, parent: AstNode) => void, + skipKeys: ReadonlySet = DEFAULT_META_KEYS +): void { + if (!node || typeof node !== 'object') { + return; + } + + const compat = node as AstCompatNode; + for (const key of Object.keys(compat)) { + if (skipKeys.has(key)) { + continue; + } + + const value = compat[key]; + if (!value || typeof value !== 'object') { + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (isAstNode(item)) { + visitor(item as AstNode, key, compat as AstNode); + } + } + continue; + } + + if (isAstNode(value)) { + visitor(value as AstNode, key, compat as AstNode); + } + } +} + +export function someAstChild( + node: AstCompatMaybeNode, + predicate: (child: AstNode, key: string, parent: AstNode) => boolean, + skipKeys: ReadonlySet = DEFAULT_META_KEYS +): boolean { + if (!node || typeof node !== 'object') { + return false; + } + + const compat = node as AstCompatNode; + for (const key of Object.keys(compat)) { + if (skipKeys.has(key)) { + continue; + } + + const value = compat[key]; + if (!value || typeof value !== 'object') { + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (isAstNode(item) && predicate(item as AstNode, key, compat as AstNode)) { + return true; + } + } + continue; + } + + if (isAstNode(value) && predicate(value as AstNode, key, compat as AstNode)) { + return true; + } + } + return false; +} + +export function someAstDescendant( + node: AstCompatMaybeNode, + predicate: (node: AstNode) => boolean +): boolean { + if (!node || typeof node !== 'object') { + return false; + } + if (predicate(node as AstNode)) { + return true; + } + return someAstChild(node, (child) => someAstDescendant(child, predicate)); +} diff --git a/packages/ts-optimizer/src/optimizer/ast/identifier-name.ts b/packages/ts-optimizer/src/optimizer/ast/identifier-name.ts new file mode 100644 index 00000000000..cc554fcc632 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/ast/identifier-name.ts @@ -0,0 +1,9 @@ +const simpleIdentifierName = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +export function isSimpleIdentifierName(name: string): boolean { + return simpleIdentifierName.test(name); +} + +export function buildPropertyAccessor(base: string, key: string): string { + return isSimpleIdentifierName(key) ? `${base}.${key}` : `${base}["${key}"]`; +} diff --git a/packages/ts-optimizer/src/optimizer/ast/parse.ts b/packages/ts-optimizer/src/optimizer/ast/parse.ts new file mode 100644 index 00000000000..a8de8ee16ae --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/ast/parse.ts @@ -0,0 +1,25 @@ +import { parseSync, type ParseResult } from 'oxc-parser'; +import { RAW_TRANSFER_PARSER_OPTIONS } from '../../ast-types.js'; + +/** + * Raw transfer reserves one large ArrayBuffer per parse, which a memory-constrained machine can + * refuse even when the plain parser fits — a Windows CI runner hit this on the SSG build. The plain + * parser yields the same ESTree, so fall back to it and stop asking once the allocation has + * failed. + */ +let rawTransferUnavailable = false; + +export function parseWithRawTransfer(filename: string, sourceText: string): ParseResult { + if (rawTransferUnavailable || !RAW_TRANSFER_PARSER_OPTIONS.experimentalRawTransfer) { + return parseSync(filename, sourceText); + } + try { + return parseSync(filename, sourceText, RAW_TRANSFER_PARSER_OPTIONS); + } catch (err) { + if (!(err instanceof RangeError)) { + throw err; + } + rawTransferUnavailable = true; + return parseSync(filename, sourceText); + } +} diff --git a/packages/ts-optimizer/src/optimizer/ast/walk-with-protocol.ts b/packages/ts-optimizer/src/optimizer/ast/walk-with-protocol.ts new file mode 100644 index 00000000000..567bde09bd5 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/ast/walk-with-protocol.ts @@ -0,0 +1,31 @@ +/** + * Enter and exit get distinct context types so the enter handler cannot reach edit helpers — + * editing during enter yields plausible-but-wrong output. + */ + +import { walk } from 'oxc-walker'; +import type { ScopeTracker, WalkerThisContextEnter, WalkerThisContextLeave } from 'oxc-walker'; +import type { AstNode, AstProgram } from '../../ast-types.js'; + +export interface ProtocolHandlers { + enter(this: WalkerThisContextEnter, node: AstNode, parent: AstNode | null, ctx: E): void; + leave(this: WalkerThisContextLeave, node: AstNode, parent: AstNode | null, ctx: X): void; +} + +export function walkWithProtocol( + program: AstProgram, + enterCtx: E, + exitCtx: X, + handlers: ProtocolHandlers, + opts?: { readonly scopeTracker?: ScopeTracker } +): void { + walk(program, { + scopeTracker: opts?.scopeTracker, + enter(node, parent) { + handlers.enter.call(this, node as AstNode, parent as AstNode | null, enterCtx); + }, + leave(node, parent) { + handlers.leave.call(this, node as AstNode, parent as AstNode | null, exitCtx); + }, + }); +} diff --git a/packages/ts-optimizer/src/optimizer/diagnostics/diagnostic-detection.ts b/packages/ts-optimizer/src/optimizer/diagnostics/diagnostic-detection.ts new file mode 100644 index 00000000000..7998cce4033 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/diagnostics/diagnostic-detection.ts @@ -0,0 +1,275 @@ +import { walk } from 'oxc-walker'; +import type { AstEcmaScriptModule, AstFunction, AstNode, AstProgram } from '../../ast-types.js'; +import type { ExtractionResult } from '../extraction/extract.js'; +import { + classifyDeclarationTypeInClosure, + emitC02, + emitC03, + emitC05, + emitPassiveConflictWarning, +} from './diagnostics.js'; +import { collectExportNames } from '../extraction/marker-detection.js'; +import type { Diagnostic, DiagnosticHighlightFlat } from '../types/types.js'; +import type { ModuleGatherFacts, PassiveConflict } from '../analysis/module-gather-walk.js'; +import { getJsxAttributeName } from '../jsx/jsx-attr-name.js'; +import { plainQrlName } from '../qwik/qrl-naming.js'; +import { computeLineColFromOffset } from './source-loc.js'; +import { mkByteOffset, mkColumnNumber, mkLineNumber } from '../types/brands.js'; +import { resolveWholeBodyIdentifier } from '../rewrite/const-propagation.js'; + +type SourceRange = { start: number; end: number }; + +function buildHighlight(source: string, lo: number, hi: number): DiagnosticHighlightFlat { + const [startLine, startCol] = computeLineColFromOffset(source, lo); + const [endLine, endCol] = computeLineColFromOffset(source, hi); + return { + lo: mkByteOffset(lo + 1), + hi: mkByteOffset(hi + 1), + startLine: mkLineNumber(startLine), + startCol: mkColumnNumber(startCol), + endLine: mkLineNumber(endLine), + endCol: mkColumnNumber(endCol), + }; +} + +export function detectC03Diagnostics( + extractions: readonly ExtractionResult[], + captures: ModuleGatherFacts['nonFunctionCaptures'], + repairedCode: string, + originalCode: string, + file: string, + diagnostics: Diagnostic[] +): void { + for (const extraction of extractions) { + if (extraction.isWorkerEventWrapper) { + continue; + } + const capture = captures.get(extraction.symbolName); + if (!capture) { + continue; + } + const body = extraction.bodyText.trim(); + if ( + capture.scopeNode && + capture.names.length === 1 && + body === capture.names[0] && + resolveWholeBodyIdentifier(capture.scopeNode, repairedCode, body) + ) { + continue; + } + diagnostics.push( + emitC03( + capture.names, + file, + buildHighlight(originalCode, extraction.loc[0] - 1, extraction.loc[1] - 1) + ) + ); + } +} + +export function detectC02Diagnostics( + extractions: ExtractionResult[], + closureNodes: Map, + closureFreeIdentifiers: ReadonlyMap, + enclosingExtMap: Map, + importedNames: Set, + program: AstProgram, + file: string, + diagnostics: Diagnostic[] +): void { + for (const extraction of extractions) { + const closureNode = closureNodes.get(extraction.symbolName); + if (!closureNode) { + continue; + } + + const undeclaredIds = closureFreeIdentifiers.get(closureNode); + if (!undeclaredIds || undeclaredIds.length === 0) { + continue; + } + + const enclosingExt = enclosingExtMap.get(extraction.symbolName) ?? null; + const enclosingClosure = enclosingExt ? closureNodes.get(enclosingExt.symbolName) : undefined; + + // C02 fires only for fn/class refs inside an enclosing extraction's closure — + // module-level refs are handled by variable-migration and would false-positive. + if (!enclosingClosure) { + continue; + } + + type Classified = { refName: string; declType: 'var' | 'fn' | 'class' }; + const classified: Classified[] = []; + for (const refName of undeclaredIds) { + if (importedNames.has(refName)) { + continue; + } + let declType: 'var' | 'fn' | 'class'; + try { + declType = classifyDeclarationTypeInClosure(enclosingClosure, refName); + } catch { + declType = 'var'; + } + if (declType === 'fn' || declType === 'class') { + classified.push({ refName, declType }); + } + } + + if (classified.length === 0) { + continue; + } + + classified.sort((a, b) => (a.refName < b.refName ? -1 : a.refName > b.refName ? 1 : 0)); + for (const { refName } of classified) { + diagnostics.push(emitC02(refName, file)); + } + } +} + +export function detectC05Diagnostics( + program: AstProgram, + moduleInfo: AstEcmaScriptModule | undefined, + originalImports: Map< + string, + { + localName: string; + importedName: string; + source: string; + isQwikCore?: boolean; + } + >, + source: string, + file: string, + diagnostics: Diagnostic[] +): void { + const moduleExportNames = collectExportNames(program, moduleInfo); + + const targets = new Set(); + const targetToQrl = new Map(); + for (const exportName of moduleExportNames) { + if (!exportName.endsWith('$')) { + continue; + } + const importInfo = originalImports.get(exportName); + if (importInfo?.isQwikCore) { + continue; + } + const qrlName = plainQrlName(exportName); + if (moduleExportNames.has(qrlName)) { + continue; + } + targets.add(exportName); + targetToQrl.set(exportName, qrlName); + } + + if (targets.size === 0) { + return; + } + + const callSitesByName = collectCallSitesByName(program, targets); + + for (const [exportName, sites] of callSitesByName) { + const qrlName = targetToQrl.get(exportName); + if (!qrlName) { + continue; + } + for (const site of sites) { + diagnostics.push( + emitC05(exportName, qrlName, file, buildHighlight(source, site.start, site.end)) + ); + } + } +} + +export function emitPassiveConflictDiagnostics( + conflicts: ReadonlyArray, + file: string, + source: string, + diagnostics: Diagnostic[] +): void { + for (const conflict of conflicts) { + diagnostics.push( + emitPassiveConflictWarning( + conflict.eventName, + file, + buildHighlight(source, conflict.start, conflict.end) + ) + ); + } +} + +/** Retained as the differential oracle for the gather walk's passive-conflict projection. */ +export function detectPassivePreventdefaultConflicts( + program: AstProgram, + file: string, + source: string, + diagnostics: Diagnostic[] +): void { + walk(program, { + enter(node: AstNode, parent: AstNode | null) { + if (node.type !== 'JSXOpeningElement') { + return; + } + + const attrs = node.attributes ?? []; + const passiveEvents = new Set(); + const preventdefaultEvents = new Set(); + + for (const attr of attrs) { + if (attr.type !== 'JSXAttribute') { + continue; + } + + const name = getJsxAttributeName(attr); + + if (name.startsWith('passive:')) { + passiveEvents.add(name.slice('passive:'.length)); + } else if (name.startsWith('preventdefault:')) { + preventdefaultEvents.add(name.slice('preventdefault:'.length)); + } + } + + for (const eventName of passiveEvents) { + if (preventdefaultEvents.has(eventName)) { + diagnostics.push( + emitPassiveConflictWarning( + eventName, + file, + buildHighlight( + source, + node.start, + parent?.type === 'JSXElement' ? parent.end : node.end + ) + ) + ); + } + } + }, + }); +} + +function collectCallSitesByName( + program: AstProgram, + names: Set +): Map { + const out = new Map(); + walk(program, { + enter(node: AstNode) { + if ( + node.type !== 'CallExpression' || + node.callee?.type !== 'Identifier' || + !names.has(node.callee.name) + ) { + return; + } + const name = node.callee.name; + const bucket = out.get(name); + const entry = { start: node.callee.start, end: node.callee.end }; + if (bucket) { + bucket.push(entry); + } else { + out.set(name, [entry]); + } + }, + }); + return out; +} diff --git a/packages/ts-optimizer/src/optimizer/diagnostics/diagnostics.ts b/packages/ts-optimizer/src/optimizer/diagnostics/diagnostics.ts new file mode 100644 index 00000000000..146cde7e8f2 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/diagnostics/diagnostics.ts @@ -0,0 +1,206 @@ +import type { AstFunction, AstMaybeNode, AstNode, AstProgram } from '../../ast-types.js'; +import type { Diagnostic, DiagnosticHighlightFlat } from '../types/types.js'; + +export function emitC02(identName: string, file: string): Diagnostic { + return { + category: 'error', + code: 'C02', + file, + message: `Reference to identifier '${identName}' can not be used inside a Qrl($) scope because it's a function`, + highlights: null, + suggestions: null, + scope: 'optimizer', + }; +} + +export function emitC03( + identNames: readonly string[], + file: string, + highlightSpan: DiagnosticHighlightFlat +): Diagnostic { + return { + category: 'error', + code: 'C03', + file, + message: `Qrl($) scope is not a function, but it's capturing local identifiers: ${identNames.join(', ')}`, + highlights: [highlightSpan], + suggestions: null, + scope: 'optimizer', + }; +} + +export function emitC05( + calleeName: string, + qrlName: string, + file: string, + highlightSpan?: DiagnosticHighlightFlat +): Diagnostic { + return { + category: 'error', + code: 'C05', + file, + message: `Found '${calleeName}' but did not find the corresponding '${qrlName}' exported in the same file. Please check that it is exported and spelled correctly`, + highlights: highlightSpan ? [highlightSpan] : null, + suggestions: null, + scope: 'optimizer', + }; +} + +export function emitPassiveConflictWarning( + eventName: string, + file: string, + highlightSpan?: DiagnosticHighlightFlat +): Diagnostic { + return { + category: 'warning', + code: 'preventdefault-passive-check', + file, + message: `preventdefault:${eventName} has no effect when passive:${eventName} is also set; passive event listeners cannot call preventDefault()`, + highlights: highlightSpan ? [highlightSpan] : null, + suggestions: null, + scope: 'optimizer', + }; +} + +const DIRECTIVE_MARKER = '@qwik-disable-next-line'; + +const TRAILING_COMMENT_CLOSER = /\*\/\s*}?\s*$/; + +export function parseDisableDirectives(sourceCode: string): Map> { + const directives = new Map>(); + const lines = sourceCode.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const idx = lines[i].indexOf(DIRECTIVE_MARKER); + if (idx === -1) { + continue; + } + + const afterMarker = lines[i].slice(idx + DIRECTIVE_MARKER.length).trim(); + const cleaned = afterMarker.replace(TRAILING_COMMENT_CLOSER, '').trim(); + if (!cleaned) { + continue; + } + + const codes = cleaned + .split(',') + .map((c) => c.trim()) + .filter(Boolean); + if (codes.length === 0) { + continue; + } + + // Line i (0-based) suppresses line i+2 (1-based) + const suppressedLine = i + 2; + const existing = directives.get(suppressedLine) ?? new Set(); + for (const code of codes) { + existing.add(code); + } + directives.set(suppressedLine, existing); + } + + return directives; +} + +export function filterSuppressedDiagnostics( + diagnostics: Diagnostic[], + directives: Map> +): Diagnostic[] { + if (directives.size === 0) { + return diagnostics; + } + + return diagnostics.filter((diag) => { + if (!diag.highlights || diag.highlights.length === 0) { + return true; + } + + const suppressedCodes = directives.get(diag.highlights[0].startLine); + return !suppressedCodes?.has(diag.code); + }); +} + +type DeclKind = 'var' | 'fn' | 'class'; + +export function classifyDeclarationType(program: AstProgram, identName: string): DeclKind { + return classifyInStatements(program.body, identName); +} + +/** + * Walks a closure body directly, avoiding a re-parse when the caller already holds the closure AST + * node. + */ +export function classifyDeclarationTypeInClosure( + closure: AstFunction, + identName: string +): DeclKind { + if (closure.body?.type === 'BlockStatement') { + return classifyInStatements(closure.body.body ?? [], identName); + } + return classifyInExpression(closure.body, identName); +} + +function classifyInStatements(stmts: ReadonlyArray, identName: string): DeclKind { + for (const stmt of stmts) { + if (stmt.type === 'FunctionDeclaration' && stmt.id?.name === identName) { + return 'fn'; + } + if (stmt.type === 'ClassDeclaration' && stmt.id?.name === identName) { + return 'class'; + } + + let result: DeclKind = 'var'; + + if (stmt.type === 'ExpressionStatement' && stmt.expression) { + result = classifyInExpression(stmt.expression, identName); + } else if (stmt.type === 'ReturnStatement' && stmt.argument) { + result = classifyInExpression(stmt.argument, identName); + } else if (stmt.type === 'VariableDeclaration') { + for (const decl of stmt.declarations ?? []) { + if (decl.init) { + result = classifyInExpression(decl.init, identName); + if (result !== 'var') { + break; + } + } + } + } else if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration) { + result = classifyInStatements([stmt.declaration], identName); + } else if (stmt.type === 'ExportDefaultDeclaration' && stmt.declaration) { + result = classifyInExpression(stmt.declaration, identName); + } + + if (result !== 'var') { + return result; + } + } + return 'var'; +} + +function classifyInExpression(node: AstMaybeNode, identName: string): DeclKind { + if (!node) { + return 'var'; + } + + if (node.type === 'ParenthesizedExpression') { + return classifyInExpression(node.expression, identName); + } + + if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') { + if (node.body?.type === 'BlockStatement') { + return classifyInStatements(node.body.body ?? [], identName); + } + return classifyInExpression(node.body, identName); + } + + if (node.type === 'CallExpression') { + for (const arg of node.arguments ?? []) { + const result = classifyInExpression(arg, identName); + if (result !== 'var') { + return result; + } + } + } + + return 'var'; +} diff --git a/packages/ts-optimizer/src/optimizer/diagnostics/source-loc.ts b/packages/ts-optimizer/src/optimizer/diagnostics/source-loc.ts new file mode 100644 index 00000000000..a4af038fe39 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/diagnostics/source-loc.ts @@ -0,0 +1,13 @@ +export function computeLineColFromOffset(source: string, offset: number): [number, number] { + let line = 1; + let col = 1; + for (let i = 0; i < offset && i < source.length; i++) { + if (source[i] === '\n') { + line++; + col = 1; + } else { + col++; + } + } + return [line, col]; +} diff --git a/packages/ts-optimizer/src/optimizer/edit/identifier-boundary.ts b/packages/ts-optimizer/src/optimizer/edit/identifier-boundary.ts new file mode 100644 index 00000000000..dbd67e891a6 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/edit/identifier-boundary.ts @@ -0,0 +1,120 @@ +/** + * `\\b` treats `$` as a non-word char, so `\\bfoo$\\b` never matches a real use of a `$`-suffixed + * identifier. These lookaround boundaries treat `$` as part of the word. + */ +const identifierPatternCache = new Map(); + +function escapeForRegex(name: string): string { + return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function wholeIdentifierPattern(name: string): RegExp { + let pattern = identifierPatternCache.get(name); + if (!pattern) { + pattern = new RegExp(`(? { + if (end > codeStart) { + out += text.slice(codeStart, end).replace(pattern, replacement); + } + }; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '`') { + flush(i); + const tpl = emitTemplateLiteral(text, i, pattern, replacement); + out += tpl.text; + i = tpl.end; + codeStart = i + 1; + } else if (ch === '"' || ch === "'") { + flush(i); + const close = skipStringLiteralForward(text, i); + out += text.slice(i, Math.min(close + 1, text.length)); + i = close; + codeStart = i + 1; + } else if (ch === '/' && text[i + 1] === '*') { + flush(i); + const end = text.indexOf('*/', i + 2); + const stop = end < 0 ? text.length : end + 2; + out += text.slice(i, stop); + i = stop - 1; + codeStart = stop; + } else if (ch === '/' && text[i + 1] === '/') { + flush(i); + const nl = text.indexOf('\n', i); + const stop = nl < 0 ? text.length : nl; + out += text.slice(i, stop); + i = stop - 1; + codeStart = stop; + } + } + flush(text.length); + return out; +} + +/** + * Emit a template literal starting at `open` (the backtick): literal chunks verbatim, `${}` + * interpolation contents rewritten through `replaceOutsideStrings`. Returns the emitted text and + * the index of the closing backtick (or end of input when unterminated). + */ +function emitTemplateLiteral( + text: string, + open: number, + pattern: RegExp, + replacement: string +): { text: string; end: number } { + let out = '`'; + let i = open + 1; + while (i < text.length && text[i] !== '`') { + if (text[i] === '\\') { + out += text.slice(i, i + 2); + i += 2; + continue; + } + if (text[i] === '$' && text[i + 1] === '{') { + let depth = 1; + let k = i + 2; + while (k < text.length && depth > 0) { + const ch = text[k]; + if (ch === '"' || ch === "'" || ch === '`') { + k = skipStringLiteralForward(text, k) + 1; + continue; + } + if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) { + break; + } + } + k++; + } + out += '${' + replaceOutsideStrings(text.slice(i + 2, k), pattern, replacement); + if (k < text.length) { + out += '}'; + } + i = k + 1; + continue; + } + out += text[i]; + i++; + } + if (i < text.length) { + out += '`'; + } + return { text: out, end: i }; +} diff --git a/packages/ts-optimizer/src/optimizer/edit/import-format.ts b/packages/ts-optimizer/src/optimizer/edit/import-format.ts new file mode 100644 index 00000000000..eed4264d805 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/edit/import-format.ts @@ -0,0 +1,29 @@ +/** Shared formatting for rebuilding import statements from their parts. */ + +/** `imported as local` when they differ, else just `local`. */ +export function formatNamedImportPart(imported: string, local: string): string { + return imported !== local ? `${imported} as ${local}` : local; +} + +/** + * Assemble the clause between `import` and `from`: default and/or namespace, or default and/or + * named list. Returns '' when nothing survives. + */ +export function formatImportParts( + defaultPart: string, + nsPart: string, + namedParts: readonly string[] +): string { + if (nsPart) { + return defaultPart ? `${defaultPart}, ${nsPart}` : nsPart; + } + if (namedParts.length > 0) { + const named = `{ ${namedParts.join(', ')} }`; + return defaultPart ? `${defaultPart}, ${named}` : named; + } + return defaultPart; +} + +export function formatImportStatement(parts: string, quote: string, source: string): string { + return `import ${parts} from ${quote}${source}${quote};`; +} diff --git a/packages/ts-optimizer/src/optimizer/edit/range-replace.ts b/packages/ts-optimizer/src/optimizer/edit/range-replace.ts new file mode 100644 index 00000000000..f525e2296cf --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/edit/range-replace.ts @@ -0,0 +1,196 @@ +/** + * One AST walk that dispatches every node to every registered collector and gathers their range + * replacements. Collectors must emit disjoint ranges — the orchestrator does not check for overlap. + * A collector returning `skipSubtree: true` suppresses recursion into that node's children for all + * collectors (used when replacing an entire subtree, e.g. folding `1 + 2 + 3` to `6`, where + * recursing would emit overlapping inner ranges). + */ + +import type { AstMaybeNode, AstNode, AstParentNode } from '../../ast-types.js'; +import { forEachAstChild } from '../ast/guards.js'; + +export interface RangeReplacement { + readonly start: number; + readonly end: number; + readonly replacement: string; +} + +/** + * Context threaded to each collector at every visited node. `exprStart` is the source-absolute + * offset of `exprText[0]`; replacement ranges index into `exprText`. + */ +export interface CollectorContext { + readonly parentKey?: string; + readonly parentNode?: AstParentNode; + readonly exprStart: number; + readonly exprText: string; +} + +/** + * Result returned by a collector for a single node. `skipSubtree: true` suppresses recursion into + * this node's children for all collectors — use it when replacing an entire subtree (e.g. + * simplifying `1 + 2 + 3` to `6`). + */ +export interface CollectorResult { + readonly replacements: readonly RangeReplacement[]; + readonly skipSubtree?: boolean; +} + +/** Per-node visitor. Return `null` for "not interested, recurse normally". */ +export type RangeReplacementCollector = ( + node: AstNode, + ctx: CollectorContext +) => CollectorResult | null; + +/** Apply a list of disjoint range replacements to a source string. */ +export function applyReplacements( + text: string, + replacements: ReadonlyArray +): string { + if (replacements.length === 0) { + return text; + } + const sorted = [...replacements].sort((a, b) => a.start - b.start); + let out = ''; + let pos = 0; + for (const r of sorted) { + out += text.slice(pos, r.start); + out += r.replacement; + pos = r.end; + } + out += text.slice(pos); + return out; +} + +const TS_EXPRESSION_WRAPPERS = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSInstantiationExpression', + 'TSTypeAssertion', +]); + +export function collectRangeReplacements( + root: AstMaybeNode, + exprStart: number, + exprText: string, + collectors: readonly RangeReplacementCollector[] +): RangeReplacement[] { + const out: RangeReplacement[] = []; + if (collectors.length === 0) { + return out; + } + + function walk( + node: AstMaybeNode, + parentKey: string | undefined, + parentNode: AstParentNode | undefined + ): void { + if (!node || typeof node !== 'object') { + return; + } + // Type-annotation subtrees are never rewrite targets; expression wrappers + // (`x as T`, `x!`) still carry real expressions and must be walked. + const nodeType = (node as { type?: unknown }).type; + if ( + typeof nodeType === 'string' && + nodeType.startsWith('TS') && + !TS_EXPRESSION_WRAPPERS.has(nodeType) + ) { + return; + } + const ctx: CollectorContext = { parentKey, parentNode, exprStart, exprText }; + + let skipSubtree = false; + for (const collect of collectors) { + const result = collect(node, ctx); + if (!result) { + continue; + } + if (result.replacements.length > 0) { + out.push(...result.replacements); + } + if (result.skipSubtree) { + skipSubtree = true; + } + } + + if (skipSubtree) { + return; + } + forEachAstChild(node, (child, key, parent) => walk(child, key, parent)); + } + + walk(root, undefined, undefined); + return out; +} + +export interface ReplaceableIdentifierPositionOptions { + /** + * How to treat MemberExpression `property` positions. `'nonComputed'` (default) excludes `.foo` + * but allows `[foo]`; `'all'` excludes both. + */ + memberPropertyMode?: 'all' | 'nonComputed'; +} + +/** + * Whether an Identifier at this position is a _reference_ worth substituting — `false` for + * declarator names, property keys, member-access properties (per `memberPropertyMode`), and + * function parameters. + * + * Shorthand `Property` value positions return `true` here (not excluded); they need special-case + * emit and the caller detects them via `parentKey === 'value' && parentNode.shorthand === true`. + */ +export function isReplaceableIdentifierPosition( + parentKey: string | undefined, + parentNode: AstParentNode | undefined, + options: ReplaceableIdentifierPositionOptions = {} +): boolean { + if (parentKey === 'key' && parentNode?.type === 'Property') { + return false; + } + if (parentKey === 'property' && parentNode?.type === 'MemberExpression') { + if (options.memberPropertyMode === 'all') { + return false; + } + if (!parentNode.computed) { + return false; + } + } + if (parentKey === 'params') { + return false; + } + if (parentKey === 'id' && parentNode?.type === 'VariableDeclarator') { + return false; + } + return true; +} + +/** + * Whether a `??` expression at this position needs wrapping parens. Returns `true` for parents with + * precedence ≥ `??`; `LogicalExpression` is included because mixing `??` with `||`/`&&` is a syntax + * error without explicit parens. + */ +export function expressionNeedsParens( + parentKey: string | undefined, + parentNode: AstParentNode | undefined +): boolean { + if (!parentNode) { + return false; + } + switch (parentNode.type) { + case 'BinaryExpression': + case 'LogicalExpression': + case 'UnaryExpression': + case 'UpdateExpression': + case 'TaggedTemplateExpression': + return true; + case 'MemberExpression': + return parentKey === 'object'; + case 'NewExpression': + case 'CallExpression': + return parentKey === 'callee'; + default: + return false; + } +} diff --git a/packages/ts-optimizer/src/optimizer/edit/string-literal.ts b/packages/ts-optimizer/src/optimizer/edit/string-literal.ts new file mode 100644 index 00000000000..19b5cdfe345 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/edit/string-literal.ts @@ -0,0 +1,11 @@ +export function quoteAsStringLiteral(body: string): string { + const quote = body.includes('"') ? "'" : '"'; + const escaped = body + .replace(/\\/g, '\\\\') + .replaceAll(quote, '\\' + quote) + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + return quote + escaped + quote; +} diff --git a/packages/ts-optimizer/src/optimizer/edit/strip-types.ts b/packages/ts-optimizer/src/optimizer/edit/strip-types.ts new file mode 100644 index 00000000000..f6d009a437a --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/edit/strip-types.ts @@ -0,0 +1,119 @@ +import { parseSync } from 'oxc-parser'; +import { transformSync as oxcTransformSync, type TransformOptions } from 'oxc-transform'; +import { scanMatchingParenForward } from './text-scanning.js'; + +interface OxcError { + severity?: string; + message?: string; + codeframe?: string | null; +} + +function hasFatalError(errors: readonly OxcError[] | undefined): boolean { + return errors?.some((e) => e.severity !== 'Warning') === true; +} + +function formatOxcErrors(errors: readonly OxcError[] | undefined): string { + return (errors ?? []).map((e) => e.codeframe || e.message || String(e)).join('\n'); +} + +function normalizeExportedEnumBindings(filename: string, source: string, output: string): string { + if (!source.includes('enum')) { + return output; + } + const program = parseSync(filename, source).program; + for (const statement of program.body) { + if ( + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'TSEnumDeclaration' + ) { + const name = statement.declaration.id.name; + output = output.replace(`export let ${name} =`, `export var ${name} =`); + } + } + return output; +} + +/** `.ts` rejects JSX and `.tsx` rejects `(x) => x`, so the other dialect may still parse. */ +function otherTsDialect(filename: string): string | undefined { + if (filename.endsWith('.tsx')) { + return filename.slice(0, -1); + } + if (filename.endsWith('.ts')) { + return filename + 'x'; + } + return undefined; +} + +/** The module this code was generated from, used to tell our bugs apart from the user's. */ +export interface StripOrigin { + filename: string; + text: string; +} + +/** + * Strip TypeScript with oxc, throwing when the input does not parse. Returning the unstripped + * source instead would emit TS syntax into a `.js` file, which the bundler reports much later as a + * parse error pointing at the first type annotation rather than at the code that actually broke. + * `context` names what is being stripped so that report identifies the culprit. When `origin` is + * itself unparseable the user's own syntax error is passed through untouched, matching the Rust + * optimizer. + */ +export function stripTypeScript( + filename: string, + code: string, + options: TransformOptions, + context: string, + origin?: StripOrigin +): string { + const stripped = oxcTransformSync(filename, code, options); + if (!hasFatalError(stripped.errors)) { + return normalizeExportedEnumBindings(filename, code, stripped.code); + } + const alternate = otherTsDialect(filename); + if (alternate) { + const retry = oxcTransformSync(alternate, code, options); + if (!hasFatalError(retry.errors)) { + return normalizeExportedEnumBindings(alternate, code, retry.code); + } + } + if (origin && hasFatalError(parseSync(origin.filename, origin.text).errors)) { + return code; + } + throw new Error( + `Qwik optimizer: could not strip TypeScript from ${context}.\n${formatOxcErrors(stripped.errors)}` + ); +} + +/** + * Strip TypeScript syntax (`as` casts, non-null `!`, annotations) from an expression when present. + * Serialized expression strings execute in the browser via the container's qFuncs script, where + * leaked TS syntax is a page-wide SyntaxError. Expressions that already parse as plain JS are + * returned byte-identical; only genuinely-TS text pays the transform (and its reprint). Generic + * calls like `f(x)` parse as comparison chains in JS and are not detected — acceptable, as + * hoistable reactive expressions don't take type arguments. + */ +export function stripExpressionTypes(exprText: string): string { + const probe = parseSync('__expr__.mjs', `(${exprText});`); + if (!probe.errors?.length) { + return exprText; + } + const context = `reactive expression \`${exprText}\``; + const wrapped = `const __qs = (${exprText});`; + const out = stripTypeScript( + '__expr__.tsx', + wrapped, + { typescript: { onlyRemoveTypeImports: false }, jsx: 'preserve' }, + context + ); + // The transform may drop the redundant wrapping parens, so slice the + // declaration initializer instead of matching them. + const eqIdx = out.indexOf('='); + const endIdx = out.lastIndexOf(';'); + if (eqIdx < 0 || endIdx <= eqIdx) { + throw new Error(`Qwik optimizer: unexpected strip output for ${context}:\n${out}`); + } + const stripped = out.slice(eqIdx + 1, endIdx).trim(); + return stripped.startsWith('(') && scanMatchingParenForward(stripped, 1) === stripped.length + ? stripped.slice(1, -1) + : stripped; +} diff --git a/packages/ts-optimizer/src/optimizer/edit/text-scanning.ts b/packages/ts-optimizer/src/optimizer/edit/text-scanning.ts new file mode 100644 index 00000000000..e758a7dea0f --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/edit/text-scanning.ts @@ -0,0 +1,332 @@ +/** + * Character-level scanning for cases where the AST is unavailable or stale: post-transform text, + * iterative rewrites, and pre-parse repair. Each helper skips string literals to avoid matching + * inside quoted content. + */ + +export function isInsideString(text: string, offset: number): boolean { + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let templateDepth = 0; + for (let i = 0; i < offset; i++) { + const ch = text[i]; + if (ch === '\\' && (inSingle || inDouble || (inTemplate && templateDepth === 0))) { + i++; + continue; + } + if (inTemplate && templateDepth > 0) { + if (ch === '{') { + templateDepth++; + } else if (ch === '}') { + templateDepth--; + } + continue; + } + if (inTemplate && ch === '$' && text[i + 1] === '{') { + templateDepth = 1; + i++; + continue; + } + if (ch === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + } else if (ch === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + } else if (ch === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + } + } + return inSingle || inDouble || (inTemplate && templateDepth === 0); +} + +const PURE_ANNOTATION_AT_END = /\/\*\s*[#@]__PURE__\s*\*\/$/; + +/** + * Given the start of a call about to become a bare identifier, return the offset to overwrite from + * so a preceding PURE annotation is consumed with it. Left in front of a bare identifier the + * annotation is meaningless, and once a downstream transform reflows it onto its own line Rolldown + * aborts with INVALID_ANNOTATION. Whitespace before the annotation is preserved. + */ +export function pureAwareOverwriteStart(source: string, callStart: number): number { + let j = callStart; + while (j > 0 && /\s/.test(source[j - 1]!)) { + j--; + } + const match = PURE_ANNOTATION_AT_END.exec(source.slice(0, j)); + return match ? match.index : callStart; +} + +export function findMatchingBrace(text: string, openPos: number): number { + let depth = 1; + let inString: string | null = null; + let i = openPos + 1; + + while (i < text.length && depth > 0) { + const ch = text[i]; + + if (inString) { + if (ch === inString && text[i - 1] !== '\\') { + inString = null; + } + i++; + continue; + } + // Comments may contain apostrophes and braces; skip them wholesale. + if (ch === '/' && text[i + 1] === '/') { + const nl = text.indexOf('\n', i); + if (nl === -1) { + return -1; + } + i = nl + 1; + continue; + } + if (ch === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + if (end === -1) { + return -1; + } + i = end + 2; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { + inString = ch; + i++; + continue; + } + + if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + } + + if (depth === 0) { + return i; + } + i++; + } + return -1; +} + +/** + * Skip the string or template literal opening at `i`; returns the index of the closing quote. + * Template `${}` interpolations are skipped by brace depth, with string literals inside the + * interpolation (including nested templates) skipped recursively so their braces don't count. + */ +export function skipStringLiteralForward(text: string, i: number): number { + const quote = text[i]; + i++; + while (i < text.length && text[i] !== quote) { + if (text[i] === '\\') { + i += 2; + continue; + } + if (quote === '`' && text[i] === '$' && text[i + 1] === '{') { + i += 2; + let depth = 1; + while (i < text.length && depth > 0) { + const ch = text[i]; + if (ch === '"' || ch === "'" || ch === '`') { + i = skipStringLiteralForward(text, i) + 1; + continue; + } + if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + } + i++; + } + continue; + } + i++; + } + return i; +} + +/** + * Length-preserving copy with string/template contents and comments blanked to spaces (newlines + * kept), so position-based scanners see only code. The quote characters themselves are kept as + * anchors. Template `${}` interpolation contents blank too — declarations inside them go unseen, + * which scanners must treat as "not found", never as license to match raw text. + */ +const nonNewline = /[^\n]/g; + +/** Same length, every character but a newline replaced by a space. */ +function spacesLike(segment: string): string { + const firstNewline = segment.indexOf('\n'); + return firstNewline === -1 ? ' '.repeat(segment.length) : segment.replace(nonNewline, ' '); +} + +export function blankNonCode(text: string): string { + // Copy the code spans verbatim and blank only what lies between them. Building this per character + // costs an array of single-character strings per call, and this runs on nearly every module. + let out = ''; + let copied = 0; + const blank = (from: number, to: number): void => { + const stop = to < text.length ? to : text.length; + if (stop <= from) { + return; + } + out += text.slice(copied, from) + spacesLike(text.slice(from, stop)); + copied = stop; + }; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"' || ch === "'" || ch === '`') { + const close = skipStringLiteralForward(text, i); + blank(i + 1, close); + i = close; + } else if (ch === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + const stop = end < 0 ? text.length : end + 2; + blank(i, stop); + i = stop - 1; + } else if (ch === '/' && text[i + 1] === '/') { + const nl = text.indexOf('\n', i); + const stop = nl < 0 ? text.length : nl; + blank(i, stop); + i = stop - 1; + } + } + return copied === 0 ? text : out + text.slice(copied); +} + +/** Scan from `start` (just after the open paren) to the index one past the matching close paren. */ +export function scanMatchingParenForward(text: string, start: number): number { + let depth = 1; + let j = start; + while (j < text.length && depth > 0) { + const ch = text[j]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } else if (ch === "'" || ch === '"' || ch === '`') { + j = skipStringLiteralForward(text, j); + } + j++; + } + return j; +} + +/** Scan backward from `start` to the index of the matching open paren. */ +export function scanMatchingParenBackward(text: string, start: number): number { + let depth = 1; + let i = start; + while (i >= 0 && depth > 0) { + if (text[i] === ')') { + depth++; + } else if (text[i] === '(') { + depth--; + } + i--; + } + return i + 1; +} + +/** + * Find the end of an expression at `start`, respecting nested parens, braces, JSX angle brackets, + * and string literals. Terminates at a depth-0 newline, semicolon, or comma, or at an unmatched + * closing delimiter. + */ +export function findExpressionEnd(code: string, start: number): number { + let i = start; + let inString: string | null = null; + let angleBraceDepth = 0; + let parenDepth = 0; + let curlyDepth = 0; + + while (i < code.length) { + const ch = code[i]; + + if (inString) { + if (ch === inString && code[i - 1] !== '\\') { + inString = null; + } + i++; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { + inString = ch; + i++; + continue; + } + + if (ch === '(') { + parenDepth++; + i++; + continue; + } + if (ch === ')') { + if (parenDepth === 0) { + return i; + } + parenDepth--; + i++; + continue; + } + if (ch === '{') { + curlyDepth++; + i++; + continue; + } + if (ch === '}') { + if (curlyDepth === 0) { + return i; + } + curlyDepth--; + i++; + continue; + } + if (ch === '<') { + if (code[i + 1] === '/') { + const closeEnd = code.indexOf('>', i); + if (closeEnd >= 0 && angleBraceDepth > 0) { + angleBraceDepth--; + i = closeEnd + 1; + if (angleBraceDepth === 0 && parenDepth === 0 && curlyDepth === 0) { + return i; + } + continue; + } + } + + angleBraceDepth++; + let j = i + 1; + let tagCurly = 0; + while (j < code.length) { + if (code[j] === '{') { + tagCurly++; + } else if (code[j] === '}') { + tagCurly--; + } else if (code[j] === '>' && tagCurly === 0) { + if (code[j - 1] === '/') { + angleBraceDepth--; + i = j + 1; + if (angleBraceDepth === 0 && parenDepth === 0 && curlyDepth === 0) { + return i; + } + } else { + i = j + 1; + } + break; + } + j++; + } + if (j >= code.length) { + return code.length; + } + continue; + } + + if (angleBraceDepth === 0 && parenDepth === 0 && curlyDepth === 0) { + if (ch === '\n' || ch === ';' || ch === ',') { + return i; + } + } + + i++; + } + return i; +} diff --git a/packages/ts-optimizer/src/optimizer/edit/transform-session.ts b/packages/ts-optimizer/src/optimizer/edit/transform-session.ts new file mode 100644 index 00000000000..4a9c2406d77 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/edit/transform-session.ts @@ -0,0 +1,191 @@ +import MagicString from 'magic-string'; +import type { AstFunction, AstProgram } from '../../ast-types.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; + +/** + * Every session wraps the body identically (and parses under one filename) so the parse memo can + * key on the wrapped source and share a parse across consecutive helpers operating on the same + * text. + */ +const WRAPPER_PREFIX = 'const __seg__ = '; +const SESSION_FILENAME = '__session__.tsx'; + +/** + * Parse memo keyed by the exact wrapped source string, so a text change between helpers produces a + * different key and staleness is impossible. Sharing a parsed AST is safe: raw-transfer parses + * materialize plain JS objects (no buffer aliasing) and consumers read without mutating; each + * session gets its own MagicString. + */ +const PARSE_MEMO_CAP = 16; +const parseMemo = new Map>(); + +function memoizedParse(wrappedSource: string): ReturnType { + const hit = parseMemo.get(wrappedSource); + if (hit !== undefined) { + // Re-insert (LRU): move the hot entry to the newest slot so it survives eviction. + parseMemo.delete(wrappedSource); + parseMemo.set(wrappedSource, hit); + return hit; + } + const parsed = parseWithRawTransfer(SESSION_FILENAME, wrappedSource); + if (parseMemo.size >= PARSE_MEMO_CAP) { + const oldest = parseMemo.keys().next().value; + if (oldest !== undefined) { + parseMemo.delete(oldest); + } + } + parseMemo.set(wrappedSource, parsed); + return parsed; +} + +export interface TransformSession { + sourceText: string; + wrappedSource: string; + wrapperPrefix: string; + wrapperSuffix: string; + offset: number; + program: AstProgram; + edits: MagicString; + toSource(): string; +} + +export interface FunctionTransformSession extends TransformSession { + fn: AstFunction; +} + +interface TransformSessionOptions { + /** + * Proceed with the recovered AST when the parse reports recoverable errors. Analysis call sites + * whose inputs legitimately contain recoverable shapes (e.g. `() => await api()` in non-async + * position) opt in; edit-applying sites stay strict and bail to their unchanged-text fallback. + */ + tolerateErrors?: boolean; +} + +export function createTransformSession( + sourceText: string, + options: TransformSessionOptions = {} +): TransformSession | null { + const wrappedSource = WRAPPER_PREFIX + sourceText; + const parseResult = memoizedParse(wrappedSource); + + if (!parseResult.program) { + return null; + } + if (!options.tolerateErrors && parseResult.errors?.length) { + return null; + } + + // Lazy so read-only sessions don't pay MagicString's full-source walk. + let edits: MagicString | undefined; + return { + sourceText, + wrappedSource, + wrapperPrefix: WRAPPER_PREFIX, + wrapperSuffix: '', + offset: WRAPPER_PREFIX.length, + program: parseResult.program, + get edits(): MagicString { + if (edits === undefined) { + edits = new MagicString(wrappedSource); + } + return edits; + }, + toSource() { + if (edits === undefined) { + return this.sourceText; + } + const transformed = edits.toString(); + return transformed.slice( + this.wrapperPrefix.length, + transformed.length - this.wrapperSuffix.length + ); + }, + }; +} + +export function createFunctionTransformSession( + sourceText: string, + options: TransformSessionOptions = {} +): FunctionTransformSession | null { + const session = createTransformSession(sourceText, options); + if (!session) { + return null; + } + + const decl = session.program.body[0]; + if (!decl || decl.type !== 'VariableDeclaration') { + return null; + } + const init = decl.declarations?.[0]?.init; + if (!init) { + return null; + } + if (init.type !== 'ArrowFunctionExpression' && init.type !== 'FunctionExpression') { + return null; + } + if (!init.body) { + return null; + } + + // Object.assign (not spread): spreading would invoke the lazy `edits` + // getter, forcing the MagicString and freezing it as a data property. + return Object.assign(session, { fn: init }); +} + +export function insertFunctionBodyPrologue( + session: TransformSession, + fn: AstFunction, + line: string +): void { + if (!fn.body) { + return; + } + if (fn.body.type === 'BlockStatement') { + session.edits.appendLeft(fn.body.start + 1, `\n${line}`); + return; + } + + const expressionText = session.wrappedSource.slice(fn.body.start, fn.body.end); + session.edits.overwrite(fn.body.start, fn.body.end, `{\n${line}\nreturn ${expressionText};\n}`); +} + +export function replaceFunctionParams( + session: TransformSession, + fn: AstFunction, + paramNames: string[] +): boolean { + if (!fn.body) { + return false; + } + const paramList = paramNames.join(', '); + if (fn.params.length > 0) { + const firstParam = fn.params[0]!; + const lastParam = fn.params[fn.params.length - 1]!; + const hasParens = + session.wrappedSource[firstParam.start - 1] === '(' && + session.wrappedSource[lastParam.end] === ')'; + session.edits.overwrite( + firstParam.start, + lastParam.end, + hasParens ? paramList : `(${paramList})` + ); + return true; + } + + const prefix = session.wrappedSource.slice(fn.start, fn.body.start); + const openParen = prefix.lastIndexOf('('); + const closeParen = prefix.lastIndexOf(')'); + if (openParen === -1 || closeParen === -1 || closeParen < openParen) { + return false; + } + + const insertStart = fn.start + openParen + 1; + const insertEnd = fn.start + closeParen; + if (insertStart === insertEnd) { + session.edits.appendLeft(insertStart, paramList); + } else { + session.edits.overwrite(insertStart, insertEnd, paramList); + } + return true; +} diff --git a/packages/ts-optimizer/src/optimizer/extraction/context-stack.ts b/packages/ts-optimizer/src/optimizer/extraction/context-stack.ts new file mode 100644 index 00000000000..e86ea7c9382 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/extraction/context-stack.ts @@ -0,0 +1,71 @@ +import { buildDisplayName, buildSymbolName } from '../../hashing/naming.js'; +import { getFileStem } from '../../paths.js'; +import type { DisplayName, SymbolName } from '../types/brands.js'; + +const catchAllRouteParam = /^\[\[\.\.\.(.+)\]\]$/; +const dynamicRouteParam = /^\[(.+)\]$/; + +function extractFileStem(fileName: string): string { + const stem = getFileStem(fileName); + + const catchAllMatch = stem.match(catchAllRouteParam); + if (catchAllMatch) { + return catchAllMatch[1]!; + } + + const dynamicMatch = stem.match(dynamicRouteParam); + if (dynamicMatch) { + return dynamicMatch[1]!; + } + + return stem; +} + +export class ContextStack { + private stack: string[] = []; + private fileStem: string; + private fileName: string; + private relPath: string; + private scope: string | undefined; + + constructor(fileStem: string, relPath: string, scope?: string, fileName?: string) { + this.fileStem = fileStem; + this.fileName = fileName ?? fileStem; + this.relPath = relPath; + this.scope = scope; + } + + push(name: string): void { + this.stack.push(name); + } + + pop(): void { + this.stack.pop(); + } + + peek(offset: number = 0): string | undefined { + const idx = this.stack.length - 1 - offset; + return idx >= 0 ? this.stack[idx] : undefined; + } + + pushDefaultExport(): void { + const stem = extractFileStem(this.fileStem); + this.stack.push(stem); + } + + getDisplayName(): DisplayName { + return buildDisplayName(this.fileName, this.stack); + } + + getSymbolName(): SymbolName { + return buildSymbolName(this.getDisplayName(), this.scope, this.relPath); + } + + getContextStack(): string[] { + return [...this.stack]; + } + + get depth(): number { + return this.stack.length; + } +} diff --git a/packages/ts-optimizer/src/optimizer/extraction/extract.ts b/packages/ts-optimizer/src/optimizer/extraction/extract.ts new file mode 100644 index 00000000000..d131d208d63 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/extraction/extract.ts @@ -0,0 +1,1571 @@ +import { walk } from 'oxc-walker'; +import type { + AstEcmaScriptModule, + AstFunction, + AstNode, + AstParentNode, + AstParseResult, + AstProgram, + JSXAttributeItem, + JSXElementName, +} from '../../ast-types.js'; +import { qwikHash, qwikHashFromSeed } from '../../hashing/siphash.js'; +import { escapeSymbol } from '../../hashing/naming.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { getObjectPropertyKeyName } from '../ast/guards.js'; +import { ContextStack } from './context-stack.js'; +import { + collectImports, + collectCustomInlined, + getCalleeName, + isMarkerCall, + isSyncMarker, + getExtractionKind, + getExtractionName, + type CustomInlinedInfo, + type ImportInfo, +} from './marker-detection.js'; +import { + isEventProp, + transformEventPropName, + collectPassiveDirectives, +} from '../jsx/event-handlers.js'; +import { collectJsxFunctionNamesFromIterable } from '../jsx/jsx-call-transform.js'; +import { getBasename, getDirectory, getExtension, getFileStem } from '../../paths.js'; +import { detectForeignJsxRuntime } from '../jsx/jsx-import-source.js'; +import { getJsxAttributeName } from '../jsx/jsx-attr-name.js'; +import { getQrlCalleeName } from '../qwik/qrl-naming.js'; +import { + type BodyText, + type ByteOffset, + type CanonicalFilename, + type CtxName, + type DisplayName, + type Hash, + type Origin, + type SymbolName, + mkBodyText, + mkByteOffset, + mkCanonicalFilename, + mkCtxName, + mkDisplayName, + mkHash, + mkOrigin, + mkSymbolName, +} from '../types/brands.js'; + +/** + * Fields shared across all three phases of the extraction pipeline. Once set at extraction time + * (post-disambig + post-prod-rename + post-transpile-downgrade), these are immutable for the rest + * of the pipeline. Phase transitions use object spread to carry these forward. + */ +export interface ExtractionBase { + readonly symbolName: SymbolName; + readonly displayName: DisplayName; + readonly hash: Hash; + readonly canonicalFilename: CanonicalFilename; + + readonly callStart: ByteOffset; + readonly callEnd: ByteOffset; + readonly calleeStart: ByteOffset; + readonly calleeEnd: ByteOffset; + readonly argStart: ByteOffset; + readonly argEnd: ByteOffset; + + readonly bodyText: BodyText; + + readonly calleeName: string; + readonly isBare: boolean; + readonly isSync: boolean; + readonly qrlCallee: string; + readonly importSource: string; + + readonly ctxKind: 'function' | 'eventHandler' | 'jSXProp'; + readonly ctxName: CtxName; + readonly origin: Origin; + // Finalised after JSX-detection (leave handler) and transpile-downgrade. + readonly extension: string; + // The stripped-segment emission path derives its zeroed loc locally rather + // than mutating the consolidated extraction. + readonly loc: readonly [ByteOffset, ByteOffset]; + + // Finalised in the extraction walk's `leave` handler before the variant is exposed. + readonly segmentImports: readonly ImportInfo[]; + + readonly isInlinedQrl: boolean; + readonly explicitCaptures: string | null; + readonly inlinedQrlNameArg: string | null; + + readonly isComponentEvent: boolean; + + // Extracted from a JSX-factory call's object-property handler + // (`_jsxDEV("button", { onClick$: () => … })`) rather than a raw JSX + // attribute. The call site is a bare value (replaced in place with the QRL + // ref), so `buildNestedCallSites` routes it through the plain call-site path. + readonly isJsxObjectProp: boolean; + + // Synthetic event-handler segment wrapping a `worker$(fn)` attr value: + // `(event, element, ...lifted)=>workerQrl(q_X)(event, element, ...lifted)`. + readonly isWorkerEventWrapper: boolean; + // The `worker$(fn)` extraction living inside a wrapper — participates in + // event-capture promotion despite its `function` ctxKind. + readonly isWorkerEventHandler: boolean; +} + +/** + * Phase-spanning fields, mutable for the pipeline's in-place transition pattern (capture analysis, + * parent rewrite, event-capture promotion all mutate in place). Truly-immutable fields stay + * readonly on `ExtractionBase`. Consumers narrow on `phase`. + */ +interface ExtractionPhaseFields { + captureNames: string[]; + paramNames: string[]; + captures: boolean; + /** + * A direct event handler used at least one element capture that promotion lifted into params. + * Recorded at promotion time (before strip-mode clears captures) — drives the `.m()` marker. + */ + movedCaptures?: boolean; + /** At least one lifted capture is a non-const binding (param/let) — the entry classifies var. */ + liftedNonConst?: boolean; + parent: SymbolName | null; + propsFieldCaptures?: Map; + /** + * Parallel to `propsFieldCaptures` — for each captured prop field with a destructure-time default + * (`some = 1+2`), the default expression as source text. Nested segment bodies emit + * `(_rawProps. ?? )` so the runtime applies the default the way the parent body + * would. Keyed by local-binding name. + */ + propsFieldDefaults?: Map; + constLiterals?: Map; +} + +/** + * Phase 1 output. `captureNames`/`paramNames` are empty unless pre-populated by the inlinedQrl + * explicit-captures array. `parent` is `null` until parent rewrite resolves nesting. + */ +export interface ExtractedSegment extends ExtractionBase, ExtractionPhaseFields { + readonly phase: 'extracted'; +} + +/** Phase 3 output (post-capture-analysis). */ +export interface CapturedSegment extends ExtractionBase, ExtractionPhaseFields { + readonly phase: 'captured'; +} + +/** + * Phase 5 output. `parent` is resolved; `propsFieldCaptures` and `constLiterals` are finalised. + * Downstream consumers take this variant. + */ +export interface ConsolidatedSegment extends ExtractionBase, ExtractionPhaseFields { + readonly phase: 'consolidated'; +} + +/** + * Discriminated union over the three pipeline phases; consumers narrow on `phase`. `phase` is + * internal to the optimizer — it does not propagate to the NAPI `SegmentAnalysis` output. + */ +export type ExtractionResult = ExtractedSegment | CapturedSegment | ConsolidatedSegment; + +/** + * Strip `readonly` from every property of `T`. Phase-transition code casts an input array to the + * next phase's mutable builder, mutates in place, and returns it typed as the readonly next-phase + * variant; external consumers see the readonly union and narrow via `phase`. + */ +export type Mutable = { -readonly [P in keyof T]: T[P] }; + +/** Builder type for the extraction walker's WIP segment state. */ +export type ExtractedSegmentBuilder = Mutable; + +function nodeContainedIn(inner: AstNode, outer: AstNode): boolean { + return inner.start >= outer.start && inner.end <= outer.end; +} + +function extensionFromSegmentJsx(hasJsx: boolean, sourceExt: string): string { + if (hasJsx) { + return '.tsx'; + } + if (sourceExt === '.ts') { + return '.ts'; + } + return '.js'; +} + +/** + * `ContextStack` restricted to push/peek/read — `.pop()` is intentionally absent so the enter + * handler can't corrupt the context-stack/pushedNodes pairing (popping is exit-only). Same instance + * the exit handler sees as the full type. + */ +type ContextStackForEnter = Pick< + ContextStack, + 'push' | 'pushDefaultExport' | 'getDisplayName' | 'getSymbolName' | 'peek' +>; + +/** + * Enter-phase context for the extraction walker. `activeSegmentBodies` is readonly at the array + * level so enter can read the top frame and mutate its fields but can't pop — popping is + * exit-only. + */ +interface ExtractWalkEnterContext { + readonly source: string; + readonly relPath: string; + readonly scope: string | undefined; + readonly transpileJsx: boolean | undefined; + /** + * Fired once per discovered extraction at the creation node's enter, before the walker descends + * into the closure body. The fused gather walk uses it to register the closure node for its + * projections, keyed by extraction object identity (symbolName isn't final until + * `disambiguateExtractions` runs). + */ + readonly onExtraction?: (extraction: ExtractedSegment, closureNode: AstFunction | null) => void; + /** + * The user's explicit `transpileJsx` value (defaults false). Distinct from the derived + * `transpileJsx` above, which defaults TRUE for `.tsx`/`.jsx`. The ctxKind classifier needs the + * strict semantic. + */ + readonly explicitTranspileJsx: boolean; + readonly sourceExt: string; + readonly defaultExtension: string; + readonly fileStem: string; + readonly fileName: string; + readonly imports: Map; + readonly customInlined: Map; + readonly hasNonQwikJsxImportSource: boolean; + /** + * Local names that resolve to a Qwik JSX-runtime function (`jsx` / `jsxs` / `jsxDEV` from + * `@qwik.dev/core`, or anything from `@qwik.dev/core/jsx-runtime` / `.../jsx-dev-runtime`). Used + * to push naming context for peer-tool `jsx('tag', { onProp$: ... })` calls the same way JSX + * syntax does. + */ + readonly jsxFunctions: ReadonlySet; + /** + * Tagged ObjectExpressions that are the props-bag (second arg) of a recognised JSX-runtime call. + * Tag value is the tag-kind from the first arg — string-literal → `'html'`, identifier → + * `'component'`. + */ + readonly jsxPropObjects: Map; + readonly naming: ContextStackForEnter; + readonly activeSegmentBodies: readonly ActiveSegmentBody[]; + readonly pendingClosures: Array<{ extraction: ExtractionResult; node: AstFunction }>; + readonly pushedNodes: Map; + readonly parentMap: Map; + readonly results: ExtractedSegmentBuilder[]; + readonly pushActiveSegmentBody: (frame: ActiveSegmentBody) => void; +} + +/** + * Exit-phase context: extends the enter context with the two exit-only act-helpers and widens + * `naming` back to the full `ContextStack` so they can `.pop()`. Calling either helper from the + * enter handler is a compile error. + */ +interface ExtractWalkExitContext extends Omit { + readonly naming: ContextStack; + readonly finaliseTopFrameIfMatches: (node: AstNode) => void; + readonly popContextStackForNode: (node: AstNode) => void; +} + +/** + * Open segment bodies during the program walk — used to fold JSX detection into this walk without + * extra subtree walks. + */ +type ActiveSegmentBody = { + leaveNode: AstNode; + root: AstNode; + /** + * The WIP segment under construction, mutated during the walker's `leave` handler (extension + * flip, segmentImports). Exposed as the readonly `ExtractedSegment` once the walker completes. + */ + result: ExtractedSegmentBuilder; + hasJsx: boolean; + /** + * When set, the outer walk accumulates referenced Identifier names here as it descends. On + * `leave`, `segmentImports` is computed from this set instead of re-walking the body — avoids an + * O(extractionCount × programSize) cost. + */ + bodyIds?: Set; +}; + +/** Resolve aliased import back to its original name (e.g., `c$` -> `component$`). */ +function resolveCanonicalCalleeName(calleeName: string, imports: Map): string { + const importInfo = imports.get(calleeName); + return importInfo ? importInfo.importedName : calleeName; +} + +/** + * When a marker call's single argument is an `Identifier` resolving to an import binding, segments + * derive their displayName + hash from the import path rather than the surrounding context stack — + * so `useStyles$(css3)` with `import css3 from './style.css'` produces `style_css` / a hash of + * `./style.css#default`, stable across files importing the same asset. Returns null for + * non-Identifier args or Identifiers that don't resolve to an import; callers fall back to + * stack-based naming. + * + * Also handles the namespace-member form (`$(ns.foo)` with `import * as ns`). + */ +function getImportArgNaming( + arg: AstNode, + imports: Map, + relPath: string +): { importContextPortion: string; hashSeed: string } | null { + let importInfo: ImportInfo | undefined; + let importedName: string; + if (arg.type === 'Identifier') { + importInfo = imports.get(arg.name); + if (!importInfo || importInfo.importedName === '*') { + return null; + } + importedName = importInfo.importedName; + } else if ( + arg.type === 'MemberExpression' && + !(arg as { computed?: boolean }).computed && + (arg as { object?: AstNode }).object?.type === 'Identifier' && + (arg as { property?: AstNode }).property?.type === 'Identifier' + ) { + const member = arg as unknown as { + object: { name: string }; + property: { name: string }; + }; + importInfo = imports.get(member.object.name); + // Only namespace imports: `obj.prop` on a named import is a property + // access on a value, not a module member reference. + if (!importInfo || importInfo.importedName !== '*') { + return null; + } + importedName = member.property.name; + } else { + return null; + } + + // The hash seed and path-tail use the resolved form: without resolution, + // `./style.css` would hash differently from `style.css` even though both + // name the same module. + const resolvedSource = resolveImportHashPath(importInfo.source, relPath); + if (resolvedSource === null) { + return null; + } + + const slashIdx = resolvedSource.lastIndexOf('/'); + const pathTail = slashIdx >= 0 ? resolvedSource.slice(slashIdx + 1) : resolvedSource; + const baseName = escapeSymbol(pathTail); + const importContextPortion = + importedName === 'default' ? baseName : `${baseName}_${escapeSymbol(importedName)}`; + + const hashSeed = `${resolvedSource}#${importedName}`; + + return { importContextPortion, hashSeed }; +} + +/** + * Absolute / bare specifiers pass through; relative paths resolve against the directory of + * `relPath`. + */ +function resolveImportHashPath(importPath: string, relPath: string): string | null { + const normalized = importPath.replace(/\\/g, '/'); + if (!normalized.startsWith('.')) { + return normalized; + } + + const baseDir = getDirectory(relPath); + const segments = baseDir.split('/').filter((s) => s !== ''); + for (const segment of normalized.split('/')) { + if (segment === '' || segment === '.') { + continue; + } + if (segment === '..') { + // Escaping the source root: no stable path exists, so the caller + // falls back to position-based naming (matches Rust). + if (segments.length === 0) { + return null; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join('/'); +} + +/** + * Bare $() segments inherit the wrapper call name as naming context, e.g., component($(() => {})) + * -> "..._component". + */ +function getDirectWrapperContextName( + node: AstNode, + parent: AstParentNode, + imports: Map, + customInlined: Map +): string | null { + if (parent?.type !== 'CallExpression') { + return null; + } + if (!parent.arguments.some((arg) => arg === node)) { + return null; + } + if (isMarkerCall(parent, imports, customInlined)) { + return null; + } + // Plain identifier callees already pushed via the generic call-callee rule. + if (parent.callee?.type === 'Identifier') { + return null; + } + + const wrapperCallee = getCalleeName(parent); + if (!wrapperCallee) { + return null; + } + + return resolveCanonicalCalleeName(wrapperCallee, imports); +} + +function getDirectQrlWrapperContextName( + node: AstNode, + parent: AstParentNode, + imports: Map +): string | null { + if ( + parent?.type !== 'CallExpression' || + parent.callee?.type !== 'Identifier' || + !parent.arguments.some((arg) => arg === node) + ) { + return null; + } + const wrapperName = resolveCanonicalCalleeName(parent.callee.name, imports); + return wrapperName.endsWith('Qrl') ? wrapperName.slice(0, -3) + '$' : null; +} + +function collectIdentifiers(node: AstNode): Set { + const ids = new Set(); + walk(node, { + enter(n: AstNode) { + if (n.type === 'Identifier') { + ids.add(n.name); + } + }, + }); + return ids; +} + +/** + * File-level imports referenced by the given identifier set. Hot path feeds the Set accumulated + * during the outer walk (no extra walk); cold path falls back to `collectSegmentImports`, which + * re-walks the body. + */ +function filterImportsByIds(ids: Set, imports: Map): ImportInfo[] { + const result: ImportInfo[] = []; + for (const [localName, info] of imports) { + if (ids.has(localName)) { + result.push(info); + } + } + return result; +} + +function collectSegmentImports(bodyNode: AstNode, imports: Map): ImportInfo[] { + return filterImportsByIds(collectIdentifiers(bodyNode), imports); +} + +function isComponentTag(tagNode: JSXElementName | null | undefined): boolean { + if (tagNode?.type === 'JSXIdentifier') { + const ch = tagNode.name[0]; + return ch === ch.toUpperCase() && ch !== ch.toLowerCase(); + } + // Member expressions like Foo.Bar are always components + return tagNode?.type === 'JSXMemberExpression'; +} + +/** `on*$` (incl. `document:on*$` / `window:on*$`) event-attribute name shape. */ +const onEventAttrName = /^(?:document:|window:)?on[A-Z-]/; + +/** + * The hash is the suffix after the last underscore of a stack-composed symbol name + * (`renderHeader1_jMxQsjbyDss`); a name with no underscore is used whole. + */ +function hashFromSymbolName(symbolName: SymbolName): Hash { + const lastUnder = symbolName.lastIndexOf('_'); + return mkHash(lastUnder >= 0 ? symbolName.slice(lastUnder + 1) : symbolName); +} + +/** + * Two-rule ctxKind classification shared by the JSX-attribute and pre-transformed JSX object-prop + * paths. `explicitTranspileJsx` true → element- kind rule (Component → jSXProp); false → + * name-prefix rule (`on*$` → eventHandler regardless of element kind). The + * marker-call-inside-JSX-attribute path has its own name-prefix rule and deliberately doesn't share + * this helper. + */ +function classifyJsxHandlerCtxKind( + propName: string, + isComponentEvent: boolean, + explicitTranspileJsx: boolean +): 'eventHandler' | 'jSXProp' { + if (explicitTranspileJsx) { + return isComponentEvent ? 'jSXProp' : 'eventHandler'; + } + const isOnEventAttr = onEventAttrName.test(propName); + return isComponentEvent && !isOnEventAttr ? 'jSXProp' : 'eventHandler'; +} + +/** + * Per-path inputs for {@link buildExtractedSegment}. Required fields are those that diverge between + * the four extraction paths; optional fields are per-path overrides of a shared default. + * Derivations and empty collections live in the factory, so adding an `ExtractionBase` field is a + * one-site change. + */ +interface ExtractedSegmentSpec { + readonly symbolName: SymbolName; + readonly displayName: DisplayName; + readonly hash: Hash; + readonly callStart: number; + readonly callEnd: number; + readonly calleeStart: number; + readonly calleeEnd: number; + readonly argStart: number; + readonly argEnd: number; + readonly bodyText: string; + readonly calleeName: string; + readonly ctxKind: 'function' | 'eventHandler' | 'jSXProp'; + readonly ctxName: CtxName; + readonly relPath: string; + readonly extension: string; + readonly isBare?: boolean; + readonly isSync?: boolean; + readonly qrlCallee?: string; + readonly importSource?: string; + readonly captureNames?: string[]; + readonly segmentImports?: ImportInfo[]; + readonly isInlinedQrl?: boolean; + readonly explicitCaptures?: string | null; + readonly inlinedQrlNameArg?: string | null; + readonly isComponentEvent?: boolean; + readonly isJsxObjectProp?: boolean; + readonly isWorkerEventWrapper?: boolean; + readonly isWorkerEventHandler?: boolean; +} + +/** Single construction point for Phase 1 extraction records. */ +function buildExtractedSegment(spec: ExtractedSegmentSpec): ExtractedSegmentBuilder { + const captureNames = spec.captureNames ?? []; + return { + phase: 'extracted', + symbolName: spec.symbolName, + displayName: spec.displayName, + hash: spec.hash, + canonicalFilename: mkCanonicalFilename(spec.displayName + '_' + spec.hash), + callStart: mkByteOffset(spec.callStart), + callEnd: mkByteOffset(spec.callEnd), + calleeStart: mkByteOffset(spec.calleeStart), + calleeEnd: mkByteOffset(spec.calleeEnd), + argStart: mkByteOffset(spec.argStart), + argEnd: mkByteOffset(spec.argEnd), + bodyText: mkBodyText(spec.bodyText), + calleeName: spec.calleeName, + isBare: spec.isBare ?? false, + isSync: spec.isSync ?? false, + qrlCallee: spec.qrlCallee ?? '', + importSource: spec.importSource ?? '', + ctxKind: spec.ctxKind, + ctxName: spec.ctxName, + origin: mkOrigin(spec.relPath), + extension: spec.extension, + // `loc` carries byte offsets, not line/col — the `[byteStart, byteEnd]` + // contract the snap fixtures encode. + loc: [mkByteOffset(spec.argStart), mkByteOffset(spec.argEnd)], + parent: null, + captures: captureNames.length > 0, + captureNames, + paramNames: [], + // Empty unless overridden; collected in the walker's leave handler via bodyIds. + segmentImports: spec.segmentImports ?? [], + isInlinedQrl: spec.isInlinedQrl ?? false, + explicitCaptures: spec.explicitCaptures ?? null, + inlinedQrlNameArg: spec.inlinedQrlNameArg ?? null, + isComponentEvent: spec.isComponentEvent ?? false, + isJsxObjectProp: spec.isJsxObjectProp ?? false, + isWorkerEventWrapper: spec.isWorkerEventWrapper ?? false, + isWorkerEventHandler: spec.isWorkerEventHandler ?? false, + }; +} + +/** Per-host options for {@link createExtractionCollector}. */ +export interface ExtractionCollectorOptions { + readonly source: string; + readonly relPath: string; + readonly program: AstProgram; + readonly parserModule?: AstEcmaScriptModule; + readonly scope?: string; + readonly transpileJsx?: boolean; + /** See the `explicitTranspileJsx` parameter of {@link extractSegments}. */ + readonly explicitTranspileJsx?: boolean; + /** See the `closureNodesOut` parameter of {@link extractSegments}. */ + readonly closureNodesOut?: Map; + /** See {@link ExtractWalkEnterContext.onExtraction}. */ + readonly onExtraction?: (extraction: ExtractedSegment, closureNode: AstFunction | null) => void; +} + +/** + * The Phase-1 extraction walk as a composable collector: per-node enter/leave handlers plus a + * post-walk `finish`. Two hosts drive it — `extractSegments` (standalone, retained as the + * differential oracle) and `gatherModuleFacts` (the canonical gather walk, sharing its single + * traversal). The enter/exit protocol is enforced by the split context views: host walks cannot + * cross the phases. + */ +export interface ExtractionCollector { + enter(node: AstNode, parent: AstNode | null): void; + leave(node: AstNode): void; + /** + * Post-walk act: disambiguate colliding display names, populate `closureNodesOut`, and expose the + * WIP builders as readonly `ExtractedSegment`. Call exactly once, after the host walk completes. + */ + finish(): readonly ExtractedSegment[]; +} + +export function createExtractionCollector( + options: ExtractionCollectorOptions +): ExtractionCollector { + const { source, relPath, scope, transpileJsx, program } = options; + const explicitTranspileJsx = options.explicitTranspileJsx; + const closureNodesOut = options.closureNodesOut; + + const imports = collectImports(program, options.parserModule); + const customInlined = collectCustomInlined(program); + + const relDir = getDirectory(relPath); + const fileStem = + getFileStem(relPath) === 'index' && relDir ? getBasename(relDir) : getBasename(relPath); + const sourceExt = getExtension(relPath) || '.js'; + const defaultExtension = extensionFromSegmentJsx(false, sourceExt); + const fileName = getBasename(relPath); + const naming = new ContextStack(fileStem, relPath, scope, fileName); + + // The walker builds WIP segments via mutation (push on enter, mutate + // on leave). The function returns `readonly ExtractedSegment[]` at the + // boundary — the mutation is internal-only. See `ExtractedSegmentBuilder`. + const results: ExtractedSegmentBuilder[] = []; + const activeSegmentBodies: ActiveSegmentBody[] = []; + /** + * Pairings of (extraction → its closure AST node) collected during the walk. Populated into + * `closureNodesOut` after `disambiguateExtractions` mutates `symbolName` in place — extraction + * object identity stays stable through the rename, so the pairing remains correct. + */ + const pendingClosures: Array<{ extraction: ExtractionResult; node: AstFunction }> = []; + + const pushedNodes = new Map(); + const parentMap = new Map(); + + // Suppress JSX $-suffixed attribute extraction when a non-Qwik + // @jsxImportSource is set. The shared helper also detects the pragma + // text for downstream phases (parent rewrite, segment codegen). + const hasNonQwikJsxImportSource = detectForeignJsxRuntime(source).hasForeignJsxRuntime; + + // Identify local bindings that resolve to a Qwik JSX-runtime callable. + // Re-uses the same predicate used in segment codegen for the + // `jsx() → _jsxSorted` rewrite. Empty when the file imports no + // jsx-runtime names (e.g. pure JSX-syntax sources); the walker + // short-circuits cheaply. + const jsxFunctions: ReadonlySet = collectJsxFunctionNamesFromIterable(imports.values()); + const jsxPropObjects = new Map(); + + // Split walk state into Enter and Exit context views so the type system + // enforces "enter pushes, leave pops." Enter sees `naming` as a + // push-only `ContextStackForEnter`; Exit sees the full `ContextStack` + // plus the two act-helpers. Calling `ctx.naming.pop()` or + // `ctx.popContextStackForNode(node)` from the enter handler is a + // compile error. + const enterCtx: ExtractWalkEnterContext = { + source, + relPath, + scope, + transpileJsx, + explicitTranspileJsx: explicitTranspileJsx === true, + onExtraction: options.onExtraction, + sourceExt, + defaultExtension, + fileStem, + fileName, + imports, + customInlined, + hasNonQwikJsxImportSource, + jsxFunctions, + jsxPropObjects, + naming, + activeSegmentBodies, + pendingClosures, + pushedNodes, + parentMap, + results, + pushActiveSegmentBody: (frame) => { + activeSegmentBodies.push(frame); + }, + }; + + const exitCtx: ExtractWalkExitContext = { + ...enterCtx, + naming, + finaliseTopFrameIfMatches: (node) => { + const activeTop = activeSegmentBodies[activeSegmentBodies.length - 1]; + if (activeTop?.leaveNode === node) { + if (activeTop.hasJsx) { + activeTop.result.extension = extensionFromSegmentJsx(true, sourceExt); + } + if (activeTop.bodyIds) { + activeTop.result.segmentImports = filterImportsByIds(activeTop.bodyIds, imports); + } + activeSegmentBodies.pop(); + } + }, + popContextStackForNode: (node) => { + const count = pushedNodes.get(node); + if (count !== undefined) { + for (let i = 0; i < count; i++) { + naming.pop(); + } + pushedNodes.delete(node); + } + }, + }; + + const handlers = { + enter(node: AstNode, parent: AstParentNode, ctx: ExtractWalkEnterContext): void { + if (parent) { + ctx.parentMap.set(node, parent); + } + + // Accumulate Identifier names into each active body's deferred set so + // `segmentImports` is computed on leave without a per-extraction body-walk. + // Gate on `nodeContainedIn(node, seg.root)` so the enclosing marker + // callee (`$`, `useTask$`, …) doesn't leak into bodyIds — otherwise the + // segment would emit a spurious `import { $ }` for the marker wrapping it. + if (node.type === 'Identifier' && node.name) { + for (const seg of ctx.activeSegmentBodies) { + if (seg.bodyIds && nodeContainedIn(node, seg.root)) { + seg.bodyIds.add(node.name); + } + } + } + + if (node.type === 'JSXElement' || node.type === 'JSXFragment') { + for (const seg of ctx.activeSegmentBodies) { + if (!seg.hasJsx && nodeContainedIn(node, seg.root)) { + seg.hasJsx = true; + } + } + } + + let pushCount = 0; + + if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') { + ctx.naming.push(node.id.name); + pushCount++; + } + + if (node.type === 'FunctionDeclaration' && node.id) { + ctx.naming.push(node.id.name); + pushCount++; + } + + if (node.type === 'Property' && node.key?.type === 'Identifier') { + // When the surrounding ObjectExpression was tagged as a peer-tool JSX + // props bag (see the JSX-runtime CallExpression arm below), apply the + // same naming rules JSX syntax uses for `` — + // `q_e_` for HTML tags, `` for components. + const jsxKind = + parent?.type === 'ObjectExpression' ? ctx.jsxPropObjects.get(parent) : undefined; + const rawKey = node.key.name; + // Skip the `children` key when naming a JSX props bag, so a handler + // nested under `{ children: _jsxDEV("button", …) }` is named + // `…_button_…`, not `…_children_button_…`. Plain object literals don't + // contribute keys at all — enclosing call callees name those contexts. + if (jsxKind && rawKey !== 'children') { + let pushedKey: string; + if (rawKey.endsWith('$')) { + if (jsxKind === 'component') { + pushedKey = rawKey.slice(0, -1); + } else { + // HTML tag — match the JSX-attribute event-handler branch. Passive + // directives aren't a props-bag concept, so pass an empty Set; + // `transformEventPropName` falls back to raw-name push for + // non-event props. + const transformed = transformEventPropName(rawKey, new Set()); + pushedKey = transformed ? transformed.replace(/[-:]/g, '_') : rawKey; + } + } else { + pushedKey = rawKey; + } + ctx.naming.push(pushedKey); + pushCount++; + } + } + + if (node.type === 'MethodDefinition' && node.key?.type === 'Identifier') { + ctx.naming.push(node.key.name); + pushCount++; + } + + // Non-marker $-suffixed calls still contribute to naming (e.g., useMemo$() -> "useMemo") + if (node.type === 'CallExpression' && node.callee?.type === 'Identifier') { + const calleeName = node.callee.name; + if (calleeName.endsWith('$') && !isMarkerCall(node, imports, customInlined)) { + ctx.naming.push(calleeName.slice(0, -1)); + pushCount++; + } else if ( + // Peer-tool `jsx('tag', { onProp$: ... })` calls: push the tag from + // arg[0] before descending into the props bag, so a `$()` segment + // inside the bag inherits the tag in its displayName — the peer-tool + // form of the `JSXElement` branch below (qwik-react codegen etc.). + ctx.jsxFunctions.has(calleeName) && + node.arguments && + node.arguments.length >= 2 + ) { + const tagArg = node.arguments[0]; + const propsArg = node.arguments[1]; + if (propsArg?.type === 'ObjectExpression') { + let tagPush: string | null = null; + let tagKind: 'html' | 'component' | null = null; + if (tagArg?.type === 'Literal' && typeof tagArg.value === 'string') { + tagPush = tagArg.value; + tagKind = 'html'; + } else if (tagArg?.type === 'Identifier') { + tagPush = tagArg.name; + tagKind = 'component'; + } + // Member-expression or other non-pushable tag (e.g. `jsx(M.N, ...)`): + // no push, but still tag the props bag so child Property keys use the + // component-style rule (strip `$`) rather than the literal push. + if (tagPush) { + ctx.naming.push(tagPush); + pushCount++; + } + if (tagKind) { + ctx.jsxPropObjects.set(propsArg, tagKind); + } else { + // Default unknown tag kind to component-style stripping (for + // non-literal-string tag expressions). + ctx.jsxPropObjects.set(propsArg, 'component'); + } + } + } else if (!isMarkerCall(node, imports, customInlined)) { + // Plain identifier calls (`children(...)`, `it('…', () => …)`) name + // the closures inside their arguments. + ctx.naming.push(calleeName); + pushCount++; + } + } + + // Push the tag name on JSXElement (not JSXOpeningElement) so it stays on + // the stack for all children — JSXOpeningElement is a sibling of children, + // so pushing there would pop before them. + if (node.type === 'JSXElement' && node.openingElement) { + const opening = node.openingElement; + const tagName = + opening.name?.type === 'JSXIdentifier' + ? opening.name.name + : opening.name?.type === 'JSXMemberExpression' + ? (opening.name.property?.name ?? '') + : ''; + if (tagName) { + ctx.naming.push(tagName); + pushCount++; + } + } + + // `<>` becomes `jsx(Fragment, ...)` only under transpileJsx, so Fragment + // enters the naming context only then. + if (node.type === 'JSXFragment' && transpileJsx) { + ctx.naming.push('Fragment'); + pushCount++; + } + + if (node.type === 'JSXAttribute') { + const rawAttrName = getJsxAttributeName(node); + if (rawAttrName.endsWith('$') && isEventProp(rawAttrName)) { + const isComponentElement = + parent?.type === 'JSXOpeningElement' && isComponentTag(parent.name); + if (isComponentElement) { + ctx.naming.push(rawAttrName.slice(0, -1)); + } else { + const jsxOpening = parent?.type === 'JSXOpeningElement' ? parent : null; + const siblingAttrs: JSXAttributeItem[] = jsxOpening?.attributes ?? []; + const passiveEvents = collectPassiveDirectives(siblingAttrs); + const transformed = transformEventPropName(rawAttrName, passiveEvents); + if (transformed) { + ctx.naming.push(transformed.replace(/[-:]/g, '_')); + } else { + ctx.naming.push(rawAttrName); + } + } + } else if (rawAttrName.endsWith('$') && rawAttrName.startsWith('host:')) { + const stripped = rawAttrName.slice(5, -1); + ctx.naming.push('host_' + stripped); + } else { + ctx.naming.push(rawAttrName); + } + pushCount++; + } + + if (node.type === 'ExportDefaultDeclaration') { + const decl = node.declaration; + const hasName = + (decl?.type === 'FunctionDeclaration' && decl.id) || + (decl?.type === 'ClassDeclaration' && decl.id); + if (!hasName) { + ctx.naming.pushDefaultExport(); + pushCount++; + } + } + + // inlinedQrl is a pre-processed QRL — must be detected before regular marker calls + if ( + node.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + (node.callee.name === 'inlinedQrl' || node.callee.name === '_inlinedQrl') && + imports.get(node.callee.name)?.isQwikCore && + node.arguments?.length >= 2 + ) { + const calleeName = node.callee.name; + const arg0 = node.arguments[0]; + const arg1 = node.arguments[1]; + const arg2 = node.arguments[2]; + + const nameValue = + arg1?.type === 'Literal' && typeof arg1.value === 'string' ? arg1.value : null; + + const isNullBody = arg0?.type === 'Literal' && arg0.value === null; + + if (arg0 && nameValue && !isNullBody) { + const bodyText = source.slice(arg0.start, arg0.end); + + // Split into display + hash portion. The 8+-alphanumeric gate matches + // `HASH_SHAPE` in `types/brands.ts`, so `lastPart` is always a valid `Hash`. + const lastUnder = nameValue.lastIndexOf('_'); + let inlinedHash: Hash; + let displayNameSuffix: string; + if (lastUnder > 0) { + const lastPart = nameValue.slice(lastUnder + 1); + if (lastPart.length >= 8 && /^[a-zA-Z0-9]+$/.test(lastPart)) { + inlinedHash = mkHash(lastPart); + displayNameSuffix = nameValue.slice(0, lastUnder); + } else { + inlinedHash = mkHash(nameValue); + displayNameSuffix = nameValue; + } + } else { + inlinedHash = mkHash(nameValue); + displayNameSuffix = nameValue; + } + + const inlinedDisplayName = mkDisplayName(fileStem + '_' + displayNameSuffix); + + let explicitCapturesText: string | null = null; + const inlinedCaptureNames: string[] = []; + if (arg2?.type === 'ArrayExpression') { + explicitCapturesText = source.slice(arg2.start, arg2.end); + for (const elem of arg2.elements ?? []) { + if (elem?.type === 'Identifier') { + inlinedCaptureNames.push(elem.name); + } + } + } + + // Derive ctxName from the surrounding context: a wrapping Qrl call + // (`componentQrl(...)` → "component$"), a jsx-factory props bag + // (`_jsxSplit("form", { onSubmit$: inlinedQrl(...) })` → the factory + // callee), or the owning declarator (`const goto = inlinedQrl(...)`, + // possibly behind conditional/logical wrappers). + let inlinedCtxName = nameValue; + let isInlinedJsxObjectProp = false; + let cursor = parent as AstNode | undefined; + while ( + cursor && + (cursor.type === 'ConditionalExpression' || + cursor.type === 'LogicalExpression' || + cursor.type === 'ParenthesizedExpression') + ) { + cursor = parentMap.get(cursor) as AstNode | undefined; + } + const qrlToDollar = (name: string): string => + name.endsWith('Qrl') ? name.slice(0, -3) + '$' : name; + const propsObject = cursor?.type === 'Property' ? parentMap.get(cursor) : undefined; + const jsxKind = + propsObject?.type === 'ObjectExpression' + ? ctx.jsxPropObjects.get(propsObject) + : undefined; + const propName = + cursor?.type === 'Property' ? getObjectPropertyKeyName(cursor.key) : null; + if (jsxKind && propName?.endsWith('$')) { + isInlinedJsxObjectProp = true; + inlinedCtxName = + jsxKind === 'html' + ? (transformEventPropName(propName, new Set()) ?? propName) + : propName; + } else if ( + cursor?.type === 'VariableDeclarator' && + (cursor as { id?: { type: string; name?: string } }).id?.type === 'Identifier' + ) { + inlinedCtxName = qrlToDollar((cursor as unknown as { id: { name: string } }).id.name); + } else { + while ( + cursor && + (cursor.type === 'Property' || + cursor.type === 'ObjectExpression' || + cursor.type === 'ArrayExpression') + ) { + cursor = parentMap.get(cursor) as AstNode | undefined; + } + if (cursor?.type === 'CallExpression') { + const parentCallee = getCalleeName(cursor); + if (parentCallee) { + inlinedCtxName = qrlToDollar(resolveCanonicalCalleeName(parentCallee, imports)); + } + } else { + // Deeply nested (`const serverQrl = (…) => … inlinedQrl(…)`) + // takes the nearest enclosing named declaration as context. + while (cursor) { + if ( + (cursor.type === 'VariableDeclarator' || cursor.type === 'FunctionDeclaration') && + (cursor as { id?: { type: string; name?: string } }).id?.type === 'Identifier' + ) { + inlinedCtxName = qrlToDollar( + (cursor as unknown as { id: { name: string } }).id.name + ); + break; + } + cursor = parentMap.get(cursor) as AstNode | undefined; + } + } + } + + // Initial value; a function `arg0` whose body has JSX gets its + // extension flipped in the leave handler. Static-value `arg0` keeps + // `sourceExt`. + const extension = sourceExt; + + // Function arg0 defers segmentImports collection to the outer walk; + // non-function arg0 (rare cold path) falls back to the inline sub-walk. + let inlinedSegmentImports: ImportInfo[] = []; + if (arg0.type !== 'ArrowFunctionExpression' && arg0.type !== 'FunctionExpression') { + inlinedSegmentImports = collectSegmentImports(arg0, ctx.imports); + } + + const extraction = buildExtractedSegment({ + symbolName: mkSymbolName(nameValue), + displayName: inlinedDisplayName, + hash: inlinedHash, + callStart: node.start, + callEnd: node.end, + calleeStart: node.callee.start, + calleeEnd: node.callee.end, + argStart: arg0.start, + argEnd: arg0.end, + bodyText, + calleeName, + importSource: imports.get(calleeName)?.source ?? '@qwik.dev/core', + ctxKind: 'function', + ctxName: mkCtxName(inlinedCtxName), + relPath, + extension, + captureNames: inlinedCaptureNames, + segmentImports: inlinedSegmentImports, + isInlinedQrl: true, + explicitCaptures: explicitCapturesText, + inlinedQrlNameArg: nameValue, + isJsxObjectProp: isInlinedJsxObjectProp, + }); + ctx.results.push(extraction); + if (arg0.type === 'ArrowFunctionExpression' || arg0.type === 'FunctionExpression') { + // Same JSX-detection as other extractions: JSX in arg0's body flips + // the extension in the leave handler. Defensive — current peer tools + // pre-transform JSX, but the gate must be ready for those that don't. + ctx.pushActiveSegmentBody({ + leaveNode: node, + root: arg0, + result: extraction, + hasJsx: false, + bodyIds: new Set(), + }); + ctx.pendingClosures.push({ extraction, node: arg0 }); + ctx.onExtraction?.(extraction, arg0); + } else { + ctx.onExtraction?.(extraction, null); + } + } + + if (pushCount > 0) { + ctx.pushedNodes.set(node, pushCount); + } + return; + } + + if (node.type === 'CallExpression' && isMarkerCall(node, ctx.imports, ctx.customInlined)) { + const calleeName = getCalleeName(node); + if (!calleeName) { + if (pushCount > 0) { + ctx.pushedNodes.set(node, pushCount); + } + return; + } + + const canonicalCallee = resolveCanonicalCalleeName(calleeName, imports); + + // `onClick$={worker$(fn)}` needs a synthetic wrapper handler segment: + // `(event, element, ...lifted)=>workerQrl(q_X)(event, element, ...lifted)`. + // Its naming matches a direct attr handler, so it's created before the + // `worker$` push extends the stack. + let workerEventAttrName: string | null = null; + if (canonicalCallee === 'worker$' && parent?.type === 'JSXExpressionContainer') { + const jsxAttrParent = parentMap.get(parent); + if (jsxAttrParent?.type === 'JSXAttribute') { + const attrFullName = getJsxAttributeName(jsxAttrParent); + if (attrFullName.endsWith('$') && onEventAttrName.test(attrFullName)) { + workerEventAttrName = attrFullName; + const wrapperDisplayName = ctx.naming.getDisplayName(); + const wrapperSymbolName = ctx.naming.getSymbolName(); + const wrapper = buildExtractedSegment({ + symbolName: wrapperSymbolName, + displayName: wrapperDisplayName, + hash: hashFromSymbolName(wrapperSymbolName), + callStart: jsxAttrParent.start, + callEnd: jsxAttrParent.end, + calleeStart: jsxAttrParent.name.start, + calleeEnd: jsxAttrParent.name.end, + argStart: node.start, + argEnd: node.end, + bodyText: source.slice(node.start, node.end), + calleeName: attrFullName, + ctxKind: 'eventHandler', + ctxName: mkCtxName(attrFullName), + relPath, + extension: defaultExtension, + isWorkerEventWrapper: true, + }); + ctx.results.push(wrapper); + ctx.onExtraction?.(wrapper, null); + } + } + } + + const wrapperContext = + canonicalCallee === '$' + ? getDirectWrapperContextName(node, parent, imports, customInlined) + : null; + const qrlWrapperContext = + canonicalCallee === '$' ? getDirectQrlWrapperContextName(node, parent, imports) : null; + if (wrapperContext) { + ctx.naming.push(wrapperContext); + pushCount++; + } + + // Bare `$()` doesn't push the callee — only wrapper context + counter matter. + if (canonicalCallee !== '$') { + ctx.naming.push(calleeName); + pushCount++; + } + + const arg = node.arguments?.[0]; + if (!arg) { + if (pushCount > 0) { + ctx.pushedNodes.set(node, pushCount); + } + return; + } + + const bodyText = source.slice(arg.start, arg.end); + const isBare = canonicalCallee === '$'; + const isSync = isSyncMarker(canonicalCallee); + const qrlCallee = getQrlCalleeName(canonicalCallee); + + // Detect if this $() call is inside a JSX attribute (CallExpr -> JSXExprContainer -> JSXAttr) + let isEventAttr = false; + let isJsxNonEventAttr = false; + let attrCtx: string | undefined; + + if (parent?.type === 'JSXExpressionContainer') { + const jsxAttrParent = parentMap.get(parent); + if (jsxAttrParent?.type === 'JSXAttribute') { + const jsxAttrName = getJsxAttributeName(jsxAttrParent); + if (jsxAttrName.endsWith('$')) { + attrCtx = ctx.naming.peek(1) ?? jsxAttrName; + + // ctxKind has two classification paths gated on transpileJsx: the + // element-kind rule (Component vs HTML) when set, the name-prefix + // rule (`on*$`) when not. This optimizer always sees raw JSX, so + // the gate is explicit here. + const jsxOpeningElement = parentMap.get(jsxAttrParent); + const isComponentElement = + jsxOpeningElement?.type === 'JSXOpeningElement' && + isComponentTag(jsxOpeningElement.name); + + if (ctx.explicitTranspileJsx) { + // Element-kind rule: HTML → eventHandler; Component → jSXProp. + if (isComponentElement) { + isEventAttr = false; + isJsxNonEventAttr = true; + } else { + isEventAttr = true; + isJsxNonEventAttr = false; + } + } else { + // Name-prefix rule: `on*$` (incl. `document:on*$` / `window:on*$`) + // → eventHandler; anything else → jSXProp. + const isOnEventAttr = onEventAttrName.test(jsxAttrName); + if (isOnEventAttr) { + isEventAttr = true; + isJsxNonEventAttr = false; + } else { + isEventAttr = false; + isJsxNonEventAttr = true; + } + } + } + } + } + + // Inside a wrapper, the worker segment keeps its marker identity — + // the wrapper owns the event-handler role. + const ctxKind = workerEventAttrName + ? 'function' + : getExtractionKind(canonicalCallee, isEventAttr, isJsxNonEventAttr); + const isJsxAttrContext = !workerEventAttrName && (isEventAttr || isJsxNonEventAttr); + const ctxName = mkCtxName( + workerEventAttrName + ? canonicalCallee + : (qrlWrapperContext ?? + getExtractionName( + canonicalCallee, + isJsxAttrContext, + isJsxAttrContext ? attrCtx : undefined + )) + ); + + // When the marker's first arg is a single Identifier resolving to an + // import binding, derive displayName + hash from the import path so the + // segment name stays stable across files importing the same asset. Falls + // back to stack-based naming when the helper returns null. + const importNaming = !isJsxAttrContext ? getImportArgNaming(arg, imports, relPath) : null; + + let displayName: DisplayName; + let symbolName: SymbolName; + let hash: Hash; + if (importNaming !== null) { + const fileStem = getBasename(relPath); + displayName = mkDisplayName(`${fileStem}_${importNaming.importContextPortion}`); + hash = qwikHashFromSeed(importNaming.hashSeed); + symbolName = mkSymbolName(`${importNaming.importContextPortion}_${hash}`); + } else { + displayName = ctx.naming.getDisplayName(); + symbolName = ctx.naming.getSymbolName(); + hash = hashFromSymbolName(symbolName); + } + + const extraction = buildExtractedSegment({ + symbolName, + displayName, + hash, + callStart: node.start, + callEnd: node.end, + calleeStart: node.callee.start, + calleeEnd: node.callee.end, + argStart: arg.start, + argEnd: arg.end, + bodyText, + calleeName: canonicalCallee, + isBare, + isSync, + qrlCallee, + importSource: imports.get(calleeName)?.source ?? '', + ctxKind, + ctxName, + relPath, + extension: defaultExtension, + isWorkerEventHandler: workerEventAttrName !== null, + }); + ctx.results.push(extraction); + ctx.pushActiveSegmentBody({ + leaveNode: node, + root: arg, + result: extraction, + hasJsx: false, + bodyIds: new Set(), + }); + if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') { + ctx.pendingClosures.push({ extraction, node: arg }); + ctx.onExtraction?.(extraction, arg); + } else { + ctx.onExtraction?.(extraction, null); + } + } + + // JSX $-suffixed attribute extraction (e.g., onClick$={expr}) + let jsxAttrName: string | null = null; + if ( + node.type === 'JSXAttribute' && + node.value?.type === 'JSXExpressionContainer' && + node.value.expression + ) { + const full = getJsxAttributeName(node); + if (full.endsWith('$')) { + jsxAttrName = full; + } + } + if ( + jsxAttrName !== null && + !hasNonQwikJsxImportSource && + node.type === 'JSXAttribute' && + node.value?.type === 'JSXExpressionContainer' && + node.value.expression + ) { + const attrName = jsxAttrName; + const expr = node.value.expression; + + if (expr.type === 'CallExpression' && isMarkerCall(expr, imports, customInlined)) { + // Handled by marker call detection above + } else if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') { + const bodyText = source.slice(expr.start, expr.end); + + const isComponentEvent = + parent?.type === 'JSXOpeningElement' && isComponentTag(parent.name); + + const ctxKind = classifyJsxHandlerCtxKind( + attrName, + isComponentEvent, + explicitTranspileJsx === true + ); + const ctxName = mkCtxName(attrName); + + const displayName = ctx.naming.getDisplayName(); + const symbolName = ctx.naming.getSymbolName(); + const hash = hashFromSymbolName(symbolName); + + const extraction = buildExtractedSegment({ + symbolName, + displayName, + hash, + callStart: node.start, + callEnd: node.end, + calleeStart: node.name.start, + calleeEnd: node.name.end, + argStart: expr.start, + argEnd: expr.end, + bodyText, + calleeName: attrName, + ctxKind, + ctxName, + relPath, + extension: defaultExtension, + isComponentEvent, + }); + ctx.results.push(extraction); + ctx.pushActiveSegmentBody({ + leaveNode: node, + root: expr, + result: extraction, + hasJsx: false, + bodyIds: new Set(), + }); + // Safe cast: the enclosing if already gated `expr.type` to a function expression. + ctx.pendingClosures.push({ extraction, node: expr as AstFunction }); + ctx.onExtraction?.(extraction, expr as AstFunction); + } + } + + // Pre-transformed JSX: a `$`-suffixed handler in a JSX-factory call's + // props bag (`_jsxDEV("button", { onClick$: () => … })`). esbuild + // transpiles `.tsx` to `_jsxDEV(...)` before the optimizer runs, so these + // handlers arrive as object properties, not JSX attributes. Without this + // branch they stay inline — never lazy-loaded — and any module-level + // binding they reference (e.g. `const testServer$ = server$(…)`) gets + // mis-attributed and dropped. Extract them into their own segment like a + // JSX attribute. The marker-call form (`onClick$: $(() => …)`) is already + // caught by the CallExpression walker above, so this handles only bare + // function values. + if ( + node.type === 'Property' && + !node.computed && + !hasNonQwikJsxImportSource && + parent?.type === 'ObjectExpression' && + ctx.jsxPropObjects.has(parent) && + (node.value?.type === 'ArrowFunctionExpression' || + node.value?.type === 'FunctionExpression') + ) { + const rawKey = getObjectPropertyKeyName(node.key); + + if (rawKey !== null && rawKey.endsWith('$')) { + const propKey = rawKey; + const value = node.value; + const bodyText = source.slice(value.start, value.end); + + // HTML-tag props bags get `q-e:click`-style event handlers; + // component-tag bags pass the `*$` prop through as a JSX prop. + const jsxKind = ctx.jsxPropObjects.get(parent); + const isComponentEvent = jsxKind === 'component'; + + const ctxKind = classifyJsxHandlerCtxKind( + propKey, + isComponentEvent, + explicitTranspileJsx === true + ); + const ctxName = mkCtxName(propKey); + + // The prop key was already pushed by the `Property` naming branch + // above, so these reflect the full call-site context. + const displayName = ctx.naming.getDisplayName(); + const symbolName = ctx.naming.getSymbolName(); + const hash = hashFromSymbolName(symbolName); + + const extraction = buildExtractedSegment({ + symbolName, + displayName, + hash, + // Call site is the bare value: replacing `[callStart, callEnd]` with + // the QRL ref leaves `onClick$: q_` for the later + // `_jsxDEV`→`_jsxSorted` rewrite to rename + slice. + callStart: value.start, + callEnd: value.end, + calleeStart: node.key.start, + calleeEnd: node.key.end, + argStart: value.start, + argEnd: value.end, + bodyText, + calleeName: propKey, + ctxKind, + ctxName, + relPath, + extension: defaultExtension, + isComponentEvent, + isJsxObjectProp: true, + }); + ctx.results.push(extraction); + ctx.pushActiveSegmentBody({ + leaveNode: node, + root: value, + result: extraction, + hasJsx: false, + bodyIds: new Set(), + }); + ctx.pendingClosures.push({ extraction, node: value as AstFunction }); + ctx.onExtraction?.(extraction, value as AstFunction); + } + } + + if (pushCount > 0) { + ctx.pushedNodes.set(node, pushCount); + } + }, + + leave(node: AstNode, ctx: ExtractWalkExitContext): void { + ctx.finaliseTopFrameIfMatches(node); + ctx.popContextStackForNode(node); + }, + }; + + return { + enter: (node, parent) => handlers.enter(node, parent, enterCtx), + leave: (node) => handlers.leave(node, exitCtx), + finish: () => { + // Pass `fileName` (e.g. "index.tsx"), not `fileStem`: `buildDisplayName` + // uses it as the strip-prefix, and for routing files `fileStem` differs + // ("foo" for `routes/foo/index.tsx`), leaving `.tsx` embedded in the + // contextPortion — which `mkSymbolName` rejects. + disambiguateExtractions(results, fileName, relPath, scope); + + if (closureNodesOut) { + for (const { extraction, node } of pendingClosures) { + closureNodesOut.set(extraction.symbolName, node); + } + } + + return results; + }, + }; +} + +export function extractSegments( + source: string, + relPath: string, + scope?: string, + transpileJsx?: boolean, + preParsedProgram?: AstProgram, + /** When `preParsedProgram` is set, optional module metadata from the same parse. */ + preParsedModule?: AstEcmaScriptModule, + /** + * Optional out-map. When provided, populated with each extraction's closure AST node keyed by the + * post-disambiguation `symbolName`, so callers skip a per-extraction body re-parse. + */ + closureNodesOut?: Map, + /** + * The user's explicit `transpileJsx` value (defaults false). Distinct from the derived + * `transpileJsx` above, which defaults TRUE for `.tsx`/`.jsx` when the user omits the flag. The + * ctxKind classifier needs the strict semantic to select between the name-prefix (default) and + * element-kind (active) rules. + */ + explicitTranspileJsx?: boolean +): readonly ExtractedSegment[] { + const parseResult: AstParseResult | null = preParsedProgram + ? null + : parseWithRawTransfer(relPath, source); + const program = preParsedProgram ?? parseResult!.program; + + const collector = createExtractionCollector({ + source, + relPath, + program, + parserModule: preParsedProgram ? preParsedModule : parseResult?.module, + scope, + transpileJsx, + explicitTranspileJsx, + closureNodesOut, + }); + + walk(program, { + enter(node, parent) { + collector.enter(node as AstNode, parent as AstNode | null); + }, + leave(node) { + collector.leave(node as AstNode); + }, + }); + + return collector.finish(); +} + +/** + * Append `_1`, `_2`, … suffixes to extractions that share a display name, recomputing hashes + * accordingly. + */ +function disambiguateExtractions( + extractions: ExtractedSegmentBuilder[], + fileStem: string, + relPath: string, + scope?: string +): void { + // Mutates identity fields on entries that collide on the displayName context + // portion; the builder mutability is internal — the boundary exposes + // `readonly ExtractedSegment[]`. + const nameCounters = new Map(); + const prefix = fileStem + '_'; + + for (const ext of extractions) { + // Peer-tool `inlinedQrl` extractions carry an explicit, already-unique name; + // appending `_` would rewrite a name the consumer expects and that the + // prod-rename hash math is computed against verbatim. Skip them. + if (ext.isInlinedQrl) { + continue; + } + + const contextPortion = ext.displayName.startsWith(prefix) + ? ext.displayName.slice(prefix.length) + : ext.displayName; + + const existing = nameCounters.get(contextPortion); + if (existing === undefined) { + nameCounters.set(contextPortion, 0); + } else { + const newIndex = existing + 1; + nameCounters.set(contextPortion, newIndex); + + const newContext = contextPortion + '_' + newIndex; + const newHash = qwikHash(scope, relPath, newContext); + ext.displayName = mkDisplayName(prefix + newContext); + ext.hash = newHash; + ext.symbolName = mkSymbolName(newContext + '_' + newHash); + ext.canonicalFilename = mkCanonicalFilename(ext.displayName + '_' + newHash); + } + } +} diff --git a/packages/ts-optimizer/src/optimizer/extraction/marker-detection.ts b/packages/ts-optimizer/src/optimizer/extraction/marker-detection.ts new file mode 100644 index 00000000000..240358550c6 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/extraction/marker-detection.ts @@ -0,0 +1,273 @@ +import type { + AstEcmaScriptModule, + AstProgram, + CallExpression, + ModuleExportName, +} from '../../ast-types.js'; +import { isQwikPackageSource } from '../qwik/qwik-packages.js'; + +export interface ImportInfo { + readonly localName: string; + readonly importedName: string; + readonly source: string; + readonly isQwikCore: boolean; +} + +export interface CustomInlinedInfo { + readonly dollarName: string; + readonly qrlName: string; +} + +function getExportedSpecifierName( + specifier: ModuleExportName | null | undefined +): string | undefined { + if (specifier?.type === 'Identifier') { + return specifier.name; + } + return specifier?.value; +} + +function getImportSpecifierName(specifier: ModuleExportName): string | undefined { + if (specifier.type === 'Identifier') { + return specifier.name; + } + return specifier.value; +} + +export function collectImports( + program: AstProgram, + moduleInfo?: AstEcmaScriptModule +): Map { + const imports = new Map(); + + if (moduleInfo?.staticImports) { + for (const entry of moduleInfo.staticImports) { + const source = entry.moduleRequest.value; + const isQwik = isQwikPackageSource(source); + + for (const spec of entry.entries ?? []) { + const localName = spec.localName.value; + let importedName = localName; + + if (spec.importName.kind === 'Default') { + importedName = 'default'; + } else if (spec.importName.kind === 'NamespaceObject') { + importedName = '*'; + } else if (spec.importName.name) { + importedName = spec.importName.name; + } + + imports.set(localName, { + localName, + importedName, + source, + isQwikCore: isQwik, + }); + } + } + + return imports; + } + + for (const node of program.body) { + if (node.type !== 'ImportDeclaration') { + continue; + } + + const source = node.source.value; + const isQwik = isQwikPackageSource(source); + + for (const spec of node.specifiers) { + if (spec.type === 'ImportSpecifier') { + const localName = spec.local.name; + const importedName = getImportSpecifierName(spec.imported) ?? localName; + imports.set(localName, { + localName, + importedName, + source, + isQwikCore: isQwik, + }); + } else if (spec.type === 'ImportDefaultSpecifier') { + imports.set(spec.local.name, { + localName: spec.local.name, + importedName: 'default', + source, + isQwikCore: isQwik, + }); + } else if (spec.type === 'ImportNamespaceSpecifier') { + imports.set(spec.local.name, { + localName: spec.local.name, + importedName: '*', + source, + isQwikCore: isQwik, + }); + } + } + } + + return imports; +} + +export function collectExportNames( + program: AstProgram, + moduleInfo?: AstEcmaScriptModule +): Set { + const exports = new Set(); + + if (moduleInfo?.staticExports) { + for (const entry of moduleInfo.staticExports) { + for (const spec of entry.entries ?? []) { + const exportedName = spec.exportName.name; + if (exportedName && spec.exportName.kind !== 'Default') { + exports.add(exportedName); + } + } + } + return exports; + } + + for (const stmt of program.body) { + if (stmt.type !== 'ExportNamedDeclaration') { + continue; + } + + if (stmt.declaration?.type === 'VariableDeclaration') { + for (const decl of stmt.declaration.declarations ?? []) { + if (decl.id?.type === 'Identifier') { + exports.add(decl.id.name); + } + } + } + + if (stmt.declaration?.type === 'FunctionDeclaration' && stmt.declaration.id) { + exports.add(stmt.declaration.id.name); + } + + if (stmt.declaration?.type === 'ClassDeclaration' && stmt.declaration.id) { + exports.add(stmt.declaration.id.name); + } + + for (const spec of stmt.specifiers ?? []) { + const exportedName = getExportedSpecifierName(spec.exported); + if (exportedName) { + exports.add(exportedName); + } + } + } + + return exports; +} + +export function collectCustomInlined(program: AstProgram): Map { + const custom = new Map(); + + for (const node of program.body) { + if (node.type !== 'ExportNamedDeclaration') { + continue; + } + if (!node.declaration || node.declaration.type !== 'VariableDeclaration') { + continue; + } + + for (const decl of node.declaration.declarations) { + if (decl.id?.type !== 'Identifier') { + continue; + } + + const name = decl.id.name; + if (!name.endsWith('$')) { + continue; + } + + const init = decl.init; + if (!init || init.type !== 'CallExpression') { + continue; + } + if (init.arguments.length < 1) { + continue; + } + + const firstArg = init.arguments[0]; + if (firstArg.type !== 'Identifier' || !firstArg.name.endsWith('Qrl')) { + continue; + } + + custom.set(name, { dollarName: name, qrlName: firstArg.name }); + } + } + + return custom; +} + +export function getCalleeName(callExpr: CallExpression): string | null { + return callExpr.callee?.type === 'Identifier' ? callExpr.callee.name : null; +} + +/** + * A marker call: the callee's original imported name ends in `$` (renamed imports match on the + * imported name, not the local one), or it's a `$`-named customInlined entry. + */ +export function isMarkerCall( + callExpr: CallExpression, + imports: Map, + customInlined: Map +): boolean { + const name = getCalleeName(callExpr); + if (!name) { + return false; + } + + const importInfo = imports.get(name); + if (importInfo && importInfo.importedName.endsWith('$')) { + return true; + } + if (name.endsWith('$') && customInlined.has(name)) { + return true; + } + + return false; +} + +/** Sync$ is a marker but does NOT extract a segment. */ +export function isSyncMarker(calleeName: string): boolean { + return calleeName === 'sync$'; +} + +export function getExtractionKind( + _calleeName: string, + isJsxEventAttr: boolean, + isJsxNonEventAttr: boolean = false +): 'function' | 'eventHandler' | 'jSXProp' { + if (isJsxEventAttr) { + return 'eventHandler'; + } + if (isJsxNonEventAttr) { + return 'jSXProp'; + } + return 'function'; +} + +export function getExtractionName( + calleeName: string, + isJsxEventAttr: boolean, + jsxAttrName?: string +): string { + return isJsxEventAttr && jsxAttrName ? jsxAttrName : calleeName; +} + +/** + * Sound textual prefilter: may `source` contain an extraction trigger? Every trigger leaves a + * verbatim token — a `$`-final identifier/attribute/key (a token-final `$` is only ever followed by + * `{` in template-literal `${`, so that shape is excluded), an `inlinedQrl` callee, or a + * unicode-escaped `$`. Over-inclusion is safe (the walk decides for real); a false negative would + * silently drop a segment, so the check must never miss. + */ +export function sourceMayContainMarkers(source: string): boolean { + for (let idx = source.indexOf('$'); idx !== -1; idx = source.indexOf('$', idx + 1)) { + // charCodeAt past end is NaN (!== '{'), so a trailing `$` over-includes safely. + if (source.charCodeAt(idx + 1) !== 0x7b /* '{' */) { + return true; + } + } + return source.includes('inlinedQrl') || source.includes('\\u0024') || source.includes('\\u{24}'); +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/bind.ts b/packages/ts-optimizer/src/optimizer/jsx/bind.ts new file mode 100644 index 00000000000..86d87a1ba60 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/bind.ts @@ -0,0 +1,65 @@ +export interface BindTransformResult { + propName: string; + propValue: string; + handler: { name: string; code: string } | null; + needsImport: string[]; +} + +interface BindMapping { + propName: string; + helperFn: string; + helperStr: string; +} + +const KNOWN_BINDS: Record = { + value: { + propName: 'value', + helperFn: '_val', + helperStr: '"_val"', + }, + checked: { + propName: 'checked', + helperFn: '_chk', + helperStr: '"_chk"', + }, +}; + +export function isBindProp(propName: string): boolean { + return propName.startsWith('bind:'); +} + +export function transformBindProp( + bindAttrName: string, + valueExprSource: string +): BindTransformResult { + const bindKey = bindAttrName.slice('bind:'.length); + const mapping = KNOWN_BINDS[bindKey]; + + if (!mapping) { + return { + propName: bindAttrName, + propValue: valueExprSource, + handler: null, + needsImport: [], + }; + } + + const handlerCode = `inlinedQrl(${mapping.helperFn}, ${mapping.helperStr}, [${valueExprSource}])`; + + return { + propName: mapping.propName, + propValue: valueExprSource, + handler: { + name: 'q-e:input', + code: handlerCode, + }, + needsImport: ['inlinedQrl', mapping.helperFn], + }; +} + +export function mergeEventHandlers(existingHandler: string | null, newHandler: string): string { + if (existingHandler === null) { + return newHandler; + } + return `[${existingHandler}, ${newHandler}]`; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/event-capture-promotion.ts b/packages/ts-optimizer/src/optimizer/jsx/event-capture-promotion.ts new file mode 100644 index 00000000000..a45d9d4134a --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/event-capture-promotion.ts @@ -0,0 +1,830 @@ +import { walk } from 'oxc-walker'; +import { walkWithProtocol } from '../ast/walk-with-protocol.js'; +import type { AstNode, AstFunction, AstProgram } from '../../ast-types.js'; +import type { ExtractionResult } from '../extraction/extract.js'; +import { + detectLoopContext, + eventHandlerQpParams, + generateParamPadding, + type LoopContext, +} from './loop-hoisting.js'; +import { addBindingNamesFromPatternToSet } from '../ast/binding-pattern.js'; +import { hasUnderscorePlaceholderParams } from '../rewrite/predicates.js'; +import { getWholeWordPattern } from '../segment/post-process.js'; + +interface BuildExtractionLoopMapEnterContext { + readonly extractions: ExtractionResult[]; + readonly repairedCode: string; + readonly extractionLoopMap: Map; + readonly loopBodyVarDecls: LoopBodyVarDeclMap; + readonly loopStack: readonly LoopContext[]; + readonly pushLoop: (loopCtx: LoopContext) => void; +} + +interface BuildExtractionLoopMapExitContext extends BuildExtractionLoopMapEnterContext { + readonly popTopLoopIfMatches: (node: AstNode) => void; +} + +/** + * Retained as the differential oracle for the canonical gather walk's loop-map projection + * (`analysis/module-gather-walk.ts`). + */ +export function buildExtractionLoopMap( + program: AstProgram, + extractions: ExtractionResult[], + repairedCode: string +): { extractionLoopMap: Map; loopBodyVarDecls: LoopBodyVarDeclMap } { + const extractionLoopMap = new Map(); + const loopBodyVarDecls: LoopBodyVarDeclMap = new Map(); + const loopStack: LoopContext[] = []; + + const enterCtx: BuildExtractionLoopMapEnterContext = { + extractions, + repairedCode, + extractionLoopMap, + loopBodyVarDecls, + loopStack, + pushLoop: (loopCtx) => { + loopStack.push(loopCtx); + }, + }; + + const exitCtx: BuildExtractionLoopMapExitContext = { + ...enterCtx, + popTopLoopIfMatches: (node) => { + const loopCtx = detectLoopContext(node, repairedCode); + if (loopCtx) { + loopStack.pop(); + } + }, + }; + + walkWithProtocol(program, enterCtx, exitCtx, { + enter(node, _parent, ctx) { + const loopCtx = detectLoopContext(node, ctx.repairedCode); + if (loopCtx) { + ctx.pushLoop(loopCtx); + if (!ctx.loopBodyVarDecls.has(loopBodyKey(loopCtx.loopBodyStart, loopCtx.loopBodyEnd))) { + ctx.loopBodyVarDecls.set(loopBodyKey(loopCtx.loopBodyStart, loopCtx.loopBodyEnd), []); + } + } + if ( + node.type === 'VariableDeclaration' && + ctx.loopStack.length > 0 && + node.start !== undefined + ) { + const innermost = ctx.loopStack[ctx.loopStack.length - 1]; + if ( + node.start >= innermost.loopBodyStart && + node.end !== undefined && + node.end <= innermost.loopBodyEnd + ) { + const bucket = ctx.loopBodyVarDecls.get( + loopBodyKey(innermost.loopBodyStart, innermost.loopBodyEnd) + )!; + for (const decl of node.declarations ?? []) { + if (decl.id?.type === 'Identifier') { + bucket.push({ name: decl.id.name, declStart: decl.start ?? node.start }); + } + } + } + } + if (node.start !== undefined && node.end !== undefined && ctx.loopStack.length > 0) { + for (const ext of ctx.extractions) { + if (node.start <= ext.callStart && node.end >= ext.callEnd) { + if ( + !ctx.extractionLoopMap.has(ext.symbolName) || + ctx.extractionLoopMap.get(ext.symbolName)!.length < ctx.loopStack.length + ) { + ctx.extractionLoopMap.set(ext.symbolName, [...ctx.loopStack]); + } + } + } + } + }, + leave(node, _parent, ctx) { + ctx.popTopLoopIfMatches(node); + }, + }); + + return { extractionLoopMap, loopBodyVarDecls }; +} + +type BindingKind = 'param' | 'let' | 'const' | 'var' | 'function' | 'class'; + +export interface ScopeEntry { + type: 'function' | 'for-loop'; + start: number; + end: number; + bindings: Array<{ name: string; pos: number; kind: BindingKind }>; +} + +export interface EventCaptureContext { + extractions: ExtractionResult[]; + closureNodes: Map; + closureFreeIdentifiers: ReadonlyMap; + bodyScopeIds: Map>; + moduleScopeIds: Set; + importedNames: Set; + enclosingExtMap: Map; + extractionLoopMap: Map; + allScopeEntries: ScopeEntry[]; + loopBodyVarDecls: LoopBodyVarDeclMap; + repairedCode: string; + isInlineStrategy: boolean; + /** + * Raw-JSX output (no transpile) can't deliver positional params to parent-level handlers, so + * their captures stay on the `.w()` path instead of lifting. + */ + liftParentLevelHandlers: boolean; + /** All module-top-level binding names — captures of these resolve by import, never lifting. */ + moduleTopLevelNames?: ReadonlySet; +} + +/** Keyed by `${loopBodyStart}-${loopBodyEnd}` — a LoopContext's positional fingerprint. */ +export type LoopBodyVarDeclMap = Map>; + +export function loopBodyKey(start: number, end: number): string { + return `${start}-${end}`; +} + +export function buildFunctionScopeEntry(node: AstFunction): ScopeEntry { + const bindings: Array<{ name: string; pos: number; kind: BindingKind }> = []; + for (const param of node.params ?? []) { + const names = new Set(); + addBindingNamesFromPatternToSet(param, names); + for (const n of names) { + bindings.push({ name: n, pos: param.start ?? 0, kind: 'param' }); + } + } + if (node.body?.type === 'BlockStatement') { + for (const stmt of node.body.body ?? []) { + if (stmt.type === 'VariableDeclaration') { + const stmtKind: BindingKind = + stmt.kind === 'const' ? 'const' : stmt.kind === 'let' ? 'let' : 'var'; + for (const decl of stmt.declarations ?? []) { + if (decl.id) { + const names = new Set(); + addBindingNamesFromPatternToSet(decl.id, names); + for (const n of names) { + bindings.push({ + name: n, + pos: decl.start ?? stmt.start ?? 0, + kind: stmtKind, + }); + } + } + } + } + } + } + return { type: 'function', start: node.start, end: node.end, bindings }; +} + +export function buildForLoopScopeEntry(node: AstNode): ScopeEntry | undefined { + if ( + node.type !== 'ForOfStatement' && + node.type !== 'ForInStatement' && + node.type !== 'ForStatement' + ) { + return undefined; + } + const left = node.type === 'ForStatement' ? node.init : node.left; + if (left?.type !== 'VariableDeclaration') { + return undefined; + } + const leftKind: BindingKind = + left.kind === 'const' ? 'const' : left.kind === 'let' ? 'let' : 'var'; + const bindings: Array<{ name: string; pos: number; kind: BindingKind }> = []; + for (const decl of left.declarations ?? []) { + if (decl.id) { + const names = new Set(); + addBindingNamesFromPatternToSet(decl.id, names); + for (const n of names) { + bindings.push({ + name: n, + pos: decl.start ?? left.start ?? 0, + kind: leftKind, + }); + } + } + } + if (bindings.length === 0) { + return undefined; + } + return { type: 'for-loop', start: node.start, end: node.end, bindings }; +} + +/** + * Retained as the differential oracle for the canonical gather walk's scope-entry projection + * (`analysis/module-gather-walk.ts`). + */ +export function collectAllScopeEntries(program: AstProgram): ScopeEntry[] { + const allScopeEntries: ScopeEntry[] = []; + + walk(program, { + enter(node: AstNode) { + if ( + (node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' || + node.type === 'FunctionDeclaration') && + node.start !== undefined && + node.end !== undefined + ) { + allScopeEntries.push(buildFunctionScopeEntry(node)); + } + if ( + (node.type === 'ForOfStatement' || + node.type === 'ForInStatement' || + node.type === 'ForStatement') && + node.start !== undefined && + node.end !== undefined + ) { + const entry = buildForLoopScopeEntry(node); + if (entry) { + allScopeEntries.push(entry); + } + } + }, + leave() {}, + }); + + return allScopeEntries; +} + +/** + * While/do-while loops carry no iterVars, so a counter like `let i = 0` in an enclosing function + * body reads as "declared" to scope analysis even though the handler needs it delivered via q:p. + * Returns the `let`/`var` names whole-word- referenced in the handler body; `declMustPrecedeLoop` + * additionally requires the binding to be declared before the loop. + */ +function collectWhileLoopCounterCandidates( + allScopeEntries: readonly ScopeEntry[], + loop: LoopContext, + extraction: ExtractionResult, + declMustPrecedeLoop: boolean +): string[] { + if (loop.type !== 'while' && loop.type !== 'do-while') { + return []; + } + if (loop.iterVars.length > 0) { + return []; + } + + const names: string[] = []; + const seen = new Set(); + for (const entry of allScopeEntries) { + if (entry.type !== 'function') { + continue; + } + if ( + entry.start >= loop.loopNode.start || + entry.end <= loop.loopNode.end || + entry.start >= extraction.callStart || + entry.end <= extraction.callEnd + ) { + continue; + } + for (const b of entry.bindings) { + if (b.kind !== 'let' && b.kind !== 'var') { + continue; + } + if (declMustPrecedeLoop && b.pos >= loop.loopNode.start) { + continue; + } + if (seen.has(b.name)) { + continue; + } + if (!getWholeWordPattern(b.name).test(extraction.bodyText)) { + continue; + } + seen.add(b.name); + names.push(b.name); + } + } + return names; +} + +/** + * The free-identifier walk never visits identifiers in computed-member-property position, so a + * handler referencing a loop variable only as an index (`count[i]++`) never surfaces `i`. Augment + * with any enclosing loop's iterVars referenced in the handler body, plus while/do-while counter + * candidates. + */ +function augmentUndeclaredIdsForLoops( + extraction: ExtractionResult, + undeclaredIds: readonly string[], + extractionLoopMap: ReadonlyMap, + allScopeEntries: readonly ScopeEntry[] +): string[] { + const result = [...undeclaredIds]; + const enclosingLoops = extractionLoopMap.get(extraction.symbolName); + if (!enclosingLoops || enclosingLoops.length === 0) { + return result; + } + + const undeclaredSet = new Set(result); + const addMissing = (name: string) => { + if (undeclaredSet.has(name)) { + return; + } + undeclaredSet.add(name); + result.push(name); + }; + for (const loop of enclosingLoops) { + for (const iterVar of loop.iterVars) { + if (getWholeWordPattern(iterVar).test(extraction.bodyText)) { + addMissing(iterVar); + } + } + for (const name of collectWhileLoopCounterCandidates( + allScopeEntries, + loop, + extraction, + false + )) { + addMissing(name); + } + } + return result; +} + +/** + * Event handlers in a loop need two positional slots for q:p delivery even with no captures. + * Component event handlers (`onClick$` on ``) are plain props, so they're excepted. + */ +function applyEmptyCaptureLoopPadding( + extraction: ExtractionResult, + extractionLoopMap: ReadonlyMap +): void { + if (extraction.isComponentEvent) { + return; + } + const enclosingLoops = extractionLoopMap.get(extraction.symbolName); + if (!enclosingLoops || enclosingLoops.length === 0) { + return; + } + extraction.paramNames = generateParamPadding([], extraction.paramNames); + extraction.captureNames = []; + extraction.captures = false; +} + +function collectVisibleScopeBindings( + extraction: ExtractionResult, + ctx: EventCaptureContext +): { + allScopeIds: Set; + declPositions: Map; + enclosingCaptureOnly: Set; +} { + const { bodyScopeIds, moduleScopeIds, enclosingExtMap, allScopeEntries, repairedCode } = ctx; + + const allScopeIds = new Set(); + const enclosingCaptureOnly = new Set(); + const enclosingExt = enclosingExtMap.get(extraction.symbolName) ?? null; + if (enclosingExt) { + const parentIds = bodyScopeIds.get(enclosingExt.symbolName); + if (parentIds) { + for (const id of parentIds) { + allScopeIds.add(id); + } + } + // The enclosing segment's own captures unpack as `_captures[N]` consts in + // its emitted body — per-invocation values a nested handler must receive + // positionally (q:p/q:ps), not via lexical `.w()` capture. Module-scope + // names resolve by import instead and stay out. This holds for every + // enclosing kind: marker segments (component$) unpack captures too. + for (const name of enclosingExt.captureNames) { + if ( + !allScopeIds.has(name) && + !moduleScopeIds.has(name) && + !ctx.moduleTopLevelNames?.has(name) + ) { + allScopeIds.add(name); + enclosingCaptureOnly.add(name); + } + } + } else { + for (const id of moduleScopeIds) { + allScopeIds.add(id); + } + } + + const declPositions = new Map(); + const enclosingStart = enclosingExt ? enclosingExt.argStart : 0; + const enclosingEnd = enclosingExt ? enclosingExt.argEnd : repairedCode.length; + for (const entry of allScopeEntries) { + if ( + entry.start >= enclosingStart && + entry.end <= enclosingEnd && + entry.start < extraction.callStart && + entry.end > extraction.callEnd + ) { + for (const b of entry.bindings) { + allScopeIds.add(b.name); + if (!declPositions.has(b.name)) { + declPositions.set(b.name, b.pos); + } + } + } + } + return { allScopeIds, declPositions, enclosingCaptureOnly }; +} + +/** + * Not in a loop. Under the default/segment strategy all captured vars become alphabetically-sorted + * paramNames. Under inline/hoist they stay in `captureNames` for `_captures[N]` unpacking. + */ +function promoteNonLoopCaptures( + extraction: ExtractionResult, + uniqueCaptures: readonly string[], + keepInlineCaptures: boolean +): void { + const sortedCaptures = [...uniqueCaptures].sort(); + if (keepInlineCaptures) { + extraction.captureNames = sortedCaptures; + extraction.captures = sortedCaptures.length > 0; + } else { + extraction.paramNames = generateParamPadding(sortedCaptures, extraction.paramNames); + extraction.captureNames = []; + extraction.captures = false; + extraction.movedCaptures = sortedCaptures.length > 0; + } +} + +/** + * IN a loop: partition captures into loop-local (promoted to positional params) vs cross-scope + * (delivered via .w() hoisting). Only the IMMEDIATE (innermost) loop's variables are loop-local; + * variables from outer loops are cross-scope captures. + */ +function partitionLoopCaptures( + extraction: ExtractionResult, + uniqueCaptures: readonly string[], + enclosingLoops: readonly LoopContext[], + allScopeEntries: readonly ScopeEntry[], + loopBodyVarDecls: LoopBodyVarDeclMap +): void { + const immediateLoop = enclosingLoops[enclosingLoops.length - 1]; + + const loopLocalSet = new Set(immediateLoop.iterVars); + const loopBodyDecls = loopBodyVarDecls.get( + loopBodyKey(immediateLoop.loopBodyStart, immediateLoop.loopBodyEnd) + ); + if (loopBodyDecls) { + for (const d of loopBodyDecls) { + loopLocalSet.add(d.name); + } + } + for (const name of collectWhileLoopCounterCandidates( + allScopeEntries, + immediateLoop, + extraction, + true + )) { + loopLocalSet.add(name); + } + + const loopLocalVars: string[] = []; + const crossScopeCaptures: string[] = []; + for (const name of uniqueCaptures) { + if (loopLocalSet.has(name)) { + loopLocalVars.push(name); + } else { + crossScopeCaptures.push(name); + } + } + + if (loopLocalVars.length > 0) { + extraction.paramNames = generateParamPadding(loopLocalVars, extraction.paramNames); + extraction.movedCaptures = true; + } + extraction.captureNames = crossScopeCaptures.sort(); + extraction.captures = crossScopeCaptures.length > 0; +} + +export function promoteEventHandlerCaptures( + ctx: EventCaptureContext, + globalDeclPositions: Map +): void { + const { + extractions, + closureNodes, + importedNames, + extractionLoopMap, + allScopeEntries, + loopBodyVarDecls, + isInlineStrategy, + } = ctx; + + for (const extraction of extractions) { + if (extraction.ctxKind !== 'eventHandler' && !extraction.isWorkerEventHandler) { + continue; + } + if (extraction.isInlinedQrl) { + continue; + } + // Wrapper params are mirrored from their worker child after promotion. + if (extraction.isWorkerEventWrapper) { + continue; + } + if (!ctx.liftParentLevelHandlers && !ctx.enclosingExtMap.has(extraction.symbolName)) { + continue; + } + + // Capture analysis misses intermediate nested-function scopes (loop + // callbacks), so re-detect against ALL enclosing scopes. + const closureNode = closureNodes.get(extraction.symbolName); + if (!closureNode) { + continue; + } + const rawUndeclaredIds = ctx.closureFreeIdentifiers.get(closureNode); + if (!rawUndeclaredIds) { + continue; + } + const undeclaredIds = augmentUndeclaredIdsForLoops( + extraction, + rawUndeclaredIds, + extractionLoopMap, + allScopeEntries + ); + if (undeclaredIds.length === 0) { + applyEmptyCaptureLoopPadding(extraction, extractionLoopMap); + continue; + } + + const { allScopeIds, declPositions, enclosingCaptureOnly } = collectVisibleScopeBindings( + extraction, + ctx + ); + for (const [name, pos] of declPositions) { + if (!globalDeclPositions.has(name)) { + globalDeclPositions.set(name, pos); + } + } + + const allCaptures = undeclaredIds.filter( + (name) => allScopeIds.has(name) && !importedNames.has(name) + ); + const uniqueCaptures = [...new Set(allCaptures)].sort( + (a, b) => (declPositions.get(a) ?? 0) - (declPositions.get(b) ?? 0) + ); + if (uniqueCaptures.length === 0) { + applyEmptyCaptureLoopPadding(extraction, extractionLoopMap); + continue; + } + + // A capture visible only through the enclosing segment's own captures + // unpacks per invocation — the entry classifies var (`is_const` false). + extraction.liftedNonConst = uniqueCaptures.some((name) => enclosingCaptureOnly.has(name)); + + const enclosingLoops = extractionLoopMap.get(extraction.symbolName); + if (!enclosingLoops || enclosingLoops.length === 0) { + // An explicit `$()` handler inside a serializing marker segment + // (component$, useTask$, …) keeps `.w()` capture delivery; inline-arrow + // attr handlers and handlers with no serializing scope around them + // (parent-level, bare-$/inlinedQrl enclosings) promote to positional + // params (matches Rust). + const enclosingExt = ctx.enclosingExtMap.get(extraction.symbolName); + const hasSerializingScope = + enclosingExt !== undefined && !enclosingExt.isBare && !enclosingExt.isInlinedQrl; + const keepsWCall = extraction.isBare && hasSerializingScope; + if (!keepsWCall || extraction.isWorkerEventHandler) { + // Rust lifts inline document handlers through q:p. + const keepInlineCaptures = isInlineStrategy && !extraction.ctxName.startsWith('document:'); + promoteNonLoopCaptures(extraction, uniqueCaptures, keepInlineCaptures); + } else { + extraction.captureNames = [...uniqueCaptures].sort(); + extraction.captures = extraction.captureNames.length > 0; + } + } else { + partitionLoopCaptures( + extraction, + uniqueCaptures, + enclosingLoops, + allScopeEntries, + loopBodyVarDecls + ); + } + } +} + +function groupPromotedHandlersByParent( + extractions: readonly ExtractionResult[], + enclosingExtMap: ReadonlyMap +): Map { + const handlersByParent = new Map(); + for (const ext of extractions) { + if (ext.ctxKind !== 'eventHandler') { + continue; + } + if (!hasUnderscorePlaceholderParams(ext.paramNames, ext.movedCaptures)) { + continue; + } + const parentExt = enclosingExtMap.get(ext.symbolName); + if (!parentExt) { + continue; + } + const existing = handlersByParent.get(parentExt.symbolName); + if (existing) { + existing.push(ext); + } else { + handlersByParent.set(parentExt.symbolName, [ext]); + } + } + return handlersByParent; +} + +function groupByContainingElement( + handlers: readonly ExtractionResult[], + repairedCode: string +): Map { + const elementGroups = new Map(); + for (const h of handlers) { + let pos = h.callStart - 1; + while (pos > 0 && repairedCode[pos] !== '<') { + pos--; + } + const existing = elementGroups.get(pos); + if (existing) { + existing.push(h); + } else { + elementGroups.set(pos, [h]); + } + } + return elementGroups; +} + +function sortByGlobalDeclPosition( + names: string[], + globalDeclPositions: ReadonlyMap +): void { + names.sort((a, b) => (globalDeclPositions.get(a) ?? 0) - (globalDeclPositions.get(b) ?? 0)); +} + +function sortQpNamesForGroup( + names: string[], + group: readonly ExtractionResult[], + extractionLoopMap: ReadonlyMap, + globalDeclPositions: ReadonlyMap +): void { + const anyInLoop = group.some((h) => (extractionLoopMap.get(h.symbolName)?.length ?? 0) > 0); + if (anyInLoop) { + sortByGlobalDeclPosition(names, globalDeclPositions); + } else { + // Code-unit sort — the same comparator the promotion's capture sorts use; + // localeCompare orders case-divergent names (colorSignal vs colors) + // differently and would desync q:ps from the client's param slots. + names.sort(); + } +} + +export function unifyParameterSlots( + ctx: EventCaptureContext, + globalDeclPositions: Map +): void { + const { extractions, enclosingExtMap, extractionLoopMap, repairedCode } = ctx; + const handlersByParent = groupPromotedHandlersByParent(extractions, enclosingExtMap); + + for (const [, handlers] of handlersByParent) { + if (handlers.length < 2) { + continue; + } + const elementGroups = groupByContainingElement(handlers, repairedCode); + + for (const [, group] of elementGroups) { + if (group.length < 2) { + continue; + } + + const allLoopLocals: string[] = []; + const seen = new Set(); + for (const h of group) { + for (let i = 2; i < h.paramNames.length; i++) { + const p = h.paramNames[i]; + if (!seen.has(p)) { + seen.add(p); + allLoopLocals.push(p); + } + } + } + sortQpNamesForGroup(allLoopLocals, group, extractionLoopMap, globalDeclPositions); + + if (allLoopLocals.length === 0) { + continue; + } + + for (const h of group) { + const handlerCaptures = new Set(); + for (let i = 2; i < h.paramNames.length; i++) { + handlerCaptures.add(h.paramNames[i]); + } + if (handlerCaptures.size === 0) { + continue; + } + const newParams = [h.paramNames[0] ?? '_', h.paramNames[1] ?? '_1']; + let paddingCounter = 2; + let lastCaptureIdx = -1; + for (let idx = 0; idx < allLoopLocals.length; idx++) { + if (handlerCaptures.has(allLoopLocals[idx])) { + lastCaptureIdx = idx; + } + } + for (let idx = 0; idx <= lastCaptureIdx; idx++) { + const p = allLoopLocals[idx]; + if (handlerCaptures.has(p)) { + newParams.push(p); + } else { + newParams.push(`_${paddingCounter}`); + } + paddingCounter++; + } + h.paramNames = newParams; + } + } + } +} + +/** + * Optional `stripCtxName`/`stripEventHandlers` triggers a second pass for stripped event handlers + * with captures: their bodies are null at runtime and can't consume captures positionally, so the + * captures propagate to the parent JSX element's `q:p` var-prop instead. + */ +export function buildElementCaptureMap( + ctx: EventCaptureContext, + globalDeclPositions: Map, + stripCtxName?: readonly string[], + stripEventHandlers?: boolean +): Map { + const { extractions, enclosingExtMap, extractionLoopMap, repairedCode } = ctx; + const elementQpParamsMap = new Map(); + + const handlersByParent = groupPromotedHandlersByParent(extractions, enclosingExtMap); + + for (const [, handlers] of handlersByParent) { + const elementGroups = groupByContainingElement(handlers, repairedCode); + + for (const [, group] of elementGroups) { + const allVars: string[] = []; + const seen = new Set(); + for (const h of group) { + for (const p of eventHandlerQpParams(h.paramNames)) { + if (!seen.has(p)) { + seen.add(p); + allVars.push(p); + } + } + } + sortQpNamesForGroup(allVars, group, extractionLoopMap, globalDeclPositions); + for (const h of group) { + elementQpParamsMap.set(h.symbolName, allVars); + } + } + } + + if (stripCtxName || stripEventHandlers) { + const strippedHandlers: ExtractionResult[] = []; + for (const ext of extractions) { + if (ext.ctxKind !== 'eventHandler') { + continue; + } + if (!ext.captures || ext.captureNames.length === 0) { + continue; + } + const isStripped = + (stripCtxName && stripCtxName.some((v) => ext.ctxName.startsWith(v))) || + stripEventHandlers === true; + if (!isStripped) { + continue; + } + strippedHandlers.push(ext); + } + + const strippedByElement = groupByContainingElement(strippedHandlers, repairedCode); + for (const [, group] of strippedByElement) { + const allCaps: string[] = []; + const seen = new Set(); + for (const h of group) { + // Stripping forces the captures out of the (noop) QRL — same runtime + // contract as param lifting, so the decl needs the `.m()` marker. + h.movedCaptures = true; + for (const c of h.captureNames) { + if (!seen.has(c)) { + seen.add(c); + allCaps.push(c); + } + } + } + // Same sort as unifyParameterSlots: the client build assigns handler + // param slots with it, and the serialized q:ps must line up. + sortQpNamesForGroup(allCaps, group, extractionLoopMap, globalDeclPositions); + for (const h of group) { + // Don't overwrite an entry already populated by the loop pass. + if (!elementQpParamsMap.has(h.symbolName)) { + elementQpParamsMap.set(h.symbolName, allCaps); + } + } + } + } + + return elementQpParamsMap; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/event-handlers.ts b/packages/ts-optimizer/src/optimizer/jsx/event-handlers.ts new file mode 100644 index 00000000000..f0f8c831fbe --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/event-handlers.ts @@ -0,0 +1,132 @@ +import type { JSXAttributeItem } from '../../ast-types.js'; +import { getJsxAttributeName } from './jsx-attr-name.js'; + +export function isEventProp(propName: string): boolean { + if (!propName.endsWith('$')) { + return false; + } + + if (propName.startsWith('document:on')) { + return true; + } + if (propName.startsWith('window:on')) { + return true; + } + if (propName.startsWith('host:on')) { + return true; + } + + if (propName.startsWith('on') && propName.length > 3) { + const charAfterOn = propName[2]; + return charAfterOn === '-' || (charAfterOn >= 'A' && charAfterOn <= 'Z'); + } + + return false; +} + +export function isPassiveDirective(propName: string): boolean { + return propName.startsWith('passive:'); +} + +function createEventName(name: string): string { + let result = ''; + for (const ch of name) { + if ((ch >= 'A' && ch <= 'Z') || ch === '-') { + result += '-'; + result += ch.toLowerCase(); + } else { + result += ch; + } + } + return result; +} + +function normalizeJsxEventName(name: string): string { + if (name === 'DOMContentLoaded') { + return '-d-o-m-content-loaded'; + } + + const processedName = name.startsWith('-') ? name.slice(1) : name.toLowerCase(); + + return createEventName(processedName); +} + +function getEventScopeData( + propName: string, + isPassive: boolean +): [prefix: string, stripIndex: number] | null { + if (propName.startsWith('window:on')) { + return [isPassive ? 'q-wp:' : 'q-w:', 9]; + } + if (propName.startsWith('document:on')) { + return [isPassive ? 'q-dp:' : 'q-d:', 11]; + } + if (propName.startsWith('on')) { + return [isPassive ? 'q-ep:' : 'q-e:', 2]; + } + return null; +} + +export function transformEventPropName( + propName: string, + passiveEvents: Set +): string | null { + if (!propName.endsWith('$')) { + return null; + } + if (propName.startsWith('host:')) { + return null; + } + + const scopeData = getEventScopeData(propName, false); + if (!scopeData) { + return null; + } + + const [, stripIndex] = scopeData; + const eventNameRaw = propName.slice(stripIndex, propName.length - 1); + const normalizedName = normalizeJsxEventName(eventNameRaw); + + const isPassive = passiveEvents.has(normalizedName); + const [prefix] = getEventScopeData(propName, isPassive)!; + + return prefix + normalizedName; +} + +/** + * The prop name an extracted event-handler call site is rewritten to. Component-element events keep + * the author-form name (the runtime resolves component props itself); HTML events get the + * serialized `q-*:` form. The inline/hoist path passes an empty `passiveEvents` set — passive + * detection runs only on the segment-codegen path (deliberate delta, see `buildNestedCallSites`). + */ +export function eventHandlerPropName( + rawName: string, + isComponentEvent: boolean, + passiveEvents: Set +): string { + if (isComponentEvent) { + return rawName; + } + return transformEventPropName(rawName, passiveEvents) ?? rawName; +} + +export function collectPassiveDirectives(attributes: readonly JSXAttributeItem[]): Set { + const passiveEvents = new Set(); + + for (const attr of attributes) { + if (attr.type !== 'JSXAttribute') { + continue; + } + + const name = getJsxAttributeName(attr); + + if (!isPassiveDirective(name)) { + continue; + } + + const rawEventName = name.slice('passive:'.length); + passiveEvents.add(normalizeJsxEventName(rawEventName)); + } + + return passiveEvents; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/jsx-attr-name.ts b/packages/ts-optimizer/src/optimizer/jsx/jsx-attr-name.ts new file mode 100644 index 00000000000..e0cbe884135 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/jsx-attr-name.ts @@ -0,0 +1,17 @@ +import type { JSXAttribute } from '../../ast-types.js'; + +export function getJsxAttributeName(attr: JSXAttribute): string { + const name = attr.name; + switch (name.type) { + case 'JSXIdentifier': + return name.name; + case 'JSXNamespacedName': + return `${name.namespace.name}:${name.name.name}`; + default: { + const _exhaustive: never = name; + throw new Error( + `unhandled JSX attribute name type: ${(_exhaustive as { type?: string }).type}` + ); + } + } +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/jsx-call-transform.ts b/packages/ts-optimizer/src/optimizer/jsx/jsx-call-transform.ts new file mode 100644 index 00000000000..097684dd5e5 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/jsx-call-transform.ts @@ -0,0 +1,724 @@ +import type MagicString from 'magic-string'; +import { walk } from 'oxc-walker'; +import type { AstNode, AstProgram } from '../../ast-types.js'; +import { someAstChild, memberStaticPropName } from '../ast/guards.js'; +import { + computeJsxFlags, + collectScopeAwareBindings, + isConstBindingName, + fnSignalDepsAllConst, + type ScopeAwareBindings, + type JsxKeyCounter, + type JsxKeyReservations, + reserveRegionKeys, +} from './jsx.js'; +import { transformEventPropName } from './event-handlers.js'; +import { buildCaptureProp } from './loop-hoisting.js'; +import { analyzeSignalExpression, type SignalHoister } from './signal-analysis.js'; +import type { SegmentImportData } from '../segment/segment-codegen.js'; + +const EMPTY_SET: ReadonlySet = new Set(); + +export function collectJsxFunctionNames(importContext: SegmentImportData): Set { + return collectJsxFunctionNamesFromIterable(importContext.moduleImports); +} + +export function collectJsxFunctionNamesFromIterable( + imports: Iterable<{ + readonly localName: string; + readonly importedName: string; + readonly source: string; + }> +): Set { + const result = new Set(); + for (const imp of imports) { + if ( + imp.source === '@qwik.dev/core/jsx-runtime' || + imp.source === '@qwik.dev/core/jsx-dev-runtime' + ) { + result.add(imp.localName); + continue; + } + if ( + (imp.source === '@qwik.dev/core' || imp.source === '@builder.io/qwik') && + (imp.importedName === 'jsx' || imp.importedName === 'jsxs' || imp.importedName === 'jsxDEV') + ) { + result.add(imp.localName); + } + } + return result; +} + +interface JsxCallTransformOptions { + jsxFunctions: Set; + keyCounter: JsxKeyCounter; + neededImports: Set; + reactiveBindings?: ReadonlySet; + /** + * Byte ranges `[start, end)` to skip. Used by the parent-rewrite path so `jsx()` calls already + * inside a marker extraction's argument tree (handled by segment-codegen) aren't + * double-transformed. + */ + skipRanges?: ReadonlyArray<{ readonly start: number; readonly end: number }>; + /** Inline-strategy reservation of extracted-body key ranges. */ + keyReservations?: JsxKeyReservations; + /** + * Maps an event-handler QRL var (`q_`) to the lexical-capture params delivered positionally. + * When a const handler on an element references one, the element gets a `q:p`/`q:ps` prop so the + * runtime passes captures through. + */ + qpByQrl?: ReadonlyMap; + importedNames?: ReadonlySet; + signalHoister?: SignalHoister; + localNames?: ReadonlySet; + bindings?: ScopeAwareBindings; + paramNames?: readonly string[]; +} + +/** + * Rewrite every `jsx(Tag, propsObj, ...)` whose callee is in `jsxFunctions` to `_jsxSorted(...)`. + * Two passes: gather reactive bindings first (a `const X = use*()` can appear after the first + * `jsx()` call in document order, so the set must be complete before any wrap decision), then + * rewrite bottom-up so an outer call's sliced source contains already-rewritten inner calls. + * Mutates `s` and `opts.neededImports`. + */ +export function transformJsxCalls( + source: string, + s: MagicString, + program: AstProgram, + opts: JsxCallTransformOptions +): void { + if (opts.jsxFunctions.size === 0) { + return; + } + + const skipRanges = opts.skipRanges ?? []; + const isInSkipRange = (start: number): boolean => { + for (const r of skipRanges) { + if (start >= r.start && start < r.end) { + return true; + } + } + return false; + }; + + const reactiveBindings = collectReactiveBindings(program); + opts.reactiveBindings = reactiveBindings; + const scopeBindings = collectScopeAwareBindings(program); + const localNames = scopeBindings.allLocalNames; + // Closure params aren't declarations in the parsed body (codegen wraps it in + // `(props) =>` afterward), so union them in — member reads on them are + // store-field reads the wrap pass must recognise. + for (const p of opts.paramNames ?? []) { + localNames.add(p); + } + opts.localNames = localNames; + opts.bindings = scopeBindings.bindings; + + const directJsxChildStarts = new Set(); + + walk(program, { + enter(node: AstNode) { + if (!isJsxCall(node, opts.jsxFunctions) || node.type !== 'CallExpression') { + return; + } + + markDirectJsxChildren(node, opts.jsxFunctions, directJsxChildStarts); + + if (reactiveBindings.size === 0 && (opts.paramNames?.length ?? 0) === 0) { + return; + } + if (isInSkipRange(node.start)) { + return; + } + const propsArg = node.arguments?.[1]; + if (!propsArg || propsArg.type !== 'ObjectExpression') { + return; + } + wrapReactivePropValues(propsArg, { + s, + source, + importedNames: opts.importedNames ?? EMPTY_SET, + localNames, + neededImports: opts.neededImports, + signalHoister: opts.signalHoister, + }); + }, + leave(node: AstNode) { + if (!isJsxCall(node, opts.jsxFunctions) || node.type !== 'CallExpression') { + return; + } + + if (isInSkipRange(node.start)) { + // Extracted-body keys are reserved so their numbering matches the + // module-order walk SWC performs. + reserveRegionKeys(opts.keyReservations, opts.keyCounter, node.start); + return; + } + + const isDirectJsxChild = directJsxChildStarts.has(node.start); + const rewritten = buildJsxSortedCall(s, node, opts, isDirectJsxChild); + if (rewritten !== null) { + s.overwrite(node.start, node.end, rewritten); + } + }, + }); +} + +function collectReactiveBindings(program: AstProgram): Set { + const result = new Set(); + walk(program, { + enter(node: AstNode) { + if (node.type !== 'VariableDeclaration') { + return; + } + for (const decl of node.declarations ?? []) { + if (decl.id?.type !== 'Identifier') { + continue; + } + if (decl.init?.type !== 'CallExpression') { + continue; + } + const callee = decl.init.callee; + if (callee?.type !== 'Identifier') { + continue; + } + if (callee.name.startsWith('use')) { + result.add(decl.id.name); + } + } + }, + }); + return result; +} + +export function isJsxCall(node: AstNode, jsxFunctions: ReadonlySet): boolean { + return ( + node.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + jsxFunctions.has(node.callee.name) && + !!node.arguments && + node.arguments.length >= 2 && + node.arguments[1]?.type === 'ObjectExpression' + ); +} + +export function markDirectJsxChildren( + node: AstNode, + jsxFunctions: ReadonlySet, + out: Set +): void { + if (node.type !== 'CallExpression') { + return; + } + const propsArg = node.arguments?.[1]; + if (!propsArg || propsArg.type !== 'ObjectExpression') { + return; + } + for (const prop of propsArg.properties ?? []) { + if (prop.type !== 'Property' || prop.computed) { + continue; + } + if (propertyKeyName(prop) !== 'children') { + continue; + } + const value = prop.value; + if (isJsxCall(value, jsxFunctions)) { + out.add(value.start); + } else if (value?.type === 'ArrayExpression') { + for (const el of value.elements ?? []) { + if (el && isJsxCall(el, jsxFunctions)) { + out.add(el.start); + } + } + } + return; + } +} + +interface WrapReactiveContext { + s: MagicString; + source: string; + importedNames: ReadonlySet; + localNames: ReadonlySet | undefined; + neededImports: Set; + signalHoister: SignalHoister | undefined; +} + +function wrapReactivePropValues(propsObj: AstNode, ctx: WrapReactiveContext): void { + if (propsObj.type !== 'ObjectExpression') { + return; + } + for (const prop of propsObj.properties ?? []) { + if (prop.type !== 'Property') { + continue; + } + const key = propertyKeyName(prop); + if (isHandlerPropKey(key)) { + continue; + } + const value = prop.value; + if (key === 'children' && value?.type === 'ArrayExpression') { + for (const element of value.elements ?? []) { + wrapReactiveValue(element, ctx); + } + continue; + } + wrapReactiveValue(value, ctx); + } +} + +function isHandlerPropKey(key: string | null): boolean { + return key !== null && key.endsWith('$'); +} + +function isDollarSuffixedMemberRead(node: AstNode): boolean { + return ( + node.type === 'MemberExpression' && + !node.computed && + node.property?.type === 'Identifier' && + node.property.name.endsWith('$') + ); +} + +function wrapReactiveValue(node: AstNode | null | undefined, ctx: WrapReactiveContext): void { + if (!node) { + return; + } + if (isDollarSuffixedMemberRead(node)) { + return; + } + const result = analyzeSignalExpression( + node, + ctx.source, + ctx.importedNames as Set, + ctx.localNames as Set | undefined + ); + if (result.type === 'wrapProp') { + ctx.s.overwrite(node.start, node.end, result.code); + ctx.neededImports.add('_wrapProp'); + return; + } + if ( + result.type === 'fnSignal' && + ctx.signalHoister !== undefined && + isHoistableSignalExpr(node) + ) { + const hf = ctx.signalHoister.hoist(result.hoistedFn, result.hoistedStr, node.start ?? 0); + ctx.s.overwrite( + node.start, + node.end, + `_fnSignal(${hf}, [${result.deps.join(', ')}], ${hf}_str)` + ); + ctx.neededImports.add('_fnSignal'); + } +} + +/** + * Only scalar reactive expressions hoist to `_fnSignal`; arrays/objects and call/chain exprs + * (`items.map(...)`) stay verbatim — hoisting them would strand their callback bindings. + */ +function isHoistableSignalExpr(node: AstNode): boolean { + return ( + node.type !== 'ArrayExpression' && + node.type !== 'ObjectExpression' && + node.type !== 'CallExpression' && + node.type !== 'ChainExpression' + ); +} + +/** + * Which prop bag a `_jsxDEV` prop value belongs in. A reactive-wrapped value goes in the const bag + * so only its wrapper subscribes, not the host (a var-bag reactive read re-renders the whole + * component on a mid-render write). A store-field read on an HTML element keeps its var/const split + * by the object's binding. Everything else — including non-reactive props carrying pre-analysed + * peer-tool markers (`[_IMMUTABLE]`) — stays in the var bag. + */ +function classifyProp( + valueNode: AstNode, + opts: JsxCallTransformOptions, + isHtmlTag: boolean, + s: MagicString +): 'const' | 'var' { + const importedNames = (opts.importedNames ?? EMPTY_SET) as Set; + const bindings = opts.bindings; + const localNames = opts.localNames as Set | undefined; + const pos = valueNode.start ?? 0; + if (isDollarSuffixedMemberRead(valueNode)) { + return 'var'; + } + const sig = analyzeSignalExpression(valueNode, s.original, importedNames, localNames); + + if (sig.type === 'wrapProp') { + if (sig.isStoreField && isHtmlTag) { + const objName = sig.code.match(/_wrapProp\(([\w$]+)/)?.[1] ?? null; + return isConstBindingName(objName, importedNames, bindings, pos) ? 'const' : 'var'; + } + return 'const'; + } + + if ( + sig.type === 'fnSignal' && + opts.signalHoister !== undefined && + isHoistableSignalExpr(valueNode) + ) { + const depsAllConst = fnSignalDepsAllConst( + sig.deps, + importedNames, + bindings, + pos, + isHtmlTag, + (dep) => opts.paramNames?.includes(dep) ?? false + ); + return depsAllConst ? 'const' : 'var'; + } + + return 'var'; +} + +function constBagEligible( + valueNode: AstNode, + opts: JsxCallTransformOptions, + isHtmlTag: boolean, + reactiveConst: boolean, + s: MagicString +): boolean { + if (reactiveConst || isConstExpr(valueNode, opts)) { + return true; + } + if (isHtmlTag) { + return false; + } + const importedNames = (opts.importedNames ?? EMPTY_SET) as Set; + const localNames = opts.localNames as Set | undefined; + const sig = analyzeSignalExpression(valueNode, s.original, importedNames, localNames); + if (sig.type === 'wrapProp') { + return true; + } + return ( + sig.type === 'fnSignal' && opts.signalHoister !== undefined && isHoistableSignalExpr(valueNode) + ); +} + +function isConstExpr(node: AstNode, opts: JsxCallTransformOptions): boolean { + const importedNames = (opts.importedNames ?? EMPTY_SET) as Set; + const bindings = opts.bindings; + const check = (n: AstNode): boolean => { + if (n.type === 'CallExpression' || n.type === 'MemberExpression') { + return false; + } + if (n.type === 'ArrowFunctionExpression') { + return true; + } + if (n.type === 'Identifier') { + return isConstBindingName(n.name, importedNames, bindings, n.start ?? 0); + } + return !someAstChild(n, (child, key, parent) => isValueChild(key, parent) && !check(child)); + }; + return check(node); +} + +function isValueChild(key: string, parent: AstNode): boolean { + return !(key === 'key' && parent.type === 'Property' && !parent.computed); +} + +/** + * `isConst` picks the bag the prop lands in; `rawConst` is const-ness of the raw value (governs a + * following spread's merge). They differ for a component's reactive prop — const bag, yet its + * member-read value isn't a const expression. + */ +type PropSlot = + | { readonly kind: 'spread'; readonly getVar: string; readonly getConst: string } + | { + readonly kind: 'named'; + readonly text: string; + readonly isConst: boolean; + readonly rawConst: boolean; + }; + +/** + * Partition a spread-carrying prop list into `_jsxSplit`'s var/const bags. A named prop before any + * remaining spread is var even when statically const — a later spread can override it; a prop after + * all spreads keeps its static bag. + */ +function partitionSpreadProps(slots: readonly PropSlot[]): { + varBag: string[]; + constBag: string[]; +} { + const lastSpreadIndex = slots.reduce((acc, slot, i) => (slot.kind === 'spread' ? i : acc), -1); + const hasVarPropAfterLastSpread = slots.some( + (slot, i) => i > lastSpreadIndex && slot.kind === 'named' && !slot.rawConst + ); + + let spreadRemaining = slots.filter((slot) => slot.kind === 'spread').length; + const varBag: string[] = []; + const constBag: string[] = []; + for (const slot of slots) { + if (slot.kind === 'spread') { + varBag.push(slot.getVar); + if (spreadRemaining > 1 || hasVarPropAfterLastSpread) { + varBag.push(slot.getConst); + } else { + constBag.push(slot.getConst); + } + spreadRemaining--; + } else if (spreadRemaining > 0 || !slot.isConst) { + varBag.push(slot.text); + } else { + constBag.push(slot.text); + } + } + return { varBag, constBag }; +} + +function renderConstBag(constBag: readonly string[]): string { + if (constBag.length === 0) { + return 'null'; + } + if (constBag.length === 1 && constBag[0].startsWith('..._getConstProps(')) { + return constBag[0].slice('...'.length); + } + return `{ ${constBag.join(', ')} }`; +} + +function buildJsxSortedCall( + s: MagicString, + callNode: AstNode, + opts: JsxCallTransformOptions, + isDirectJsxChild: boolean +): string | null { + // The CallExpression check is required for type-checker narrowing before + // destructuring, even though the caller already gated on it. + if (callNode.type !== 'CallExpression') { + return null; + } + const args = callNode.arguments; + const tagArg = args[0]; + const propsArg = args[1]; + if ( + !tagArg || + tagArg.type === 'SpreadElement' || + !propsArg || + propsArg.type !== 'ObjectExpression' + ) { + return null; + } + + const tag = s.slice(tagArg.start, tagArg.end); + // HTML tags are string-literal first args; component tags are identifiers. + // Event-handler key rewriting (`onClick$` → `"q-e:click"`) applies only to + // HTML — components receive the `*$` form as a JSX prop and wire it at runtime. + const isHtmlTag = tagArg.type === 'Literal' && typeof tagArg.value === 'string'; + + let childrenText: string | null = null; + let childrenIsDynamic = false; + let hasVarEventHandler = false; + const varEntries: string[] = []; + const constEntries: string[] = []; + const slots: PropSlot[] = []; + const spreadArgs: string[] = []; + const qpParams: string[] = []; + for (const prop of propsArg.properties) { + if (prop.type === 'SpreadElement') { + const spreadArg = s.slice(prop.argument.start, prop.argument.end); + spreadArgs.push(spreadArg); + slots.push({ + kind: 'spread', + getVar: `..._getVarProps(${spreadArg})`, + getConst: `..._getConstProps(${spreadArg})`, + }); + continue; + } + if (prop.type !== 'Property') { + return null; + } + const keyName = propertyKeyName(prop); + if (keyName === 'children' && !prop.computed) { + const value = prop.value; + childrenText = s.slice(value.start, value.end); + childrenIsDynamic = !isStaticChildren(value, opts.reactiveBindings ?? new Set()); + continue; + } + if (prop.shorthand === true) { + const entryText = s.slice(prop.start, prop.end); + varEntries.push(entryText); + slots.push({ kind: 'named', text: entryText, isConst: false, rawConst: false }); + if (isHandlerPropKey(keyName)) { + hasVarEventHandler = true; + } + continue; + } + if (isHtmlTag && keyName !== null) { + const transformed = transformEventPropName(keyName, new Set()); + if (transformed !== null) { + const valueText = s.slice(prop.value.start, prop.value.end); + // A const event handler (bare QRL ref `q_X`) belongs in the const props + // bag with the static_listeners flag: put it in the var bag while the + // flag claims static listeners and the runtime looks in the const bag, + // finds nothing, and never wires the event. An inline arrow or computed + // value is a var handler and drops the flag bit. + const eventEntry = `"${transformed}": ${valueText}`; + const rawConst = isConstExpr(prop.value, opts); + if (isConstEventHandlerValue(prop.value)) { + constEntries.push(eventEntry); + slots.push({ kind: 'named', text: eventEntry, isConst: true, rawConst }); + // A const handler delivers its captures positionally; record them + // so the element emits one `q:p`/`q:ps` prop below. + const handlerQp = opts.qpByQrl?.get(valueText); + if (handlerQp) { + for (const p of handlerQp) { + if (!qpParams.includes(p)) { + qpParams.push(p); + } + } + } + } else { + varEntries.push(eventEntry); + slots.push({ kind: 'named', text: eventEntry, isConst: false, rawConst }); + hasVarEventHandler = true; + } + continue; + } + } + if (isHandlerPropKey(keyName)) { + const handlerText = s.slice(prop.start, prop.end); + const isConst = isConstEventHandlerValue(prop.value); + slots.push({ + kind: 'named', + text: handlerText, + isConst, + rawConst: isConstExpr(prop.value, opts), + }); + if (isConst) { + constEntries.push(handlerText); + } else { + varEntries.push(handlerText); + hasVarEventHandler = true; + } + continue; + } + const entryText = s.slice(prop.start, prop.end); + const reactiveConst = classifyProp(prop.value, opts, isHtmlTag, s) === 'const'; + (reactiveConst ? constEntries : varEntries).push(entryText); + slots.push({ + kind: 'named', + text: entryText, + isConst: constBagEligible(prop.value, opts, isHtmlTag, reactiveConst, s), + rawConst: isConstExpr(prop.value, opts), + }); + } + + const shouldEmitKey = !isHtmlTag || !isDirectJsxChild; + let keyText: string; + if (args.length === 3 && args[2] && args[2].type !== 'SpreadElement') { + // An explicit user key rides only on the 3-arg call form; the dev form's + // trailing key/source arguments are synthetic and carry no key. + keyText = s.slice(args[2].start, args[2].end); + } else if (shouldEmitKey) { + keyText = `"${opts.keyCounter.nextFor(callNode.start)}"`; + } else { + keyText = 'null'; + } + + // The element's `q:p`/`q:ps` capture prop goes in the VAR bag (captured + // values can change between renders), ahead of other var props; its presence + // sets the moved_captures flag. + let qpEntry: string | null = null; + if (qpParams.length > 0) { + const qp = buildCaptureProp(qpParams, true); + if (qp) { + qpEntry = `"${qp.propName}": ${qp.propValue}`; + varEntries.unshift(qpEntry); + } + } + + const childrenSlot = childrenText ?? 'null'; + const childrenType: 'none' | 'static' | 'dynamic' = + childrenText === null ? 'none' : childrenIsDynamic ? 'dynamic' : 'static'; + + if (spreadArgs.length > 0) { + opts.neededImports.add('_jsxSplit'); + opts.neededImports.add('_getVarProps'); + opts.neededImports.add('_getConstProps'); + const { varBag, constBag } = partitionSpreadProps(slots); + if (qpEntry !== null) { + varBag.unshift(qpEntry); + } + const varPropsPart = varBag.length > 0 ? `{ ${varBag.join(', ')} }` : 'null'; + const constPropsPart = renderConstBag(constBag); + const splitFlags = qpParams.length > 0 ? 4 : 0; + return `/*#__PURE__*/ _jsxSplit(${tag}, ${varPropsPart}, ${constPropsPart}, ${childrenSlot}, ${splitFlags}, ${keyText})`; + } + + const varPropsText = varEntries.length > 0 ? `{ ${varEntries.join(', ')} }` : 'null'; + const constPropsText = constEntries.length > 0 ? `{ ${constEntries.join(', ')} }` : 'null'; + const hasVarProps = varEntries.length > 0; + let flags = computeJsxFlags(hasVarProps, childrenType, false, hasVarEventHandler); + if (qpParams.length > 0) { + flags |= 4; + } + + opts.neededImports.add('_jsxSorted'); + + return `/*#__PURE__*/ _jsxSorted(${tag}, ${varPropsText}, ${constPropsText}, ${childrenSlot}, ${flags}, ${keyText})`; +} + +/** + * A const event-handler value is a bare QRL identifier (`q_`); its captures ride the element's + * `q:p`/`q:ps` prop. A `q_.w([captures])` handler is NOT const — `.w()` binds captures inline + * so the reference varies with them (var bag, static_listeners cleared). + */ +function isConstEventHandlerValue(value: AstNode): boolean { + return value.type === 'Identifier'; +} + +function propertyKeyName(prop: AstNode): string | null { + if (prop.type !== 'Property' || !prop.key) { + return null; + } + const key = prop.key; + if (key.type === 'Identifier') { + return key.name; + } + if (key.type === 'Literal' && typeof key.value === 'string') { + return key.value; + } + return null; +} + +/** + * Classify a children expression as static. The reactive-binding check runs against the SOURCE AST + * (pre-wrap), since `isStaticChildren` runs before the `_wrapProp` overwrite in + * `buildJsxSortedCall`. + */ +function isStaticChildren(value: AstNode, reactive: ReadonlySet): boolean { + if (value.type === 'Literal') { + return true; + } + if (value.type === 'TemplateLiteral' && (value.expressions ?? []).length === 0) { + return true; + } + if ( + value.type === 'CallExpression' && + value.callee?.type === 'Identifier' && + value.callee.name === '_wrapProp' + ) { + return true; + } + if ( + value.type === 'MemberExpression' && + value.object?.type === 'Identifier' && + reactive.has(value.object.name) + ) { + // A reactive member read (signal `.value` or store `.field`) becomes a + // stable `_wrapProp(...)` reference, so it counts as static children. + const propName = memberStaticPropName(value); + if (propName !== null) { + return true; + } + } + if (value.type === 'ArrayExpression') { + return (value.elements ?? []).every((el) => el != null && isStaticChildren(el, reactive)); + } + return false; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/jsx-children.ts b/packages/ts-optimizer/src/optimizer/jsx/jsx-children.ts new file mode 100644 index 00000000000..5b78626ea2a --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/jsx-children.ts @@ -0,0 +1,279 @@ +import { decodeHTMLStrict } from 'entities'; +import { analyzeSignalExpression } from './signal-analysis.js'; +import { + classifyConstness, + sliceTransformed, + type JsxTransformContext, + type ProcessChildrenOptions, +} from './jsx.js'; +import type { JSXChild, JSXElement, JSXExpressionContainer, JSXFragment } from '../../ast-types.js'; + +type AnnotatedJSXChild = JSXChild & { _trimmedText?: string }; + +export function normalizeJsxChildren( + children: JSXChild[] +): (JSXChild & { _trimmedText?: string })[] { + const meaningful: (JSXChild & { _trimmedText?: string })[] = []; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.type !== 'JSXText') { + meaningful.push(child); + continue; + } + + const raw = decodeHTMLStrict(child.value ?? ''); + const hasNewline = /[\r\n]/.test(raw); + let normalized: string; + + if (hasNewline) { + // JSX whitespace normalization (Babel's `cleanJSXElementLiteralChild`): + // a line that is both the last line of the chunk and non-empty keeps its + // trailing whitespace, and a first line keeps its leading whitespace — + // only newline-adjacent indentation is stripped. Split every line-ending + // form, or a CRLF checkout leaves a `\r` in every emitted text child. + const lines = raw.split(/\r\n|\n|\r/); + let lastNonEmptyLine = -1; + for (let li = 0; li < lines.length; li++) { + if (/[^ \t]/.test(lines[li])) { + lastNonEmptyLine = li; + } + } + let out = ''; + for (let li = 0; li < lines.length; li++) { + let line = lines[li].replace(/\t/g, ' '); + const isFirstLine = li === 0; + const isLastLine = li === lines.length - 1; + const isLastNonEmptyLine = li === lastNonEmptyLine; + if (!isFirstLine) { + line = line.replace(/^ +/, ''); + } + if (!isLastLine) { + line = line.replace(/ +$/, ''); + } + if (line) { + if (!isLastNonEmptyLine) { + line += ' '; + } + out += line; + } + } + normalized = out; + } else { + const prevChild = i > 0 ? children[i - 1] : null; + // Same-line whitespace after a sibling element/expression is significant + // (` ` keeps the space); only leading text of the parent trims. + if ( + prevChild && + (prevChild.type === 'JSXExpressionContainer' || + prevChild.type === 'JSXElement' || + prevChild.type === 'JSXFragment') + ) { + normalized = raw; + } else { + normalized = raw.replace(/^\s+/, ''); + } + const nextNonWhitespace = children + .slice(i + 1) + .find((c: JSXChild) => c.type !== 'JSXText' || (c.type === 'JSXText' && c.value?.trim())); + if (!nextNonWhitespace) { + normalized = normalized.trimEnd(); + } + } + + if (normalized) { + meaningful.push({ ...child, _trimmedText: normalized }); + continue; + } + + if (!hasNewline) { + const prevSibling = meaningful.length > 0 ? meaningful[meaningful.length - 1] : null; + const nextSibling = children + .slice(i + 1) + .find((c: JSXChild) => c.type !== 'JSXText' || c.value.trim()); + if ( + prevSibling && + nextSibling && + prevSibling.type === 'JSXExpressionContainer' && + nextSibling.type === 'JSXExpressionContainer' + ) { + meaningful.push({ ...child, _trimmedText: ' ' }); + } + } + } + + return meaningful; +} + +export function processChildren( + ctx: JsxTransformContext, + children: JSXChild[], + opts: ProcessChildrenOptions +): { text: string | null; type: 'none' | 'static' | 'dynamic' } { + if (!children || children.length === 0) { + return { text: null, type: 'none' }; + } + + const meaningful = normalizeJsxChildren(children); + + if (meaningful.length === 0) { + return { text: null, type: 'none' }; + } + + if (meaningful.length === 1) { + return processOneChild(ctx, meaningful[0], opts); + } + + const parts: string[] = []; + let isDynamic = false; + for (const child of meaningful) { + const { text, type } = processOneChild(ctx, child, opts); + if (text !== null) { + parts.push(text); + } + if (type === 'dynamic') { + isDynamic = true; + } + } + + return { + text: `[${parts.join(', ')}]`, + type: isDynamic ? 'dynamic' : 'static', + }; +} + +/** + * Structural mirror of rust's `jsx_mutable`: component and member-expression tags re-render on + * their own, var props and a dynamic subtree propagate up. The child's facts come from the write + * memo recorded by its own transform (the walk is bottom-up), never from re-parsing generated + * code. + */ +function classifyNestedJsxChild( + ctx: JsxTransformContext, + child: JSXElement | JSXFragment +): 'static' | 'dynamic' { + if (child.type === 'JSXElement') { + const tagName = child.openingElement.name; + if (tagName.type === 'JSXMemberExpression') { + return 'dynamic'; + } + const tagStr = tagName.type === 'JSXIdentifier' ? tagName.name : ''; + const isComponent = + tagStr.length > 0 && + tagStr[0] === tagStr[0].toUpperCase() && + tagStr[0] !== tagStr[0].toLowerCase(); + if (isComponent) { + return 'dynamic'; + } + } + + const written = ctx.jsxWriteMemo?.get(child.start); + // Unknown ⇒ mutable: a misclassified-static child stops updating. + if (!written || written.end !== child.end) { + return 'dynamic'; + } + if (written.hasVarProps) { + return 'dynamic'; + } + return (written.flags & 2) !== 0 ? 'static' : 'dynamic'; +} + +function processOneChild( + ctx: JsxTransformContext, + child: AnnotatedJSXChild, + opts: ProcessChildrenOptions +): { text: string | null; type: 'none' | 'static' | 'dynamic' } { + if (child._trimmedText) { + return { text: JSON.stringify(child._trimmedText), type: 'static' }; + } + + if (child.type === 'JSXText') { + const trimmed = child.value.trim(); + if (!trimmed) { + return { text: null, type: 'none' }; + } + return { text: JSON.stringify(trimmed), type: 'static' }; + } + + if (child.type === 'JSXExpressionContainer') { + return processExpressionChild(ctx, child, opts); + } + + if (child.type === 'JSXElement' || child.type === 'JSXFragment') { + const childText = sliceTransformed(ctx, child.start, child.end); + const type = classifyNestedJsxChild(ctx, child); + return { text: childText, type }; + } + + return { text: null, type: 'none' }; +} + +function processExpressionChild( + ctx: JsxTransformContext, + child: JSXExpressionContainer, + opts: ProcessChildrenOptions +): { text: string | null; type: 'none' | 'static' | 'dynamic' } { + const expr = child.expression; + if (!expr || expr.type === 'JSXEmptyExpression') { + return { text: null, type: 'none' }; + } + + const { source, importedNames, signalHoister, bindings, allDeclaredNames } = ctx; + const { neededImports, enableSignalAnalysis = true } = opts; + const exprText = sliceTransformed(ctx, expr.start, expr.end); + + // Runtime emits all four literal interfaces (String/Numeric/Boolean/Null) + // under one `'Literal'` discriminant; narrow on `.value`'s primitive type. + if ( + expr.type === 'Literal' && + (typeof expr.value === 'string' || + typeof expr.value === 'number' || + typeof expr.value === 'boolean') + ) { + return { text: exprText, type: 'static' }; + } + + // Without signal analysis, staticness can't be proven — classify dynamic. + if (!enableSignalAnalysis) { + return { text: exprText, type: 'dynamic' }; + } + + const signalResult = analyzeSignalExpression(expr, source, importedNames, allDeclaredNames); + + if (signalResult.type === 'wrapProp') { + neededImports.add('_wrapProp'); + let wrapIsConst = true; + if (expr.type === 'MemberExpression' && expr.object?.type === 'Identifier') { + const objName = expr.object.name; + if (!importedNames.has(objName) && bindings?.classify(objName, expr.start) !== 'const') { + wrapIsConst = false; + } + } + return { text: signalResult.code, type: wrapIsConst ? 'static' : 'dynamic' }; + } + + if (signalResult.type === 'fnSignal') { + const depsConst = signalResult.deps.every( + (dep) => importedNames.has(dep) || bindings?.classify(dep, expr.start) === 'const' + ); + // Rust never wraps a non-const template literal or call: re-evaluating + // one may repeat side effects it contains (transform.rs). + if (depsConst || (expr.type !== 'TemplateLiteral' && expr.type !== 'CallExpression')) { + const hfName = signalHoister.hoist( + signalResult.hoistedFn, + signalResult.hoistedStr, + expr.start ?? 0 + ); + const fnSignalCall = `_fnSignal(${hfName}, [${signalResult.deps.join(', ')}], ${hfName}_str)`; + neededImports.add('_fnSignal'); + return { text: fnSignalCall, type: depsConst ? 'static' : 'dynamic' }; + } + } + + const propClass = classifyConstness(expr, importedNames, bindings, expr.start); + if (propClass === 'const') { + return { text: exprText, type: 'static' }; + } + + return { text: exprText, type: 'dynamic' }; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/jsx-elements-core.ts b/packages/ts-optimizer/src/optimizer/jsx/jsx-elements-core.ts new file mode 100644 index 00000000000..d05dc37f096 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/jsx-elements-core.ts @@ -0,0 +1,709 @@ +import type { JSXAttributeItem, JSXElement, JSXFragment } from '../../ast-types.js'; +import { collectPassiveDirectives } from './event-handlers.js'; +import { buildCaptureProp, type LoopContext } from './loop-hoisting.js'; +import { + processProps, + formatPropName, + isRewrittenEventEntry, + sortVarEntries, + type SlotEntry, +} from './jsx-props.js'; +import { processChildren } from './jsx-children.js'; +import { + type JsxTransformResult, + processJsxTag, + isTextOnlyElement, + computeJsxFlags, + type JsxTransformContext, + type JsxElementOptions, + type ScopeAwareBindings, +} from './jsx.js'; + +function buildAdditionalSpreadsPart(additionalSpreads: string[], spreadArg: string): string { + if (additionalSpreads.length === 0) { + return ''; + } + + const spreadEntries = additionalSpreads.map((spread) => { + if (spread === spreadArg) { + return `..._getVarProps(${spread})`; + } + return `...${spread}`; + }); + return `, ${spreadEntries.join(', ')}`; +} + +function buildConstPropsPart( + constEntries: string[], + spreadArg: string, + hasDuplicateSpreads: boolean +): string { + if (constEntries.length > 0) { + return `{ ${constEntries.join(', ')} }`; + } + if (hasDuplicateSpreads) { + return `_getConstProps(${spreadArg})`; + } + return 'null'; +} + +function injectQpProp( + node: JSXElement, + tagIsHtml: boolean, + inLoop: boolean, + loopCtx: LoopContext | null | undefined, + qpOverrides: Map | undefined, + varEntries: string[], + constEntries: string[], + qrlsWithCaptures: Set | undefined +): void { + if (!tagIsHtml) { + return; + } + + const overrideParams = qpOverrides?.get(node.start); + if (overrideParams && overrideParams.length > 0) { + const qpResult = buildCaptureProp(overrideParams, true); + if (qpResult) { + insertEntrySorted(varEntries, qpResult.propName, qpResult.propValue); + } + return; + } + + if (!inLoop || qpOverrides) { + return; + } + + const hasEventHandlers = + varEntries.some((e) => isRewrittenEventEntry(e) || e.startsWith('"host:')) || + constEntries.some((e) => isRewrittenEventEntry(e) || e.startsWith('"host:')); + if (!hasEventHandlers) { + return; + } + + // Suppress the iterVars fallback when an event handler references a QRL with + // hoisted cross-scope captures: those handlers receive data via `.w([captures])` + // bindings hoisted to the outer loop scope, so an immediate-iterVars `q:p` + // would be redundant. + if ( + qrlsWithCaptures && + eventHandlerReferencesCapturingQrl(varEntries, constEntries, qrlsWithCaptures) + ) { + return; + } + + const qpResult = buildCaptureProp(loopCtx!.iterVars); + if (qpResult) { + insertEntrySorted(varEntries, qpResult.propName, qpResult.propValue); + } +} + +/** `_jsxSorted` prop bags are key-sorted; splice the entry at its sorted slot. */ +function insertEntrySorted(entries: string[], propName: string, propValue: string): void { + const entry = `${formatPropName(propName)}: ${propValue}`; + const nameOf = (e: string): string => { + const m = /^"([^"]+)"|^([\w$-]+)/.exec(e); + return m ? (m[1] ?? m[2] ?? '') : ''; + }; + let at = entries.length; + for (let i = 0; i < entries.length; i++) { + if (nameOf(entries[i]) > propName) { + at = i; + break; + } + } + entries.splice(at, 0, entry); +} + +/** + * The prop name itself contains a colon (`q-e:click`), so the value separator is the colon _after_ + * the name's closing quote. + */ +function eventHandlerReferencesCapturingQrl( + varEntries: string[], + constEntries: string[], + qrlsWithCaptures: Set +): boolean { + for (const e of [...varEntries, ...constEntries]) { + if (!isRewrittenEventEntry(e)) { + continue; + } + if (e[0] !== '"') { + continue; + } + const closingQuote = e.indexOf('"', 1); + if (closingQuote < 0) { + continue; + } + const colonIdx = e.indexOf(':', closingQuote); + if (colonIdx < 0) { + continue; + } + const valueText = e.slice(colonIdx + 1).trim(); + if (qrlsWithCaptures.has(valueText)) { + return true; + } + } + return false; +} + +function moveEventHandlersForNonConstCaptures( + node: JSXElement, + tagIsHtml: boolean, + inLoop: boolean, + qpOverrides: Map | undefined, + bindings: ScopeAwareBindings | undefined, + importedNames: Set, + varEntries: string[], + constEntries: string[], + hasSpread: boolean +): boolean { + if (!tagIsHtml || inLoop) { + return false; + } + + const overrideParams = qpOverrides?.get(node.start); + if (!overrideParams || overrideParams.length === 0) { + return false; + } + + const hasNonConstParam = overrideParams.some( + (p) => bindings?.classify(p, node.start) !== 'const' && !importedNames.has(p) + ); + if (!hasNonConstParam) { + return false; + } + + let movedAny = false; + for (let i = constEntries.length - 1; i >= 0; i--) { + if (isRewrittenEventEntry(constEntries[i])) { + varEntries.push(constEntries[i]); + constEntries.splice(i, 1); + movedAny = true; + } + } + + if (movedAny && !hasSpread) { + sortVarEntries(varEntries); + } + + return movedAny; +} + +function buildCreateElementCall( + tag: string, + spreadArg: string, + beforeSpreadEntries: string[], + varEntries: string[], + constEntries: string[], + explicitKey: string, + childrenText: string | null, + neededImports: Set +): JsxTransformResult { + neededImports.add('createElement as _createElement'); + + const allPropEntries = [...beforeSpreadEntries, ...varEntries, ...constEntries]; + allPropEntries.push(`key: ${explicitKey}`); + const propsObj = `{ ...${spreadArg}, ${allPropEntries.join(', ')} }`; + const callString = `_createElement(${tag}, ${propsObj})`; + + return { + tag, + varProps: null, + constProps: null, + children: childrenText, + flags: 0, + key: explicitKey, + callString, + neededImports, + }; +} + +/** + * Source-ordered `_jsxSplit` emission. Returns null when the rule doesn't apply (single spread, no + * spread, or no real-const-after-all-spreads — the wrapper-based path handles those). + * + * Rule: with MULTIPLE spreads AND at least one explicit "real-const" prop + * (literal/stable-QRL/identifier value, not event-handler routing or `q:p*` metadata) positioned + * AFTER all spreads, the explicit const props cover the const-bag completely; spreads contribute + * only raw `...expr` to the var-bag at their source position, and the var-bag preserves source + * order. Single-spread cases go through the wrapper-based path so the spread's keys are classified + * at runtime. + */ +function tryBuildSourceOrderedJsxSplit( + tag: string, + slotOrder: readonly SlotEntry[], + childrenText: string | null, + flags: number, + keyStr: string | null, + neededImports: Set +): JsxTransformResult | null { + let spreadCount = 0; + let lastSpreadStart = -1; + for (const slot of slotOrder) { + if (slot.kind !== 'spread') { + continue; + } + spreadCount++; + if (slot.sourceStart > lastSpreadStart) { + lastSpreadStart = slot.sourceStart; + } + } + if (spreadCount < 2) { + return null; + } + + let hasRealConstAfterSpreads = false; + for (const slot of slotOrder) { + if (slot.kind !== 'named') { + continue; + } + if (slot.classification !== 'const') { + continue; + } + if (slot.sourceStart <= lastSpreadStart) { + continue; + } + if (isRewrittenEventEntry(slot.entry) || slot.entry.startsWith('"q:')) { + continue; + } + hasRealConstAfterSpreads = true; + break; + } + if (!hasRealConstAfterSpreads) { + return null; + } + + const varParts: string[] = []; + const constParts: string[] = []; + for (const slot of slotOrder) { + if (slot.kind === 'spread') { + varParts.push(`...${slot.expr}`); + continue; + } + const isAfterAllSpreads = slot.sourceStart > lastSpreadStart; + if (slot.classification === 'const' && isAfterAllSpreads) { + constParts.push(slot.entry); + } else { + varParts.push(slot.entry); + } + } + + neededImports.add('_jsxSplit'); + const varPropsPart = varParts.length > 0 ? `{ ${varParts.join(', ')} }` : 'null'; + const constPropsPart = constParts.length > 0 ? `{ ${constParts.join(', ')} }` : 'null'; + const callString = `_jsxSplit(${tag}, ${varPropsPart}, ${constPropsPart}, ${childrenText ?? 'null'}, ${flags}, ${keyStr ?? 'null'})`; + + return { + tag, + varProps: varParts.length > 0 ? varPropsPart : null, + constProps: constPropsPart, + children: childrenText, + flags, + key: keyStr, + callString, + neededImports, + }; +} + +function buildJsxSplitCall( + tag: string, + tagIsHtml: boolean, + spreadArg: string, + beforeSpreadEntries: string[], + varEntries: string[], + constEntries: string[], + additionalSpreads: string[], + childrenText: string | null, + flags: number, + keyStr: string | null, + neededImports: Set, + slotOrder?: readonly SlotEntry[] +): JsxTransformResult { + if (slotOrder && slotOrder.length > 0) { + const sourceOrdered = tryBuildSourceOrderedJsxSplit( + tag, + slotOrder, + childrenText, + flags, + keyStr, + neededImports + ); + if (sourceOrdered !== null) { + return sourceOrdered; + } + } + + // Multiple spreads: emit slots in source order so a later spread overrides + // earlier named props (and vice versa). Only when every named entry went + // through slotOrder — post-hoc bucket pushes (bind/event merging) bypass it. + if ( + additionalSpreads.length > 0 && + slotOrder && + slotOrder.filter((sl) => sl.kind === 'named').length === + beforeSpreadEntries.length + varEntries.length + constEntries.length + ) { + let lastSpreadStart = -1; + for (const slot of slotOrder) { + if (slot.kind === 'spread' && slot.sourceStart > lastSpreadStart) { + lastSpreadStart = slot.sourceStart; + } + } + // Only the props-proxy spread (the first spread's expression, per the + // legacy wrapping rule) splits into _getVarProps/_getConstProps; + // arbitrary expressions spread raw. A wrapped FINAL spread donates its + // const part to the const arg; any other wrapped spread emits both parts + // inline at its source position. + const isWrappable = (expr: string): boolean => expr === spreadArg; + let finalSpreadWrapped = false; + const varParts: string[] = []; + const constParts: string[] = []; + for (const slot of slotOrder) { + if (slot.kind === 'spread') { + const isLast = slot.sourceStart === lastSpreadStart; + if (isWrappable(slot.expr)) { + varParts.push(`..._getVarProps(${slot.expr})`); + if (isLast) { + finalSpreadWrapped = true; + } else { + varParts.push(`..._getConstProps(${slot.expr})`); + } + } else { + varParts.push(`...${slot.expr}`); + } + continue; + } + if (slot.sourceStart > lastSpreadStart && slot.classification === 'const') { + constParts.push(slot.entry); + } else { + varParts.push(slot.entry); + } + } + neededImports.add('_jsxSplit'); + neededImports.add('_getVarProps'); + neededImports.add('_getConstProps'); + const varPropsPart = varParts.length > 0 ? `{ ${varParts.join(', ')} }` : 'null'; + const constPropsPart = finalSpreadWrapped + ? constParts.length > 0 + ? `{ ..._getConstProps(${spreadArg}), ${constParts.join(', ')} }` + : `_getConstProps(${spreadArg})` + : constParts.length > 0 + ? `{ ${constParts.join(', ')} }` + : 'null'; + const callString = `_jsxSplit(${tag}, ${varPropsPart}, ${constPropsPart}, ${childrenText ?? 'null'}, ${flags}, ${keyStr ?? 'null'})`; + return { + tag, + varProps: varParts.length > 0 ? varPropsPart : null, + constProps: constPropsPart, + children: childrenText, + flags, + key: keyStr, + callString, + neededImports, + }; + } + + neededImports.add('_jsxSplit'); + neededImports.add('_getVarProps'); + neededImports.add('_getConstProps'); + + const beforePart = beforeSpreadEntries.length > 0 ? `${beforeSpreadEntries.join(', ')}, ` : ''; + const afterPart = varEntries.length > 0 ? `, ${varEntries.join(', ')}` : ''; + const additionalSpreadsPart = buildAdditionalSpreadsPart(additionalSpreads, spreadArg); + + let varPropsPart: string; + let constPropsPart: string; + + const componentHasExtras = + !tagIsHtml && + (constEntries.length > 0 || + varEntries.length > 0 || + beforeSpreadEntries.length > 0 || + additionalSpreads.length > 0); + + const partitionableComponentSpread = + componentHasExtras && additionalSpreads.length === 0 && constEntries.length > 0; + + if (partitionableComponentSpread) { + const hasVarAfterSpread = varEntries.length > 0; + const getConstInVar = hasVarAfterSpread ? `, ..._getConstProps(${spreadArg})` : ''; + varPropsPart = `{ ${beforePart}..._getVarProps(${spreadArg})${getConstInVar}${afterPart} }`; + const getConstInConst = hasVarAfterSpread ? '' : `..._getConstProps(${spreadArg}), `; + constPropsPart = `{ ${getConstInConst}${constEntries.join(', ')} }`; + } else if (componentHasExtras) { + const constPart = constEntries.length > 0 ? `, ${constEntries.join(', ')}` : ''; + varPropsPart = `{ ${beforePart}..._getVarProps(${spreadArg}), ..._getConstProps(${spreadArg})${afterPart}${constPart}${additionalSpreadsPart} }`; + constPropsPart = 'null'; + } else { + const hasNonBindNonEventVarEntries = varEntries.some( + (e) => + !e.startsWith('"bind:') && + !isRewrittenEventEntry(e) && + !e.startsWith('"q:p') && + !e.startsWith('"q:ps') + ); + const shouldMergeConst = + (varEntries.length > 0 && constEntries.length > 0) || hasNonBindNonEventVarEntries; + + if (shouldMergeConst) { + // When const entries include a "real" const prop (not just event-handler + // routing or `q:p` metadata), split the spreads: `_getConstProps` goes in + // the const bag alongside the real const entries. Event-handler-only const + // entries keep the merged form (both spreads in the var bag). + const hasRealConstEntries = constEntries.some( + (e) => !isRewrittenEventEntry(e) && !e.startsWith('"q:') + ); + if (hasRealConstEntries) { + varPropsPart = `{ ${beforePart}..._getVarProps(${spreadArg})${afterPart}${additionalSpreadsPart} }`; + constPropsPart = `{ ..._getConstProps(${spreadArg}), ${constEntries.join(', ')} }`; + } else { + varPropsPart = `{ ${beforePart}..._getVarProps(${spreadArg}), ..._getConstProps(${spreadArg})${afterPart}${additionalSpreadsPart} }`; + const hasDuplicateSpreads = additionalSpreads.some((s) => s === spreadArg); + constPropsPart = buildConstPropsPart(constEntries, spreadArg, hasDuplicateSpreads); + } + } else { + varPropsPart = `{ ${beforePart}..._getVarProps(${spreadArg})${afterPart}${additionalSpreadsPart} }`; + constPropsPart = + constEntries.length > 0 + ? `{ ..._getConstProps(${spreadArg}), ${constEntries.join(', ')} }` + : `_getConstProps(${spreadArg})`; + } + } + + const callString = `_jsxSplit(${tag}, ${varPropsPart}, ${constPropsPart}, ${childrenText ?? 'null'}, ${flags}, ${keyStr ?? 'null'})`; + + return { + tag, + varProps: varPropsPart, + constProps: constPropsPart, + children: childrenText, + flags, + key: keyStr, + callString, + neededImports, + }; +} + +export function transformJsxElement( + ctx: JsxTransformContext, + node: JSXElement, + opts: JsxElementOptions = {} +): JsxTransformResult | null { + if (node.type !== 'JSXElement') { + return null; + } + + const { source, importedNames, keyCounter, bindings, qrlsWithCaptures } = ctx; + const { + passiveEvents, + loopCtx, + isSoleChild, + enableChildSignals = true, + qpOverrides, + inAttrValue = false, + } = opts; + + const neededImports = new Set(); + const openingElement = node.openingElement; + const tag = processJsxTag(openingElement.name); + const tagIsHtml = tag.startsWith('"') && tag.length > 2; + const rawTagName = tagIsHtml ? tag.slice(1, -1) : ''; + const textOnly = tagIsHtml && isTextOnlyElement(rawTagName); + const elementPassiveEvents = passiveEvents ?? collectPassiveDirectives(openingElement.attributes); + const inLoop = !!loopCtx && loopCtx.iterVars.length > 0; + + const preHasSpread = + openingElement.attributes?.some((a: JSXAttributeItem) => a.type === 'JSXSpreadAttribute') ?? + false; + const preHasKey = + openingElement.attributes?.some( + (a: JSXAttributeItem) => + a.type === 'JSXAttribute' && + ((a.name?.type === 'JSXIdentifier' && a.name.name === 'key') || + (a.name?.type === 'JSXNamespacedName' && a.name.name?.name === 'key')) + ) ?? false; + const willUseCreateElement = preHasSpread && preHasKey; + + const { + varEntries, + constEntries, + beforeSpreadEntries, + additionalSpreads, + key: explicitKey, + hasVarProps, + hasVarEventHandler: initialHasVarEventHandler, + hasSpread, + slotOrder, + neededImports: propImports, + } = processProps(ctx, openingElement.attributes, { + tagIsHtml, + passiveEvents: elementPassiveEvents, + inLoop, + skipSignalAnalysis: willUseCreateElement, + loopIterVars: loopCtx?.iterVars, + }); + let hasVarEventHandler = initialHasVarEventHandler; + + for (const imp of propImports) { + neededImports.add(imp); + } + + injectQpProp( + node, + tagIsHtml, + inLoop, + loopCtx, + qpOverrides, + varEntries, + constEntries, + qrlsWithCaptures + ); + + if ( + moveEventHandlersForNonConstCaptures( + node, + tagIsHtml, + inLoop, + qpOverrides, + bindings, + importedNames, + varEntries, + constEntries, + hasSpread + ) + ) { + hasVarEventHandler = true; + } + + const childSignalsEnabled = enableChildSignals && !textOnly; + const { text: childrenText, type: childrenType } = processChildren(ctx, node.children, { + neededImports, + enableSignalAnalysis: childSignalsEnabled, + }); + + const hasQpProp = + varEntries.some((e) => e.startsWith('"q:p"') || e.startsWith('"q:ps"')) || + constEntries.some((e) => e.startsWith('"q:p"') || e.startsWith('"q:ps"')); + const effectiveLoopCtx = tagIsHtml && (qpOverrides ? hasQpProp : !!loopCtx && hasQpProp); + const effectiveHasVarProps = varEntries.length > 0 || beforeSpreadEntries.length > 0; + const isRealLoop = !!loopCtx && loopCtx.iterVars.length > 0; + const isCaptureOnly = effectiveLoopCtx && !isRealLoop; + + let flags: number; + if (hasSpread) { + const hasQpEntry = varEntries.some( + (e) => + e.startsWith('"q:p"') || + e.startsWith('"q:p":') || + e.startsWith('"q:ps"') || + e.startsWith('"q:ps":') + ); + flags = hasQpEntry ? 4 : 0; + } else if (isCaptureOnly) { + flags = computeJsxFlags(hasVarProps, childrenType, false, hasVarEventHandler) | 4; + } else { + flags = computeJsxFlags( + effectiveHasVarProps, + childrenType, + effectiveLoopCtx, + hasVarEventHandler + ); + } + + let keyStr: string | null; + if (explicitKey !== null) { + keyStr = explicitKey; + } else if (inAttrValue || (isSoleChild && tagIsHtml)) { + keyStr = null; + } else { + keyStr = `"${keyCounter.nextFor(node.start)}"`; + } + + if (hasSpread) { + const spreadAttr = openingElement.attributes.find( + (a: JSXAttributeItem) => a.type === 'JSXSpreadAttribute' + ); + const spreadArg = spreadAttr + ? source.slice(spreadAttr.argument.start, spreadAttr.argument.end) + : 'props'; + + if (explicitKey !== null) { + return buildCreateElementCall( + tag, + spreadArg, + beforeSpreadEntries, + varEntries, + constEntries, + explicitKey, + childrenText, + neededImports + ); + } + + return buildJsxSplitCall( + tag, + tagIsHtml, + spreadArg, + beforeSpreadEntries, + varEntries, + constEntries, + additionalSpreads, + childrenText, + flags, + keyStr, + neededImports, + slotOrder + ); + } + + const hasBindInConst = !tagIsHtml && constEntries.some((e) => e.startsWith('"bind:')); + const varProps = varEntries.length > 0 ? `{ ${varEntries.join(', ')} }` : null; + const constProps = constEntries.length > 0 ? `{ ${constEntries.join(', ')} }` : null; + const jsxFn = hasBindInConst ? '_jsxSplit' : '_jsxSorted'; + neededImports.add(jsxFn); + const callString = `${jsxFn}(${tag}, ${varProps ?? 'null'}, ${constProps ?? 'null'}, ${childrenText ?? 'null'}, ${flags}, ${keyStr ?? 'null'})`; + + return { + tag, + varProps, + constProps, + children: childrenText, + flags, + key: keyStr, + callString, + neededImports, + }; +} + +export function transformJsxFragment( + ctx: JsxTransformContext, + node: JSXFragment, + opts: { inAttrValue?: boolean } = {} +): JsxTransformResult | null { + if (node.type !== 'JSXFragment') { + return null; + } + + const { keyCounter } = ctx; + const neededImports = new Set(); + neededImports.add('_jsxSorted'); + + const { text: childrenText, type: childrenType } = processChildren(ctx, node.children, { + neededImports, + }); + + const flags = computeJsxFlags(false, childrenType); + const keyStr = opts.inAttrValue ? null : `"${keyCounter.nextFor(node.start)}"`; + const callString = `_jsxSorted(_Fragment, null, null, ${childrenText ?? 'null'}, ${flags}, ${keyStr ?? 'null'})`; + + return { + tag: '_Fragment', + varProps: null, + constProps: null, + children: childrenText, + flags, + key: keyStr, + callString, + neededImports, + }; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/jsx-import-source.ts b/packages/ts-optimizer/src/optimizer/jsx/jsx-import-source.ts new file mode 100644 index 00000000000..3d02f7c4ffe --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/jsx-import-source.ts @@ -0,0 +1,58 @@ +/** + * Yield the module's leading comments — everything before the first token. Anything later is not a + * pragma site, so scanning stops there rather than searching the whole file for comment-shaped + * text. + */ +function* leadingComments(source: string): Generator<{ text: string }> { + let i = 0; + while (i < source.length) { + const ch = source[i]; + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + i++; + continue; + } + if (ch === '/' && source[i + 1] === '*') { + const close = source.indexOf('*/', i + 2); + const end = close < 0 ? source.length : close + 2; + yield { text: source.slice(i, end) }; + i = end; + continue; + } + if (ch === '/' && source[i + 1] === '/') { + const nl = source.indexOf('\n', i); + const end = nl < 0 ? source.length : nl; + yield { text: source.slice(i, end) }; + i = end; + continue; + } + return; + } +} + +const jsxImportSourcePragma = /@jsxImportSource\s+(\S+)/; + +function isQwikRuntime(importSource: string): boolean { + return importSource.startsWith('@qwik') || importSource.startsWith('@builder.io/qwik'); +} + +/** + * Detect an `@jsxImportSource` pragma naming a non-Qwik runtime (`react`, `preact`, …), meaning the + * file's JSX should be compiled by that runtime, not Qwik. + * + * Matches oxc, which is what actually compiles the JSX: it reads the pragma from any leading + * comment form — `//`, `/* *\/` or `/** *\/` — and ignores one that appears after the first token. + * Detecting a pragma oxc would ignore is as wrong as missing one it honours: we would hand the file + * to a runtime oxc never switches to. + */ +export function detectForeignJsxRuntime(source: string): { + hasForeignJsxRuntime: boolean; + pragmaText: string | null; +} { + for (const { text } of leadingComments(source)) { + const match = text.match(jsxImportSourcePragma); + if (match && !isQwikRuntime(match[1])) { + return { hasForeignJsxRuntime: true, pragmaText: text }; + } + } + return { hasForeignJsxRuntime: false, pragmaText: null }; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/jsx-props.ts b/packages/ts-optimizer/src/optimizer/jsx/jsx-props.ts new file mode 100644 index 00000000000..5c3cd6c3188 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/jsx-props.ts @@ -0,0 +1,649 @@ +import { decodeHTMLStrict } from 'entities'; +import type { AstMaybeNode, JSXAttributeItem } from '../../ast-types.js'; +import { analyzeSignalExpression } from './signal-analysis.js'; +import { transformEventPropName, isEventProp, isPassiveDirective } from './event-handlers.js'; +import { isBindProp, transformBindProp, mergeEventHandlers } from './bind.js'; +import { isCaptureWrappingQrlCall } from '../qwik/w-call.js'; +import { + classifyConstness, + isConstBindingName, + fnSignalDepsAllConst, + sliceTransformed, + type JsxTransformContext, + type ProcessPropsOptions, +} from './jsx.js'; +import { simplifyExpression, formatSimplifiedLiteral } from './simplify.js'; +import { startsWithRewrittenEventPrefix } from '../qwik/event-attrs.js'; +import { getJsxAttributeName } from './jsx-attr-name.js'; + +/** + * Read a byte range from the transform buffer, falling back to the original `source` when + * MagicString throws — which happens when the range's start byte lies inside a previously-replaced + * range (an upstream rewrite overwrote the attribute value before the JSX walker reached the outer + * element). + */ +function sliceWithFallback(ctx: JsxTransformContext, start: number, end: number): string { + try { + return sliceTransformed(ctx, start, end); + } catch { + return ctx.source.slice(start, end); + } +} + +/** + * Slot entry — one per JSX attribute in source order. Captures enough for `buildJsxSplitCall` to + * assemble var/const bags in emit order: + * + * - Attributes appear in source order; + * - Named props with stable values placed AFTER ALL spreads land in the const-bag; everything else + * (stable-but-pre-spread and unstable values) lands in the var-bag at its source position; + * - Spreads appear as raw `...expr` in the var-bag when the post-all-spreads const set is non-empty; + * otherwise the `_getVarProps`/`_getConstProps` wrappers are needed so the spread's content is + * classified at runtime. + */ +export type SlotEntry = + | { + readonly kind: 'named'; + readonly entry: string; + readonly classification: 'var' | 'const'; + readonly sourceStart: number; + } + | { + readonly kind: 'spread'; + readonly expr: string; + readonly sourceStart: number; + }; + +/** + * A JSX attr value that stays fully static — literal-only props (an explicit key is fine) and + * literal/text children. Rust parks these in the const bag; anything reactive stays var. + */ +function isStaticJsxAttrValue(node: NonNullable): boolean { + if (node.type !== 'JSXElement' && node.type !== 'JSXFragment') { + return false; + } + if (node.type === 'JSXElement') { + for (const attr of node.openingElement.attributes ?? []) { + if (attr.type !== 'JSXAttribute') { + return false; + } + if (attr.name?.type !== 'JSXIdentifier') { + return false; + } + if (attr.name.name !== 'key' && attr.name.name.endsWith('$')) { + return false; + } + const v = attr.value; + if (v == null || v.type === 'Literal') { + continue; + } + if (v.type === 'JSXExpressionContainer' && v.expression.type === 'Literal') { + continue; + } + return false; + } + } + for (const child of node.children ?? []) { + if (child.type === 'JSXText') { + continue; + } + if (child.type === 'JSXElement' || child.type === 'JSXFragment') { + if (isStaticJsxAttrValue(child)) { + continue; + } + return false; + } + if (child.type === 'JSXExpressionContainer' && child.expression.type === 'Literal') { + continue; + } + return false; + } + return true; +} + +/** Identifier names in the capture array of a `q_X.w([a, b])` call. */ +function wCallCaptureIdents(callNode: NonNullable): string[] { + if (callNode.type !== 'CallExpression') { + return []; + } + const arr = callNode.arguments?.[0]; + if (!arr || arr.type !== 'ArrayExpression') { + return []; + } + const names: string[] = []; + for (const el of arr.elements ?? []) { + if (el?.type === 'Identifier') { + names.push(el.name); + } + } + return names; +} + +function isConstValueNode(valueNode: AstMaybeNode): boolean { + if (!valueNode) { + return true; + } + switch (valueNode.type) { + case 'ArrowFunctionExpression': + case 'FunctionExpression': + case 'Identifier': + case 'Literal': + return true; + case 'CallExpression': + return isCaptureWrappingQrlCall(valueNode); + default: + return false; + } +} + +/** + * Fold a string-literal JSX attribute value so a value spanning multiple physical lines (or + * containing tabs) survives re-emission as a single-line JS string literal — raw newlines in an + * emitted JS string are a syntax error. `quoted` is the raw source slice including its surrounding + * quotes. + */ +function foldJsxAttrStringWhitespace(quoted: string): string { + const quote = quoted[0]; + const interior = quoted.slice(1, -1); + const lines = interior.split(/\r\n|\r|\n/); + const folded = lines + .map((line, i) => { + let l = line.replace(/\t/g, ' '); + if (i > 0) { + l = l.replace(/^ +/, ''); + } + if (i < lines.length - 1) { + l = l.replace(/ +$/, ''); + } + return l; + }) + .join(' '); + return `${quote}${folded}${quote}`; +} + +export function isRewrittenEventEntry(entry: string): boolean { + return entry.startsWith('"') && startsWithRewrittenEventPrefix(entry.slice(1)); +} + +/** Entry key up to the value separator — quoted keys may themselves contain `:`. */ +function entryKey(entry: string): string { + if (entry.startsWith('"')) { + const close = entry.indexOf('"', 1); + if (close > 0) { + return entry.slice(1, close); + } + } + return entry.split(':')[0].trim(); +} + +export function sortVarEntries(entries: string[]): void { + if (entries.length > 1) { + entries.sort((a, b) => { + const keyA = entryKey(a); + const keyB = entryKey(b); + // Code-unit order — localeCompare diverges from rust on case/punctuation. + return keyA < keyB ? -1 : keyA > keyB ? 1 : 0; + }); + } +} + +function needsQuoting(name: string): boolean { + return /[^a-zA-Z0-9_$]/.test(name); +} + +export function formatPropName(name: string): string { + return needsQuoting(name) ? `"${name}"` : name; +} + +export function processProps( + ctx: JsxTransformContext, + attributes: JSXAttributeItem[], + opts: ProcessPropsOptions +): { + varEntries: string[]; + constEntries: string[]; + beforeSpreadEntries: string[]; + key: string | null; + hasVarProps: boolean; + hasVarEventHandler: boolean; + hasSpread: boolean; + additionalSpreads: string[]; + slotOrder: SlotEntry[]; + neededImports: Set; +} { + const { + source, + importedNames, + signalHoister, + qrlsWithCaptures, + qrlsNonConst, + paramNames, + bindings, + allDeclaredNames, + } = ctx; + const { tagIsHtml, passiveEvents, inLoop, skipSignalAnalysis, loopIterVars } = opts; + const varEntries: string[] = []; + const constEntries: string[] = []; + const beforeSpreadEntries: string[] = []; + const slotOrder: SlotEntry[] = []; + const neededImports = new Set(); + let key: string | null = null; + let hasSpread = false; + let hasVarEventHandler = false; + const bindHandlers = new Map(); + const varBindHandlers = new Set(); + + if (!attributes || attributes.length === 0) { + return { + varEntries, + constEntries, + beforeSpreadEntries, + key, + hasVarProps: false, + hasVarEventHandler: false, + hasSpread, + additionalSpreads: [], + slotOrder, + neededImports, + }; + } + + // Dual-write the legacy buckets and `slotOrder` at every push so the many + // existing bucket readers (event-handler movement, `buildJsxSplitCall`, + // `_jsxSorted` emit) don't need to change. + const pushNamed = ( + bucket: string[], + entry: string, + classification: 'var' | 'const', + sourceStart: number + ): void => { + bucket.push(entry); + slotOrder.push({ kind: 'named', entry, classification, sourceStart }); + }; + const pushSpread = (expr: string, sourceStart: number): void => { + slotOrder.push({ kind: 'spread', expr, sourceStart }); + }; + + const hasSpreadAttr = attributes.some((a) => a.type === 'JSXSpreadAttribute'); + const hasBindAttr = attributes.some( + (a) => a.type === 'JSXAttribute' && isBindProp(getJsxAttributeName(a)) + ); + const spreadIndex = attributes.findIndex((a) => a.type === 'JSXSpreadAttribute'); + const additionalSpreads: string[] = []; + let spreadCount = 0; + + for (let attrIdx = 0; attrIdx < attributes.length; attrIdx++) { + const attr = attributes[attrIdx]; + const beforeSpread = hasSpreadAttr && attrIdx < spreadIndex; + + if (attr.type === 'JSXSpreadAttribute') { + hasSpread = true; + spreadCount++; + const spreadExpr = source.slice(attr.argument.start, attr.argument.end); + if (spreadCount > 1) { + additionalSpreads.push(spreadExpr); + } + pushSpread(spreadExpr, attr.start); + continue; + } + + if (attr.type !== 'JSXAttribute') { + continue; + } + + let propName = getJsxAttributeName(attr); + + if (propName === 'className' && tagIsHtml) { + propName = 'class'; + } + + if (propName === 'key') { + if (attr.value) { + if (attr.value.type === 'JSXExpressionContainer') { + key = source.slice(attr.value.expression.start, attr.value.expression.end); + } else if (attr.value.type === 'Literal') { + key = JSON.stringify(decodeHTMLStrict(String(attr.value.value))); + } + } + continue; + } + + if (isPassiveDirective(propName)) { + continue; + } + + if (propName.startsWith('preventdefault:')) { + const eventName = propName.slice('preventdefault:'.length); + if (!passiveEvents.has(eventName)) { + pushNamed(constEntries, `"${propName}": true`, 'const', attr.start); + } + continue; + } + + let valueText: string; + let valueNode: AstMaybeNode; + + if (attr.value === null || attr.value === undefined) { + valueText = 'true'; + valueNode = null; + } else if (attr.value.type === 'JSXExpressionContainer') { + valueNode = attr.value.expression; + // Prefer the post-rewrite buffer over raw source: DFS leaves children + // before the parent, so JSX nested in this attribute's arrow body is + // already overwritten in `s`; slicing raw `source` would re-emit the + // original JSX and the outer element's overwrite would clobber the inner + // rewrites. The fallback covers value bytes inside a replaced range, + // where MagicString throws on the slice anchor. + valueText = sliceWithFallback(ctx, valueNode.start, valueNode.end); + // Fold compile-time-constant prop expressions to their literal result, + // so `prop={'true' + 1 ? 'true' : ''}` emits as `prop: 'true'` instead + // of `prop: "true" + 1 ? "true" : ""`. + const simplified = simplifyExpression(valueNode); + if (simplified.simplified) { + valueText = formatSimplifiedLiteral(simplified.value); + } + } else { + valueNode = attr.value; + valueText = sliceWithFallback(ctx, attr.value.start, attr.value.end); + if (attr.value.type === 'Literal') { + const decodedValue = decodeHTMLStrict(String(attr.value.value)); + if (decodedValue !== attr.value.value) { + valueText = JSON.stringify(decodedValue); + } else if (/[\n\r\t]/.test(valueText)) { + // Only fold multiline values, so single-line source bytes stay exact. + valueText = foldJsxAttrStringWhitespace(valueText); + } + } + } + + if (isBindProp(propName) && !tagIsHtml) { + let bindValue = valueText; + if (valueNode && !skipSignalAnalysis) { + const bindSignal = analyzeSignalExpression( + valueNode, + source, + importedNames, + allDeclaredNames + ); + if (bindSignal.type === 'wrapProp' && bindSignal.isStoreField) { + bindValue = bindSignal.code; + neededImports.add('_wrapProp'); + } + } + pushNamed(constEntries, `"${propName}": ${bindValue}`, 'const', attr.start); + continue; + } + + if (isBindProp(propName) && !hasSpreadAttr) { + const bindResult = transformBindProp(propName, valueText); + pushNamed( + constEntries, + `"${bindResult.propName}": ${bindResult.propValue}`, + 'const', + attr.start + ); + if (bindResult.handler) { + const existing = bindHandlers.get(bindResult.handler.name); + if (existing) { + bindHandlers.set(bindResult.handler.name, `[${existing}, ${bindResult.handler.code}]`); + } else { + bindHandlers.set(bindResult.handler.name, bindResult.handler.code); + } + } + for (const imp of bindResult.needsImport) { + neededImports.add(imp); + } + continue; + } + + if (isBindProp(propName) && hasSpreadAttr) { + pushNamed( + beforeSpread ? beforeSpreadEntries : varEntries, + `"${propName}": ${valueText}`, + 'var', + attr.start + ); + continue; + } + + if (isEventProp(propName) && tagIsHtml) { + const renamedProp = transformEventPropName(propName, passiveEvents); + if (renamedProp !== null) { + const formattedName = formatPropName(renamedProp); + // A rewritten inlinedQrl(...) value is a runtime call — not static. + const isNonConstQrl = qrlsNonConst?.has(valueText.trim()) === true; + if (isConstValueNode(valueNode) && !isNonConstQrl) { + pushNamed(constEntries, `${formattedName}: ${valueText}`, 'const', attr.start); + } else { + pushNamed(varEntries, `${formattedName}: ${valueText}`, 'var', attr.start); + hasVarEventHandler = true; + } + continue; + } + } + + if (propName.endsWith('$') && !startsWithRewrittenEventPrefix(propName)) { + const formattedName = formatPropName(propName); + // A `.w([captures])` whose captures include per-invocation bindings + // (params, locals) re-creates each render — var, like rust. + const hasUnstableWCaptures = + valueNode?.type === 'CallExpression' && + isCaptureWrappingQrlCall(valueNode) && + wCallCaptureIdents(valueNode).some( + (name) => + !importedNames.has(name) && + bindings?.classify(name, valueNode.start) !== 'const' && + bindings?.classify(name, valueNode.start) !== undefined + ); + if (isConstValueNode(valueNode) && !hasUnstableWCaptures) { + pushNamed(constEntries, `${formattedName}: ${valueText}`, 'const', attr.start); + } else { + pushNamed(varEntries, `${formattedName}: ${valueText}`, 'var', attr.start); + // A non-const prop value clears the static_listeners bit; the flag math + // reads `hasVarEventHandler` for bit 0, so set it here for Component-prop + // `*$` attrs with non-const values. + hasVarEventHandler = true; + } + continue; + } + + if (startsWithRewrittenEventPrefix(propName)) { + const formattedName = `"${propName}"`; + if (inLoop) { + if (qrlsWithCaptures) { + const qrlName = valueText.trim(); + if (qrlsWithCaptures.has(qrlName)) { + pushNamed(varEntries, `${formattedName}: ${valueText}`, 'var', attr.start); + hasVarEventHandler = true; + } else { + pushNamed(constEntries, `${formattedName}: ${valueText}`, 'const', attr.start); + } + } else { + pushNamed(varEntries, `${formattedName}: ${valueText}`, 'var', attr.start); + hasVarEventHandler = true; + } + } else { + // A handler whose lifted captures include per-invocation values + // re-renders with fresh values: entry is var, static_listeners clears. + const capturesVary = qrlsNonConst?.has(valueText.trim()) === true; + const isConst = isConstValueNode(valueNode) && !capturesVary; + if (capturesVary) { + hasVarEventHandler = true; + } + + if (hasSpreadAttr || hasBindAttr) { + const existing = bindHandlers.get(propName); + if (capturesVary) { + varBindHandlers.add(propName); + } + if (existing) { + bindHandlers.set(propName, mergeEventHandlers(existing, valueText)); + } else { + bindHandlers.set(propName, valueText); + slotOrder.push({ + kind: 'named', + entry: `${formattedName}: ${valueText}`, + classification: isConst ? 'const' : 'var', + sourceStart: attr.start, + }); + } + continue; + } + + const prefix = `${formattedName}: `; + const existingConst = constEntries.findIndex((entry) => entry.startsWith(prefix)); + const existingVar = varEntries.findIndex((entry) => entry.startsWith(prefix)); + const existingEntries = existingConst >= 0 ? constEntries : varEntries; + const existingIndex = existingConst >= 0 ? existingConst : existingVar; + if (existingIndex >= 0) { + const existingValue = existingEntries[existingIndex].slice(prefix.length); + const mergedEntry = `${prefix}${mergeEventHandlers(existingValue, valueText)}`; + existingEntries[existingIndex] = mergedEntry; + } else { + pushNamed( + isConst ? constEntries : varEntries, + `${prefix}${valueText}`, + isConst ? 'const' : 'var', + attr.start + ); + } + } + continue; + } + + if (valueNode && !skipSignalAnalysis) { + const signalResult = analyzeSignalExpression( + valueNode, + source, + importedNames, + allDeclaredNames + ); + + if (signalResult.type === 'wrapProp') { + const formattedName = formatPropName(propName); + // The wrapper is only reusable across renders when its root binding is + // stable: a const, or — on component tags — a function param that is + // not a loop iteration var (map callback params vary per iteration). + const objName = signalResult.code.match(/_wrapProp\(([\w$]+)/)?.[1] ?? null; + const isParamRoot = + objName !== null && + ((paramNames?.has(objName) ?? false) || + bindings?.classify(objName, valueNode?.start ?? 0) === 'param') && + !loopIterVars?.includes(objName); + const isConst = + isConstBindingName(objName, importedNames, bindings, valueNode?.start ?? 0) || + (!tagIsHtml && isParamRoot) || + (!signalResult.isStoreField && !tagIsHtml && objName === null); + pushNamed( + beforeSpread ? beforeSpreadEntries : isConst ? constEntries : varEntries, + `${formattedName}: ${signalResult.code}`, + isConst && !beforeSpread ? 'const' : 'var', + attr.start + ); + neededImports.add('_wrapProp'); + continue; + } + + if (signalResult.type === 'fnSignal') { + const isParam = (dep: string) => paramNames?.has(dep) ?? false; + const isRawWhenNonConst = + valueNode?.type === 'TemplateLiteral' || valueNode?.type === 'CallExpression'; + const excludedFromHoist = + // applyRef writes into the ref value; a hoisted _fnSignal is + // read-only and throws Q31 when the element is (re)created. + propName === 'ref' || + (isRawWhenNonConst && + !fnSignalDepsAllConst( + signalResult.deps, + importedNames, + bindings, + valueNode.start, + tagIsHtml, + isParam + )); + if (!excludedFromHoist) { + const hfName = signalHoister.hoist( + signalResult.hoistedFn, + signalResult.hoistedStr, + valueNode.start ?? 0 + ); + const fnSignalCall = `_fnSignal(${hfName}, [${signalResult.deps.join(', ')}], ${hfName}_str)`; + const formattedName = formatPropName(propName); + const depsAllConst = fnSignalDepsAllConst( + signalResult.deps, + importedNames, + bindings, + valueNode.start, + tagIsHtml, + isParam + ); + if (beforeSpread) { + // A named prop before a spread must stay before it in the var bag + // so the spread's keys override at runtime. + pushNamed(beforeSpreadEntries, `${formattedName}: ${fnSignalCall}`, 'var', attr.start); + } else if (depsAllConst && !inLoop) { + pushNamed(constEntries, `${formattedName}: ${fnSignalCall}`, 'const', attr.start); + } else { + pushNamed(varEntries, `${formattedName}: ${fnSignalCall}`, 'var', attr.start); + } + neededImports.add('_fnSignal'); + continue; + } + } + } + + const classification = !valueNode + ? 'const' + : valueNode.type === 'JSXElement' || valueNode.type === 'JSXFragment' + ? isStaticJsxAttrValue(valueNode) + ? 'const' + : 'var' + : classifyConstness(valueNode, importedNames, bindings, valueNode.start); + + const entry = `${formatPropName(propName)}: ${valueText}`; + + if (beforeSpread) { + pushNamed(beforeSpreadEntries, entry, classification === 'var' ? 'var' : 'var', attr.start); + } else if (classification === 'var') { + pushNamed(varEntries, entry, 'var', attr.start); + } else { + pushNamed(constEntries, entry, 'const', attr.start); + } + } + + if (!hasSpread) { + sortVarEntries(varEntries); + } + + const hasBindEntries = varEntries.some((e) => e.startsWith('"bind:')); + const eventTarget = hasSpread && tagIsHtml && !hasBindEntries ? varEntries : constEntries; + for (const [eventName, handlerCode] of bindHandlers) { + const quotedEventName = `"${eventName}"`; + const existingIdx = constEntries.findIndex((e) => e.startsWith(`${quotedEventName}: `)); + if (existingIdx >= 0) { + const existingEntry = constEntries[existingIdx]; + const existingValue = existingEntry.slice(quotedEventName.length + 2); + constEntries[existingIdx] = + `${quotedEventName}: ${mergeEventHandlers(existingValue, handlerCode)}`; + } else { + const target = varBindHandlers.has(eventName) ? varEntries : eventTarget; + target.push(`${quotedEventName}: ${handlerCode}`); + } + } + if (varBindHandlers.size > 0 && !hasSpread) { + sortVarEntries(varEntries); + } + + return { + varEntries, + constEntries, + beforeSpreadEntries, + additionalSpreads, + key, + hasVarProps: varEntries.length > 0 || beforeSpreadEntries.length > 0, + hasVarEventHandler, + hasSpread, + slotOrder, + neededImports, + }; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/jsx.ts b/packages/ts-optimizer/src/optimizer/jsx/jsx.ts new file mode 100644 index 00000000000..214daae51b6 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/jsx.ts @@ -0,0 +1,1130 @@ +import type MagicString from 'magic-string'; +import { walkWithProtocol } from '../ast/walk-with-protocol.js'; +import { forEachAstChild } from '../ast/guards.js'; +import { isCaptureWrappingQrlCall } from '../qwik/w-call.js'; +import type { AstNode, AstProgram, Expression, JSXElementName } from '../../ast-types.js'; +import { SignalHoister } from './signal-analysis.js'; +import { collectPassiveDirectives } from './event-handlers.js'; +import { detectLoopContext, type LoopContext } from './loop-hoisting.js'; +import { computeKeyPrefix } from './key-prefix.js'; + +export interface JsxTransformResult { + tag: string; + varProps: string | null; + constProps: string | null; + children: string | null; + flags: number; + key: string | null; + callString: string; + neededImports: Set; +} + +export interface JsxTransformOutput { + neededImports: Set; + needsFragment: boolean; + hoistedDeclarations: string[]; + keyCounterValue: number; +} + +/** + * Coordinate-conversion info for JSX dev-info under wrapped-body callers. The inline-strategy and + * segment-file paths parse the extracted body wrapped in a prefix, so AST-reported JSX positions + * are offsets in that wrapped body, not the module source. Without conversion, `lineNumber:` ends + * up body-relative instead of source-relative. Module-level callers (parent rewrite) leave this + * undefined. + */ +export interface DevInfoSourcePosition { + source: string; + bodyOriginOffset: number; + wrapperPrefixLen: number; +} + +export interface DevSuffixOptions { + relPath: string; + sourcePosition?: DevInfoSourcePosition; +} + +export interface JsxTransformContext { + source: string; + s: MagicString; + importedNames: Set; + keyCounter: JsxKeyCounter; + signalHoister: SignalHoister; + /** + * Scope-aware binding lookup. `classify(name, atPosition)` resolves to the innermost enclosing + * scope's kind so shadowed names (arrow param over a for-of binding) classify correctly; a flat + * `Set` cannot disambiguate. + */ + bindings?: ScopeAwareBindings; + allDeclaredNames?: Set; + paramNames?: Set; + qrlsWithCaptures?: Set; + /** QRL vars whose lifted captures vary per invocation — their entries classify var. */ + qrlsNonConst?: Set; + /** + * Exact-range log of every `writeJsxCall` overwrite, keyed by range start; lets readers recover a + * rewritten subtree's text without the chunk-list walk `MagicString.slice` pays on a heavily + * edited buffer (see `sliceTransformed`), and carries the subtree's structured facts so parents + * classify children without re-parsing generated code. + */ + jsxWriteMemo?: ReadonlyMap; +} + +export interface JsxWriteRecord { + end: number; + content: string; + flags: number; + hasVarProps: boolean; +} + +/** + * Read a byte range from the in-progress transform buffer, preferring an exact-range hit in the JSX + * write memo over `MagicString.slice`. Sound because the JSX walk rewrites bottom-up through the + * single `writeJsxCall` path, so a child's recorded write is the last edit inside its range by the + * time an ancestor reads it; the only pass that revisits recorded ranges (post-walk signal-hoist + * rename) runs after all reads. + */ +export function sliceTransformed(ctx: JsxTransformContext, start: number, end: number): string { + const hit = ctx.jsxWriteMemo?.get(start); + if (hit !== undefined && hit.end === end) { + return hit.content; + } + return ctx.s.slice(start, end); +} + +export interface JsxWalkEnterContext { + readonly source: string; + readonly loopStack: LoopContext[]; + readonly childJsxNodes: WeakSet; +} + +export interface JsxWalkExitContext extends JsxWalkEnterContext { + readonly ranges: ReadonlyArray<{ start: number; end: number }>; + readonly jsxCtx: JsxTransformContext; + readonly enableSignals: boolean; + readonly qpOverrides: Map | undefined; + readonly neededImports: Set; + readonly getDevSourceSuffix: (nodeStart: number) => string; + readonly setNeedsFragment: () => void; + readonly writeJsxCall: ( + start: number, + end: number, + content: string, + result: JsxTransformResult + ) => void; +} + +export interface JsxElementOptions { + passiveEvents?: Set; + loopCtx?: LoopContext | null; + isSoleChild?: boolean; + enableChildSignals?: boolean; + qpOverrides?: Map; + /** Inside a JSX attribute value: no generated key (parity with rust). */ + inAttrValue?: boolean; +} + +export interface ProcessChildrenOptions { + neededImports: Set; + enableSignalAnalysis?: boolean; +} + +export interface ProcessPropsOptions { + tagIsHtml: boolean; + passiveEvents: Set; + inLoop?: boolean; + /** + * Skip signal analysis for prop values; set when lowering to `_createElement` (spread + key), + * whose path emits prop values verbatim so any `_fnSignal` hoists would be unreachable. + */ + skipSignalAnalysis?: boolean; + /** Loop iteration vars in scope — a wrapProp rooted on one stays var. */ + loopIterVars?: readonly string[]; +} + +export function isConstBindingName( + name: string | null, + importedNames: Set, + bindings: ScopeAwareBindings | undefined, + atPosition: number +): boolean { + if (!name) { + return false; + } + if (importedNames.has(name)) { + return true; + } + return bindings?.classify(name, atPosition) === 'const'; +} + +/** + * A closure parameter counts as a const-stable dep only on component elements: on an HTML element a + * param-dependent prop stays var because the DOM node re-renders on every prop change. + */ +export function fnSignalDepsAllConst( + deps: readonly string[], + importedNames: Set, + bindings: ScopeAwareBindings | undefined, + atPosition: number, + tagIsHtml: boolean, + isParam: (dep: string) => boolean +): boolean { + return deps.every( + (dep) => + isConstBindingName(dep, importedNames, bindings, atPosition) || (!tagIsHtml && isParam(dep)) + ); +} + +function staticPropKeyName(key: AstNode | null | undefined): string | null { + if (!key) { + return null; + } + if (key.type === 'Identifier') { + return key.name; + } + if (key.type === 'Literal') { + return String(key.value); + } + return null; +} + +function isReturnStatic(init: Expression | null | undefined): boolean { + if (!init) { + return true; + } + if (init.type === 'CallExpression' && init.callee.type === 'Identifier') { + const calleeName = init.callee.name; + return calleeName.endsWith('$') || calleeName.endsWith('Qrl') || calleeName.startsWith('use'); + } + return false; +} + +/** + * Scope-aware binding classification: a reference to `item` at position P resolves to the innermost + * enclosing scope that declares `item`, and classification follows that scope's binding kind. + * Per-name index `nameToScopes: Map`; lookup picks the smallest range + * containing `atPosition`. Synthetic program-scope bindings (`addProgramScopeConst`) use range `[0, + * MAX]` so any inner scope's binding wins. + */ +interface ScopeRange { + readonly start: number; + readonly end: number; + readonly kind: 'const' | 'var' | 'param'; +} + +export interface ScopeAwareBindings { + /** + * Innermost enclosing scope's binding kind for `name` at `atPosition`, or undefined when no scope + * binds it (caller falls through to imports/undeclared). + */ + classify(name: string, atPosition: number): 'const' | 'var' | 'param' | undefined; + /** + * Register `name` as const everywhere — for names that aren't AST-declared but are runtime-const + * (e.g. `_captures[i]` unpacking bindings). Inner-scope bindings still shadow. + */ + addProgramScopeConst(name: string): void; +} + +class ScopeAwareBindingsImpl implements ScopeAwareBindings { + private nameToScopes = new Map(); + /** + * Names that classify as `'const'` everywhere, overriding AST-derived binding. Populated by + * `addProgramScopeConst` for `_captures[N]` unpacking names: those appear as `const X = + * _captures[N]` whose MemberExpression initializer wouldn't pass `isReturnStatic`, so the + * AST-derived kind would wrongly be `'var'`. + */ + private alwaysConst = new Set(); + + add(name: string, start: number, end: number, kind: 'const' | 'var' | 'param'): void { + let arr = this.nameToScopes.get(name); + if (!arr) { + arr = []; + this.nameToScopes.set(name, arr); + } + arr.push({ start, end, kind }); + } + + classify(name: string, atPosition: number): 'const' | 'var' | 'param' | undefined { + if (this.alwaysConst.has(name)) { + return 'const'; + } + const scopes = this.nameToScopes.get(name); + if (!scopes) { + return undefined; + } + let best: ScopeRange | undefined; + let bestSize = Infinity; + for (const s of scopes) { + if (s.start <= atPosition && atPosition < s.end) { + const size = s.end - s.start; + if (size < bestSize) { + best = s; + bestSize = size; + } + } + } + return best?.kind; + } + + addProgramScopeConst(name: string): void { + this.alwaysConst.add(name); + } +} + +export interface ScopeAwareCollectResult { + bindings: ScopeAwareBindings; + /** + * Every locally declared name (any binding kind), flat/scope-unaware — signal-analysis uses it to + * skip names declared locally. + */ + allLocalNames: Set; +} + +/** + * Enter/leave view of the bindings collection so the canonical gather walk + * (`analysis/module-gather-walk.ts`) can drive it as a projection. `collectScopeAwareBindings` + * remains the standalone walk and the projection's differential oracle. + */ +export interface ScopeBindingsCollector { + readonly enter: (node: AstNode) => void; + readonly leave: (node: AstNode) => void; + readonly result: () => ScopeAwareCollectResult; +} + +export function createScopeBindingsCollector(program: AstProgram): ScopeBindingsCollector { + const bindings = new ScopeAwareBindingsImpl(); + const allLocalNames = new Set(); + + const scopeStack: Array<{ start: number; end: number }> = []; + + function currentScope(): { start: number; end: number } { + return scopeStack[scopeStack.length - 1]; + } + + function addBindingIdent( + idNode: AstNode | null | undefined, + kind: 'const' | 'var' | 'param' + ): void { + if (!idNode) { + return; + } + if (idNode.type === 'Identifier') { + allLocalNames.add(idNode.name); + const scope = currentScope(); + bindings.add(idNode.name, scope.start, scope.end, kind); + } else if (idNode.type === 'ArrayPattern') { + for (const elem of idNode.elements) { + if (elem) { + addBindingIdent(elem.type === 'RestElement' ? elem.argument : elem, kind); + } + } + } else if (idNode.type === 'ObjectPattern') { + for (const prop of idNode.properties) { + if (prop.type === 'RestElement') { + addBindingIdent(prop.argument, kind); + } else { + addBindingIdent(prop.value, kind); + } + } + } else if (idNode.type === 'AssignmentPattern') { + addBindingIdent(idNode.left, kind); + } + } + + function walkPatternInit( + id: AstNode | null | undefined, + init: Expression | null | undefined + ): void { + if (!id) { + return; + } + if (id.type === 'Identifier') { + addBindingIdent(id, isReturnStatic(init) ? 'const' : 'var'); + return; + } + if (id.type === 'ArrayPattern') { + const elems = id.elements; + if (init && init.type === 'ArrayExpression') { + for (let i = 0; i < elems.length; i++) { + const elem = elems[i]; + if (!elem || elem.type === 'RestElement') { + continue; + } + const initElem = init.elements?.[i] ?? null; + if (initElem && initElem.type === 'SpreadElement') { + continue; + } + walkPatternInit(elem, initElem); + } + } else { + for (const elem of elems) { + if (!elem || elem.type === 'RestElement') { + continue; + } + walkPatternInit(elem, init); + } + } + return; + } + if (id.type === 'ObjectPattern') { + const props = id.properties; + if (init && init.type === 'ObjectExpression') { + const valueByKey = new Map(); + for (const ip of init.properties ?? []) { + if (ip.type !== 'Property' || ip.computed) { + continue; + } + const keyName = staticPropKeyName(ip.key); + if (keyName === null) { + continue; + } + valueByKey.set(keyName, ip.value as Expression); + } + for (const pp of props) { + if (pp.type !== 'Property' || pp.computed) { + continue; + } + const keyName = staticPropKeyName(pp.key); + if (keyName === null) { + continue; + } + walkPatternInit(pp.value, valueByKey.get(keyName) ?? null); + } + } else { + for (const pp of props) { + if (pp.type !== 'Property') { + continue; + } + walkPatternInit(pp.value, init); + } + } + return; + } + if (id.type === 'AssignmentPattern') { + walkPatternInit(id.left, init); + } + } + + function isScopeIntroducing(node: AstNode): boolean { + return ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' || + node.type === 'BlockStatement' || + node.type === 'ForStatement' || + node.type === 'ForInStatement' || + node.type === 'ForOfStatement' || + node.type === 'CatchClause' + ); + } + + function enter(node: AstNode): void { + const pushed = isScopeIntroducing(node); + if (pushed) { + scopeStack.push({ start: node.start, end: node.end }); + } + + // FunctionDeclaration/ClassDeclaration names bind in the enclosing scope, + // not the new body scope — record them in the scope above the just-pushed frame. + if (node.type === 'FunctionDeclaration' && node.id) { + const targetScope = + pushed && scopeStack.length >= 2 ? scopeStack[scopeStack.length - 2] : currentScope(); + allLocalNames.add(node.id.name); + bindings.add(node.id.name, targetScope.start, targetScope.end, 'var'); + } + if (node.type === 'ClassDeclaration' && node.id) { + // Classes aren't scope-introducing in this model (no push), so the name + // binds in the current scope. + allLocalNames.add(node.id.name); + const scope = currentScope(); + bindings.add(node.id.name, scope.start, scope.end, 'var'); + } + + if ( + (node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression') && + node.params + ) { + for (const param of node.params) { + addBindingIdent(param, 'param'); + } + } + + if (node.type === 'CatchClause' && node.param) { + addBindingIdent(node.param, 'var'); + } + + // One `VariableDeclaration` covers standalone declarations and for-loop LHS. + // For-of/in have `decl.init === null`, so `isReturnStatic(null) === true` + // binds them as 'const'. + if (node.type === 'VariableDeclaration') { + const isConst = node.kind === 'const'; + for (const decl of node.declarations) { + if (isConst) { + walkPatternInit(decl.id, decl.init); + } else { + addBindingIdent(decl.id, 'var'); + } + } + } + } + + function leave(node: AstNode): void { + if (isScopeIntroducing(node)) { + scopeStack.pop(); + } + } + + // Push the program-level scope frame so top-level bindings land somewhere; + // never popped, so `result` is readable after the traversal. + scopeStack.push({ start: program.start ?? 0, end: program.end ?? Number.MAX_SAFE_INTEGER }); + + return { + enter, + leave, + result: () => ({ bindings, allLocalNames }), + }; +} + +export function collectScopeAwareBindings(program: AstProgram): ScopeAwareCollectResult { + const collector = createScopeBindingsCollector(program); + function visit(node: AstNode | null | undefined): void { + if (!node) { + return; + } + collector.enter(node); + forEachAstChild(node, (child) => visit(child)); + collector.leave(node); + } + visit(program); + return collector.result(); +} + +/** + * Classify an expression as immutable (const) or mutable (var). Takes the AST position so + * identifier references resolve through scope-aware lookup and shadowed names classify correctly. + */ +export function classifyConstness( + exprNode: AstNode | null | undefined, + importedNames: Set, + bindings: ScopeAwareBindings | undefined, + atPosition: number +): 'const' | 'var' { + if (!exprNode) { + return 'const'; + } + + switch (exprNode.type) { + case 'Literal': + // All four literal interfaces (String/Numeric/Boolean/Null) share the + // same `'Literal'` discriminant. + return 'const'; + + case 'TemplateLiteral': { + if (exprNode.expressions.length === 0) { + return 'const'; + } + for (const expr of exprNode.expressions) { + if (classifyConstness(expr, importedNames, bindings, atPosition) === 'var') { + return 'var'; + } + } + return 'const'; + } + + case 'Identifier': { + const name = exprNode.name; + if (name === 'undefined') { + return 'const'; + } + if (importedNames.has(name)) { + return 'const'; + } + // Use the identifier's own start for the scope lookup — the outer + // `atPosition` is the enclosing expression's start, but the identifier + // may sit deeper. + if (bindings?.classify(name, exprNode.start) === 'const') { + return 'const'; + } + return 'var'; + } + + case 'MemberExpression': { + // Computed/Static/PrivateField share one `'MemberExpression'` + // discriminant; all three carry `.object`. + const obj = exprNode.object; + if (obj.type === 'Identifier' && importedNames.has(obj.name)) { + return 'const'; + } + return 'var'; + } + + case 'CallExpression': + // A hoisted `_fnSignal(_hf, [deps], ...)` has a stable callee; + // classifying it const puts it in the const-props bag so the runtime skips + // re-computing the prop record on re-render. + if (exprNode.callee.type === 'Identifier' && exprNode.callee.name === '_fnSignal') { + return 'const'; + } + if (isCaptureWrappingQrlCall(exprNode)) { + return 'const'; + } + return 'var'; + + case 'UnaryExpression': + return classifyConstness(exprNode.argument, importedNames, bindings, atPosition); + + case 'BinaryExpression': + case 'LogicalExpression': { + const leftClass = classifyConstness(exprNode.left, importedNames, bindings, atPosition); + const rightClass = classifyConstness(exprNode.right, importedNames, bindings, atPosition); + return leftClass === 'var' || rightClass === 'var' ? 'var' : 'const'; + } + + case 'ConditionalExpression': { + const testClass = classifyConstness(exprNode.test, importedNames, bindings, atPosition); + const consClass = classifyConstness(exprNode.consequent, importedNames, bindings, atPosition); + const altClass = classifyConstness(exprNode.alternate, importedNames, bindings, atPosition); + return testClass === 'var' || consClass === 'var' || altClass === 'var' ? 'var' : 'const'; + } + + case 'ObjectExpression': { + for (const prop of exprNode.properties) { + if (prop.type === 'SpreadElement') { + if (classifyConstness(prop.argument, importedNames, bindings, atPosition) === 'var') { + return 'var'; + } + } else if (prop.value) { + if (classifyConstness(prop.value, importedNames, bindings, atPosition) === 'var') { + return 'var'; + } + } + } + return 'const'; + } + + case 'ArrayExpression': { + for (const el of exprNode.elements) { + if (el === null) { + continue; + } + if (el.type === 'SpreadElement') { + if (classifyConstness(el.argument, importedNames, bindings, atPosition) === 'var') { + return 'var'; + } + } else { + if (classifyConstness(el, importedNames, bindings, atPosition) === 'var') { + return 'var'; + } + } + } + return 'const'; + } + + case 'ArrowFunctionExpression': + case 'FunctionExpression': + return 'const'; + + case 'ParenthesizedExpression': + return classifyConstness(exprNode.expression, importedNames, bindings, atPosition); + + case 'SequenceExpression': { + for (const expr of exprNode.expressions) { + if (classifyConstness(expr, importedNames, bindings, atPosition) === 'var') { + return 'var'; + } + } + return 'const'; + } + + default: + return 'var'; + } +} + +/** + * Compute the flags bitmask for a JSX element. + * + * Bit 0 (1): static_listeners -- all event handler props are const Bit 1 (2): static_subtree -- + * children are static or none Bit 2 (4): moved_captures -- loop context (q:p/q:ps) + */ +export function computeJsxFlags( + hasVarProps: boolean, + childrenType: 'none' | 'static' | 'dynamic', + inLoop: boolean = false, + hasVarEventHandler: boolean = false +): number { + let flags = 0; + if (!hasVarEventHandler && (!inLoop || !hasVarProps)) { + flags |= 1; + } + if (childrenType !== 'dynamic') { + flags |= 2; + } + if (inLoop) { + flags |= 4; + } + return flags; +} + +export class JsxKeyCounter { + private count: number; + private prefix: string; + /** (source position, assigned index) per consumed key — drives post-hoc renumbering. */ + readonly log: Array<{ pos: number; index: number }> = []; + + constructor(startAt = 0, prefix = 'u6') { + this.count = startAt; + this.prefix = prefix; + } + + next(): string { + return `${this.prefix}_${this.count++}`; + } + + /** Like `next()`, recording the consuming node's source position. */ + nextFor(pos: number): string { + this.log.push({ pos, index: this.count }); + return this.next(); + } + + /** Reserve `n` keys (consumed later by an extracted body's own counter). */ + advance(n: number): void { + this.count += n; + } + + current(): number { + return this.count; + } + + reset(): void { + this.count = 0; + } +} + +/** + * Inline-strategy key reservation: extracted bodies' keys are numbered where the module-order walk + * would place them. The parent transforms skip extraction ranges; on first contact with a counted + * region they record its base and advance past its keys. + */ +export interface JsxKeyReservations { + readonly bases: Map; + findRegion(pos: number): { start: number; count: number } | undefined; +} + +export function reserveRegionKeys( + reservations: JsxKeyReservations | undefined, + keyCounter: JsxKeyCounter, + pos: number +): void { + if (!reservations) { + return; + } + const region = reservations.findRegion(pos); + if (!region || reservations.bases.has(region.start)) { + return; + } + reservations.bases.set(region.start, keyCounter.current()); + keyCounter.advance(region.count); +} + +export function isHtmlElement(tagName: string): boolean { + return tagName.length > 0 && tagName[0] === tagName[0].toLowerCase(); +} + +/** Text-only HTML elements whose children should NOT be signal-wrapped. */ +const TEXT_ONLY_TAGS = new Set([ + 'text', + 'textarea', + 'title', + 'option', + 'script', + 'style', + 'noscript', +]); + +export function isTextOnlyElement(tagName: string): boolean { + return TEXT_ONLY_TAGS.has(tagName); +} + +export function processJsxTag(nameNode: JSXElementName | null | undefined): string { + if (!nameNode) { + return '"div"'; + } + + switch (nameNode.type) { + case 'JSXIdentifier': { + const name = nameNode.name; + return isHtmlElement(name) ? `"${name}"` : name; + } + case 'JSXMemberExpression': { + const parts: string[] = []; + let current: JSXElementName = nameNode; + while (current.type === 'JSXMemberExpression') { + parts.unshift(current.property.name); + current = current.object as JSXElementName; + } + if (current.type === 'JSXIdentifier') { + parts.unshift(current.name); + } + return parts.join('.'); + } + case 'JSXNamespacedName': + return `"${nameNode.namespace.name}:${nameNode.name.name}"`; + default: { + const _exhaustive: never = nameNode; + throw new Error(`unhandled JSXElementName: ${(_exhaustive as { type?: string }).type}`); + } + } +} + +/** + * Build a binary-searchable index over skip ranges: sort by start (descending end tie-break), drop + * ranges contained in an already-kept range (lossless — a node inside a dropped range is inside its + * container). Kept ranges have strictly increasing starts AND ends, which makes `isInSkipRange`'s + * single-probe query sound. + */ +export function buildSkipRangeIndex( + skipRanges: ReadonlyArray<{ start: number; end: number }> +): ReadonlyArray<{ start: number; end: number }> { + if (skipRanges.length <= 1) { + return skipRanges; + } + const sorted = [...skipRanges].sort((a, b) => a.start - b.start || b.end - a.end); + const kept: { start: number; end: number }[] = []; + let maxEnd = -1; + for (const range of sorted) { + if (range.end <= maxEnd) { + continue; + } + kept.push(range); + maxEnd = range.end; + } + return kept; +} + +/** + * Probing only the rightmost kept range with `start <= nodeStart` is sound: suppose the node is + * contained in some kept range A but the probe picked B (so B.start >= A.start). B kept means B is + * not contained in A, hence B.end > A.end >= nodeEnd — and nodeStart >= B.start by the probe — so + * the node is contained in B too and the check still succeeds. + */ +export function isInSkipRange( + nodeStart: number, + nodeEnd: number, + index: ReadonlyArray<{ start: number; end: number }> +): boolean { + let lo = 0; + let hi = index.length - 1; + let found = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (index[mid].start <= nodeStart) { + found = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return found >= 0 && nodeEnd <= index[found].end; +} + +/** Whole-identifier rename: `_hf1` must not match inside `_hf10` or `_hf1_str`. */ +function renameWholeIdentifier(text: string, from: string, to: string): string { + return text.replace(new RegExp(`(?): string { + let renamed = text; + + const tempMap = new Map(); + for (const [oldName, newName] of renameMap) { + const temp = `__hf_temp_${oldName.slice(3)}__`; + tempMap.set(temp, newName); + renamed = renameWholeIdentifier(renamed, `${oldName}_str`, `${temp}_str`); + renamed = renameWholeIdentifier(renamed, oldName, temp); + } + + for (const [temp, newName] of tempMap) { + renamed = renameWholeIdentifier(renamed, `${temp}_str`, `${newName}_str`); + renamed = renameWholeIdentifier(renamed, temp, newName); + } + + return renamed; +} + +interface RecordedJsxWrite { + readonly start: number; + readonly end: number; + readonly content: string; +} + +/** + * Renumber `_hf` occurrences by re-overwriting exactly the recorded JSX write ranges (every + * `_hf` lives inside walk-inserted content). Replaying in recorded bottom-up order reproduces + * the walk's overwrite nesting without touching untouched chunks, so original-source offsets stay + * valid for later passes that `s.slice` at original AST positions and error on replaced anchors. + */ +function applySignalHoistRenames( + s: MagicString, + renameMap: Map, + writes: ReadonlyArray +): void { + for (const write of writes) { + const renamed = renameSignalHoistNames(write.content, renameMap); + if (renamed !== write.content) { + s.overwrite(write.start, write.end, renamed); + } + } +} + +function appendDevSuffix(callString: string, devSuffix: string): string { + if (!devSuffix) { + return callString; + } + return callString.slice(0, -1) + devSuffix + ')'; +} + +import { transformJsxElement, transformJsxFragment } from './jsx-elements-core.js'; + +export { transformJsxElement, transformJsxFragment }; + +export interface TransformAllJsxInput { + source: string; + s: MagicString; + program: AstProgram; + importedNames: Set; +} + +export interface TransformAllJsxOptions { + skipRanges?: Array<{ start: number; end: number }>; + devOptions?: DevSuffixOptions; + keyCounterStart?: number; + /** Inline-strategy reservation of extracted-body key ranges. */ + keyReservations?: JsxKeyReservations; + enableSignals?: boolean; + qpOverrides?: Map; + qrlsWithCaptures?: Set; + qrlsNonConst?: Set; + paramNames?: Set; + relPath?: string; + sharedSignalHoister?: SignalHoister; + precomputedScopeBindings?: ScopeAwareCollectResult; +} + +export function transformAllJsx( + input: TransformAllJsxInput, + opts: TransformAllJsxOptions = {} +): JsxTransformOutput { + const { source, s, program, importedNames } = input; + const { + skipRanges, + devOptions, + keyCounterStart, + keyReservations, + enableSignals = true, + qpOverrides, + qrlsWithCaptures, + qrlsNonConst, + paramNames, + relPath, + sharedSignalHoister, + precomputedScopeBindings, + } = opts; + const { bindings: resolvedBindings, allLocalNames: allDeclaredNames } = + precomputedScopeBindings ?? collectScopeAwareBindings(program); + const prefix = relPath ? computeKeyPrefix(relPath) : 'u6'; + const keyCounter = new JsxKeyCounter(keyCounterStart ?? 0, prefix); + const signalHoister = sharedSignalHoister ?? new SignalHoister(); + // Earlier bodies' hoists are frozen text; renumbering must not touch them. + const hoistStartIndex = signalHoister.hoistedFunctions.length; + const neededImports = new Set(); + let needsFragment = false; + const ranges = buildSkipRangeIndex(skipRanges ?? []); + const jsxWriteMemo = new Map(); + const jsxCtx: JsxTransformContext = { + source, + s, + importedNames, + keyCounter, + signalHoister, + bindings: resolvedBindings, + allDeclaredNames, + paramNames, + qrlsWithCaptures, + qrlsNonConst, + jsxWriteMemo, + }; + + // Over a wrapped body, `source` is the wrapped body, so computing + // `lineStarts` from it yields body-relative dev-info positions; + // `devOptions.sourcePosition` supplies the original source + offset to convert + // to source-relative. Module-level callers omit it. + let lineStarts: number[] | null = null; + if (devOptions) { + const linesSource = devOptions.sourcePosition?.source ?? source; + lineStarts = [0]; + for (let i = 0; i < linesSource.length; i++) { + if (linesSource[i] === '\n') { + lineStarts.push(i + 1); + } + } + } + + function getDevSourceSuffix(nodeStart: number): string { + if (!devOptions || !lineStarts) { + return ''; + } + const effectiveOffset = devOptions.sourcePosition + ? devOptions.sourcePosition.bodyOriginOffset + + (nodeStart - devOptions.sourcePosition.wrapperPrefixLen) + : nodeStart; + let lo = 0, + hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= effectiveOffset) { + lo = mid; + } else { + hi = mid - 1; + } + } + const lineNumber = lo + 1; + const columnNumber = effectiveOffset - lineStarts[lo] + 1; + return `, {\n fileName: "${devOptions.relPath}",\n lineNumber: ${lineNumber},\n columnNumber: ${columnNumber}\n}`; + } + + const loopStack: LoopContext[] = []; + const childJsxNodes = new WeakSet(); + // JSX directly inside an attribute value never takes a counter key, but a + // function body inside the attr resets to normal keyed emission (rust parity). + const attrFnStack: ('attr' | 'fn')[] = []; + const isDirectlyInAttrValue = () => attrFnStack[attrFnStack.length - 1] === 'attr'; + const attrFnMarker = (node: AstNode): 'attr' | 'fn' | null => + node.type === 'JSXAttribute' || node.type === 'JSXSpreadAttribute' + ? 'attr' + : node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' || + node.type === 'FunctionDeclaration' + ? 'fn' + : null; + + // Enter/Exit context views so the type system enforces "enter gathers, exit + // acts": calling `writeJsxCall` from enter is a compile error because + // EnterContext lacks it. + const jsxWrites: RecordedJsxWrite[] = []; + const enterCtx: JsxWalkEnterContext = { + source, + loopStack, + childJsxNodes, + }; + const exitCtx: JsxWalkExitContext = { + ...enterCtx, + ranges, + jsxCtx, + enableSignals, + qpOverrides, + neededImports, + getDevSourceSuffix, + setNeedsFragment: () => { + needsFragment = true; + }, + writeJsxCall: (start, end, content, result) => { + s.overwrite(start, end, content); + jsxWrites.push({ start, end, content }); + jsxWriteMemo.set(start, { + end, + content, + flags: result.flags, + hasVarProps: result.varProps != null, + }); + }, + }; + + walkWithProtocol(program, enterCtx, exitCtx, { + enter(node, _parent, ctx) { + const marker = attrFnMarker(node); + if (marker) { + attrFnStack.push(marker); + } + const loopCtx = detectLoopContext(node, ctx.source); + if (loopCtx) { + ctx.loopStack.push(loopCtx); + } + + if (node.type === 'JSXElement' || node.type === 'JSXFragment') { + for (const child of node.children) { + if (child.type === 'JSXElement' || child.type === 'JSXFragment') { + ctx.childJsxNodes.add(child); + } + } + } + }, + leave(node, _parent, ctx) { + if (attrFnMarker(node)) { + attrFnStack.pop(); + } + if (ctx.loopStack.length > 0 && ctx.loopStack[ctx.loopStack.length - 1].loopNode === node) { + ctx.loopStack.pop(); + } + + if (ctx.ranges.length > 0 && isInSkipRange(node.start, node.end, ctx.ranges)) { + reserveRegionKeys(keyReservations, keyCounter, node.start); + return; + } + + const currentLoop = ctx.loopStack.length > 0 ? ctx.loopStack[ctx.loopStack.length - 1] : null; + + if (node.type === 'JSXElement') { + const passiveEvents = collectPassiveDirectives(node.openingElement?.attributes ?? []); + const isSoleChild = ctx.childJsxNodes.has(node); + + const result = transformJsxElement(ctx.jsxCtx, node, { + passiveEvents, + loopCtx: currentLoop, + isSoleChild, + enableChildSignals: ctx.enableSignals, + qpOverrides: ctx.qpOverrides, + inAttrValue: isDirectlyInAttrValue(), + }); + if (result) { + const callStr = appendDevSuffix(result.callString, ctx.getDevSourceSuffix(node.start)); + ctx.writeJsxCall(node.start, node.end, `/*#__PURE__*/ ${callStr}`, result); + for (const imp of result.neededImports) { + ctx.neededImports.add(imp); + } + } + } else if (node.type === 'JSXFragment') { + const result = transformJsxFragment(ctx.jsxCtx, node, { + inAttrValue: isDirectlyInAttrValue(), + }); + if (result) { + const callStr = appendDevSuffix(result.callString, ctx.getDevSourceSuffix(node.start)); + ctx.writeJsxCall(node.start, node.end, `/*#__PURE__*/ ${callStr}`, result); + for (const imp of result.neededImports) { + ctx.neededImports.add(imp); + } + ctx.setNeedsFragment(); + } + } + }, + }); + + const renameMap = signalHoister.buildRenameMap(hoistStartIndex); + if (renameMap && renameMap.size > 0) { + applySignalHoistRenames(s, renameMap, jsxWrites); + } + + const hoistedDeclarations = signalHoister.getDeclarations(); + return { + neededImports, + needsFragment, + hoistedDeclarations, + keyCounterValue: keyCounter.current(), + }; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/key-prefix.ts b/packages/ts-optimizer/src/optimizer/jsx/key-prefix.ts new file mode 100644 index 00000000000..79c5f777a75 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/key-prefix.ts @@ -0,0 +1,26 @@ +/** + * Compute a 2-character JSX key prefix: the first 2 base64 chars of the SipHash-1-3 (zero-key) of + * the relative file path's little-endian hash bytes. + */ + +import SipHash13 from 'siphash/lib/siphash13.js'; + +const ZERO_KEY: [number, number, number, number] = [0, 0, 0, 0]; + +export function computeKeyPrefix(relPath: string): string { + const result = SipHash13.hash(ZERO_KEY, relPath); + + const bytes = new Uint8Array(8); + bytes[0] = result.l & 0xff; + bytes[1] = (result.l >>> 8) & 0xff; + bytes[2] = (result.l >>> 16) & 0xff; + bytes[3] = (result.l >>> 24) & 0xff; + bytes[4] = result.h & 0xff; + bytes[5] = (result.h >>> 8) & 0xff; + bytes[6] = (result.h >>> 16) & 0xff; + bytes[7] = (result.h >>> 24) & 0xff; + + return btoa(String.fromCharCode(...bytes)) + .replace(/[+\/]/g, '0') + .substring(0, 2); +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/loop-hoisting.ts b/packages/ts-optimizer/src/optimizer/jsx/loop-hoisting.ts new file mode 100644 index 00000000000..38991ec5797 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/loop-hoisting.ts @@ -0,0 +1,215 @@ +import type { + AstFunction, + AstNode, + CallExpression, + MemberExpression, + ForInStatement, + ForOfStatement, + ForStatement, + VariableDeclarator, + WhileStatement, + DoWhileStatement, +} from '../../ast-types.js'; +import { paddingParam } from '../segment/post-process.js'; + +export interface LoopContext { + type: 'map' | 'for-i' | 'for-of' | 'for-in' | 'while' | 'do-while'; + iterVars: string[]; + loopNode: AstNode; + loopBodyStart: number; + loopBodyEnd: number; +} + +export function detectLoopContext(node: AstNode, _source: string): LoopContext | null { + switch (node.type) { + case 'CallExpression': + return detectMapCall(node); + + case 'ForStatement': + return buildLoopContext('for-i', extractForInitVars(node.init), node); + + case 'ForOfStatement': + return buildLoopContext('for-of', extractForLeftVars(node.left), node); + + case 'ForInStatement': + return buildLoopContext('for-in', extractForLeftVars(node.left), node); + + case 'WhileStatement': + return buildLoopContext('while', [], node); + + case 'DoWhileStatement': + return buildLoopContext('do-while', [], node); + + default: + return null; + } +} + +function detectMapCall(node: CallExpression): LoopContext | null { + const callee = node.callee; + if ( + callee?.type !== 'MemberExpression' || + node.optional || + hasOptionalReceiver(callee) || + callee.property?.type !== 'Identifier' || + callee.property.name !== 'map' + ) { + return null; + } + + const callback = node.arguments?.[0]; + if (!callback) { + return null; + } + if ( + callback.type !== 'ArrowFunctionExpression' && + callback.type !== 'FunctionExpression' && + callback.type !== 'FunctionDeclaration' + ) { + return null; + } + + const iterVars = extractCallbackParams(callback); + const bodyRange = getCallbackBodyRange(callback); + + return { + type: 'map', + iterVars, + loopNode: node, + loopBodyStart: bodyRange.start, + loopBodyEnd: bodyRange.end, + }; +} + +function hasOptionalReceiver(member: MemberExpression): boolean { + let current = member; + while (current.object.type === 'MemberExpression') { + if (current.optional) { + return true; + } + current = current.object; + } + return current.optional || current.object.type === 'ChainExpression'; +} + +function buildLoopContext( + type: LoopContext['type'], + iterVars: string[], + node: ForStatement | ForOfStatement | ForInStatement | WhileStatement | DoWhileStatement +): LoopContext { + const body = node.body; + return { + type, + iterVars, + loopNode: node, + loopBodyStart: body?.start ?? node.start, + loopBodyEnd: body?.end ?? node.end, + }; +} + +export function generateParamPadding( + loopVarNames: string[], + originalParams: readonly string[] = [] +): string[] { + // Author-written (event, element) params survive promotion (matches Rust's + // `(ev, _1, store)` shape); only missing positions get padding. + const eventParam = originalParams[0] && originalParams[0] !== '_' ? originalParams[0] : '_'; + const elementParam = originalParams[1] && originalParams[1] !== '_1' ? originalParams[1] : '_1'; + return [eventParam, elementParam, ...loopVarNames]; +} + +/** + * The lexical-capture params an event handler receives positionally — its params after the `_, _1` + * (event, element) prefix, excluding padding (`_2`, `_3`, … and bare `_`). Returns `[]` when + * there's no `_, _1` prefix. + * + * A bare `_` at index ≥2 is never a lifted param — only an author-written placeholder — so it's + * excluded defensively (with the `_, _1` prefix present it would also be a duplicate-param + * SyntaxError in module code). + */ +export function eventHandlerQpParams(paramNames: readonly string[]): string[] { + // Only capture promotion creates handlers with a third-or-later param — + // authors write at most (event, element) — so position filters suffice. + if (paramNames.length < 3) { + return []; + } + const result: string[] = []; + for (let i = 2; i < paramNames.length; i++) { + const p = paramNames[i]; + if (paddingParam.test(p)) { + continue; + } + result.push(p); + } + return result; +} + +export function buildCaptureProp( + loopVars: string[], + preserveOrder: boolean = false +): { propName: string; propValue: string } | null { + if (loopVars.length === 0) { + return null; + } + + if (loopVars.length === 1) { + return { propName: 'q:p', propValue: loopVars[0] }; + } + + const sorted = preserveOrder ? loopVars : [...loopVars].sort(); + return { propName: 'q:ps', propValue: '[' + sorted.join(', ') + ']' }; +} + +function extractCallbackParams(callback: AstFunction): string[] { + const params = callback.params ?? []; + const names: string[] = []; + for (const param of params) { + if (param.type === 'Identifier') { + names.push(param.name); + } else if (param.type === 'AssignmentPattern' && param.left?.type === 'Identifier') { + names.push(param.left.name); + } + } + return names; +} + +function getCallbackBodyRange(callback: AstFunction): { start: number; end: number } { + const body = callback.body; + if (!body) { + return { start: callback.start, end: callback.end }; + } + return { start: body.start, end: body.end }; +} + +function extractForInitVars(init: ForStatement['init']): string[] { + if (!init) { + return []; + } + if (init.type === 'VariableDeclaration') { + return extractDeclaratorNames(init.declarations ?? []); + } + return []; +} + +function extractForLeftVars(left: ForOfStatement['left'] | ForInStatement['left']): string[] { + if (!left) { + return []; + } + if (left.type === 'VariableDeclaration') { + return extractDeclaratorNames(left.declarations ?? []); + } + if (left.type === 'Identifier') { + return [left.name]; + } + return []; +} + +function extractDeclaratorNames(declarators: VariableDeclarator[]): string[] { + const names: string[] = []; + for (const decl of declarators) { + if (decl.id?.type === 'Identifier') { + names.push(decl.id.name); + } + } + return names; +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/qp-walk.ts b/packages/ts-optimizer/src/optimizer/jsx/qp-walk.ts new file mode 100644 index 00000000000..d5a0db0ee77 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/qp-walk.ts @@ -0,0 +1,78 @@ +/** + * Shared q:p collection walk for both emit paths (segment codegen and inline/hoist). The walk shape + * is identical; only param resolution differs, so it's injected as `resolveParams`. Matched QRL + * names feed `qrlsWithCaptures`, which drives downstream var/const prop classification. + */ + +import { forEachAstChild } from '../ast/guards.js'; +import { getJsxAttributeName } from './jsx-attr-name.js'; +import { isEventAttributeName } from '../qwik/event-attrs.js'; +import type { AstMaybeNode, JSXAttributeItem } from '../../ast-types.js'; + +export function collectQpParamsFromElement( + attrs: readonly JSXAttributeItem[], + resolveParams: (qrlName: string) => readonly string[] | undefined, + qrlsWithCaptures?: Set +): string[] { + const elementParams: string[] = []; + const seen = new Set(); + + for (const attr of attrs) { + if (attr.type !== 'JSXAttribute') { + continue; + } + + const attrName = getJsxAttributeName(attr); + if (!isEventAttributeName(attrName)) { + continue; + } + if (attr.value?.type !== 'JSXExpressionContainer') { + continue; + } + if (attr.value.expression?.type !== 'Identifier') { + continue; + } + + const qrlName = attr.value.expression.name; + const params = resolveParams(qrlName); + if (!params) { + continue; + } + + qrlsWithCaptures?.add(qrlName); + for (const p of params) { + if (!seen.has(p)) { + seen.add(p); + elementParams.push(p); + } + } + } + + return elementParams; +} + +export function walkAstForQp( + node: AstMaybeNode, + resolveParams: (qrlName: string) => readonly string[] | undefined, + qpOverrides: Map, + qrlsWithCaptures?: Set +): void { + if (!node || typeof node !== 'object') { + return; + } + + if (node.type === 'JSXElement' && node.openingElement) { + const elementParams = collectQpParamsFromElement( + node.openingElement.attributes, + resolveParams, + qrlsWithCaptures + ); + if (elementParams.length > 0) { + qpOverrides.set(node.start, elementParams); + } + } + + forEachAstChild(node, (child) => + walkAstForQp(child, resolveParams, qpOverrides, qrlsWithCaptures) + ); +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/signal-analysis.ts b/packages/ts-optimizer/src/optimizer/jsx/signal-analysis.ts new file mode 100644 index 00000000000..ff0f2690353 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/signal-analysis.ts @@ -0,0 +1,1110 @@ +import type { AstMaybeNode, AstNode } from '../../ast-types.js'; +import { forEachAstChild, someAstDescendant, memberStaticPropName } from '../ast/guards.js'; +import { lambdaBodySimplificationsCollector } from './simplify.js'; +import { + applyReplacements, + collectRangeReplacements, + isReplaceableIdentifierPosition, + type RangeReplacementCollector, +} from '../edit/range-replace.js'; +import { quoteAsStringLiteral } from '../edit/string-literal.js'; +import { replaceOutsideStrings } from '../edit/identifier-boundary.js'; +import { skipStringLiteralForward } from '../edit/text-scanning.js'; +import { stripExpressionTypes } from '../edit/strip-types.js'; +import { addBindingNamesFromPatternToSet } from '../ast/binding-pattern.js'; + +const trailingComma = /,([}\])])/g; + +export type SignalExprResult = + | { type: 'none' } + | { type: 'wrapProp'; code: string; isStoreField?: boolean } + | { + type: 'fnSignal'; + deps: string[]; + hoistedFn: string; + hoistedStr: string; + isObjectExpr?: boolean; + }; + +type IdentifierNode = Extract; +type MemberExpressionNode = Extract; + +function peelExpressionWrappers(node: AstMaybeNode): AstNode | null { + let current: AstMaybeNode = node; + while (current) { + switch (current.type) { + case 'ParenthesizedExpression': + case 'TSAsExpression': + case 'TSNonNullExpression': + case 'TSTypeAssertion': + case 'TSInstantiationExpression': + case 'TSSatisfiesExpression': + current = current.expression; + continue; + default: + return current; + } + } + return null; +} + +const GLOBAL_NAMES = new Set([ + 'window', + 'document', + 'globalThis', + 'navigator', + 'location', + 'history', + 'screen', + 'localStorage', + 'sessionStorage', + 'console', + 'Math', + 'JSON', + 'Date', + 'Array', + 'Object', + 'String', + 'Number', + 'Boolean', + 'undefined', + 'NaN', + 'Infinity', +]); + +function isSignalValueAccess(node: AstMaybeNode): node is MemberExpressionNode { + return ( + node != null && + node.type === 'MemberExpression' && + !node.computed && + node.property?.type === 'Identifier' && + node.property.name === 'value' + ); +} + +function isStoreFieldAccess( + node: AstMaybeNode, + importedNames: Set, + localNames?: Set +): node is MemberExpressionNode { + if (node == null || node.type !== 'MemberExpression') { + return false; + } + if (node.object?.type !== 'Identifier') { + return false; + } + + const objName = node.object.name; + if (importedNames.has(objName)) { + return false; + } + + // A local binding shadows a same-named global (e.g. flattened `location`); + // without scope info only known globals are excluded. + if (localNames ? !localNames.has(objName) : GLOBAL_NAMES.has(objName)) { + return false; + } + + const propName = memberStaticPropName(node); + if (propName == null || propName === 'value') { + return false; + } + + return true; +} + +function containsJsx(node: AstMaybeNode): boolean { + return someAstDescendant(node, (n) => n.type === 'JSXElement' || n.type === 'JSXFragment'); +} + +function containsUnknownCall(node: AstMaybeNode, importedNames: Set): boolean { + return someAstDescendant(node, (n) => { + if (n.type === 'TaggedTemplateExpression') { + return true; + } + if (n.type !== 'CallExpression') { + return false; + } + if (n.callee?.type === 'MemberExpression' || n.callee?.type === 'ChainExpression') { + return false; + } + const calleeName = getCalleeIdentifierName(n.callee); + return calleeName == null || !importedNames.has(calleeName); + }); +} + +function getCalleeIdentifierName(callee: AstMaybeNode): string | null { + if (callee?.type === 'Identifier') { + return callee.name; + } + return null; +} + +function containsImportedReference(node: AstMaybeNode, importedNames: Set): boolean { + return someAstDescendant(node, (n) => n.type === 'Identifier' && importedNames.has(n.name)); +} + +/** + * True when the expression contains a call outside an optional chain. Such a call blocks hoisting: + * a serialized, reactively re-run `_fnSignal` body cannot carry an arbitrary call, whereas an + * optional-chain getter (`a?.b()`) can. + */ +function containsNonOptionalCall(node: AstMaybeNode): boolean { + return hasCallOutsideChainSpine(node, false); +} + +function hasCallOutsideChainSpine(node: AstMaybeNode, inChainSpine: boolean): boolean { + if (node == null) { + return false; + } + + if (node.type === 'ChainExpression') { + return hasCallOutsideChainSpine(node.expression, true); + } + + if (node.type === 'CallExpression') { + if (!inChainSpine) { + return true; + } + if (hasCallOutsideChainSpine(node.callee, true)) { + return true; + } + for (const arg of node.arguments) { + if (hasCallOutsideChainSpine(arg, false)) { + return true; + } + } + return false; + } + + if (node.type === 'MemberExpression') { + if (hasCallOutsideChainSpine(node.object, inChainSpine)) { + return true; + } + if (node.computed && hasCallOutsideChainSpine(node.property, false)) { + return true; + } + return false; + } + + let found = false; + forEachAstChild(node, (child) => { + if (!found && hasCallOutsideChainSpine(child, false)) { + found = true; + } + }); + return found; +} + +function getMemberChainRoot(node: AstNode): string | null { + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'MemberExpression') { + return getMemberChainRoot(node.object); + } + return null; +} + +function getMemberChainDepth(node: AstNode): number { + if (node.type !== 'MemberExpression') { + return 0; + } + return 1 + getMemberChainDepth(node.object); +} + +function isDeepStoreAccess( + node: AstNode, + importedNames: Set, + localNames?: Set +): node is MemberExpressionNode { + if (node.type !== 'MemberExpression') { + return false; + } + if (getMemberChainDepth(node) < 2) { + return false; + } + + const root = getMemberChainRoot(node); + if (root == null) { + return false; + } + if (importedNames.has(root)) { + return false; + } + // Local bindings shadow same-named globals. + if (localNames ? !localNames.has(root) : GLOBAL_NAMES.has(root)) { + return false; + } + return true; +} + +/** + * Single-walk collector returning both `roots` (reactive roots, first-appearance order) and + * `allDeps` (roots plus bare locally-bound identifiers, sorted). The non-computed + * `MemberExpression.property` and `Property.key` positions are excluded from both outputs — safe + * because signal/store detection happens at the MemberExpression level, not the property + * Identifier. + */ +interface SignalDepsResult { + roots: string[]; + allDeps: string[]; +} + +function collectSignalDeps( + node: AstNode, + importedNames: Set, + localNames?: Set +): SignalDepsResult { + const roots: string[] = []; + const bareIdents: string[] = []; + const rootsSeen = new Set(); + const allSeen = new Set(); + const boundParams = new Set(); + + function addRoot(name: string | null): void { + if (name == null) { + return; + } + if (boundParams.has(name)) { + return; + } + if (!rootsSeen.has(name)) { + rootsSeen.add(name); + roots.push(name); + } + allSeen.add(name); + } + + function addBare(name: string): void { + if (importedNames.has(name)) { + return; + } + // Local bindings shadow same-named globals. + if (GLOBAL_NAMES.has(name) && !localNames?.has(name)) { + return; + } + if (boundParams.has(name)) { + return; + } + if (allSeen.has(name)) { + return; + } + allSeen.add(name); + bareIdents.push(name); + } + + function fallbackCollectIdents(n: AstMaybeNode): void { + if (n == null) { + return; + } + if (n.type === 'Identifier') { + if (!importedNames.has(n.name)) { + addRoot(n.name); + } + return; + } + forEachAstChild(n, (child) => fallbackCollectIdents(child)); + } + + // A store/signal chain's computed keys (`sig.value[key]`) are dependencies: + // a loop-local key must be carried so the hoisted `_fnSignal` parameterizes + // it, while module-scope keys stay free (resolvable at the hoist site). + function walkComputedKeys(chainNode: AstMaybeNode): void { + let cur: AstMaybeNode = chainNode; + while (cur != null && cur.type === 'MemberExpression') { + if (cur.computed && cur.property != null) { + walkKey(cur.property); + } + cur = cur.object; + } + } + + function walkKey(keyNode: AstMaybeNode): void { + if (keyNode == null) { + return; + } + if ( + isSignalValueAccess(keyNode) || + isDeepStoreAccess(keyNode, importedNames, localNames) || + isStoreFieldAccess(keyNode, importedNames, localNames) + ) { + walk(keyNode); + return; + } + if (keyNode.type === 'Identifier') { + if (localNames == null || localNames.has(keyNode.name)) { + addBare(keyNode.name); + } + return; + } + forEachAstChild(keyNode, (child) => walkKey(child)); + } + + function walk(n: AstMaybeNode): void { + if (n == null) { + return; + } + + if (isSignalValueAccess(n)) { + const root = getMemberChainRoot(n.object); + if (root != null) { + addRoot(root); + walkComputedKeys(n.object); + return; + } + fallbackCollectIdents(n.object); + return; + } + + if (isDeepStoreAccess(n, importedNames, localNames)) { + addRoot(getMemberChainRoot(n)); + walkComputedKeys(n); + return; + } + + if (isStoreFieldAccess(n, importedNames, localNames)) { + // Load-bearing cast: the preceding `isDeepStoreAccess` (also + // `n is MemberExpressionNode`) has narrowed `n` such that re-narrowing + // to the same type yields `never`. + const memberNode = n as MemberExpressionNode; + addRoot(getMemberChainRoot(memberNode.object)); + walkComputedKeys(memberNode.object); + return; + } + + if (n.type === 'Identifier') { + addBare(n.name); + return; + } + + if (n.type === 'ArrowFunctionExpression' || n.type === 'FunctionExpression') { + const paramNames = new Set(); + for (const param of n.params) { + addBindingNamesFromPatternToSet(param, paramNames); + } + const introduced: string[] = []; + for (const name of paramNames) { + if (!boundParams.has(name)) { + boundParams.add(name); + introduced.push(name); + } + } + descend(n); + for (const name of introduced) { + boundParams.delete(name); + } + return; + } + + descend(n); + } + + function descend(n: AstNode): void { + forEachAstChild(n, (child, key, parent) => { + if (key === 'key' && parent.type === 'Property') { + return; + } + if (key === 'property' && parent.type === 'MemberExpression' && !parent.computed) { + return; + } + walk(child); + }); + } + + walk(node); + + const allDeps = [...roots, ...bareIdents]; + allDeps.sort((a, b) => a.localeCompare(b)); + return { roots, allDeps }; +} + +/** Rust caps the rendered fnSignal body at 150 chars; longer stays inline (inlined_fn.rs). */ +const MAX_FN_SIGNAL_BODY_LEN = 150; + +/** + * Generate a hoisted function body, replacing reactive root names with `pN` params. Returns both + * the full function (original spacing) and a minimal-whitespace serialized string form. + */ +function generateFnSignal( + exprNode: AstNode, + source: string, + roots: string[] +): { hoistedFn: string; hoistedStr: string; strBodyLen: number } { + const rootToParam = new Map(); + for (let i = 0; i < roots.length; i++) { + rootToParam.set(roots[i], `p${i}`); + } + + const exprText = source.slice(exprNode.start, exprNode.end); + const exprStart = exprNode.start; + + // Root substitution and constant-subtree simplification share the + // orchestrator; disjoint by construction — roots target Identifiers, + // simplifications target Binary/Unary/Logical/Conditional with primitive + // operands, so no node type overlaps. + const rootCollector = rootReplacementsCollector(rootToParam); + const replacements = collectRangeReplacements(exprNode, exprStart, exprText, [rootCollector]); + const simplifications = collectRangeReplacements(exprNode, exprStart, exprText, [ + lambdaBodySimplificationsCollector(), + ]); + + const strFnBody = stripExpressionTypes(applyReplacements(exprText, replacements)); + + const lambdaFnBody = + simplifications.length > 0 + ? stripExpressionTypes(applyReplacements(exprText, [...replacements, ...simplifications])) + : strFnBody; + + const params = roots.map((_, i) => `p${i}`).join(','); + + // Object expressions need wrapping parens so the arrow returns an object, not a block + const needsParens = exprNode.type === 'ObjectExpression'; + const hoistedFn = needsParens ? `(${params})=>(${lambdaFnBody})` : `(${params})=>${lambdaFnBody}`; + + let strBody = stripTrailingCommas(normalizeStringQuotes(removeWhitespace(strFnBody))); + strBody = stripOuterParens(strBody); + strBody = stripTernaryConditionParens(strBody); + + const hoistedStr = quoteAsStringLiteral(strBody); + + return { hoistedFn, hoistedStr, strBodyLen: strBody.length }; +} + +function collectComputedKeyRewrites( + chainNode: AstNode, + rootToParam: ReadonlyMap, + exprStart: number +): Array<{ start: number; end: number; replacement: string }> { + const reps: Array<{ start: number; end: number; replacement: string }> = []; + function rewriteKey(node: AstMaybeNode, parentKey?: string, parentNode?: AstNode): void { + if (node == null) { + return; + } + if (node.type === 'Identifier') { + if ( + parentKey === 'property' && + parentNode?.type === 'MemberExpression' && + !parentNode.computed + ) { + return; + } + const param = rootToParam.get(node.name); + if (param != null) { + reps.push({ start: node.start - exprStart, end: node.end - exprStart, replacement: param }); + } + return; + } + forEachAstChild(node, (child, key, parent) => rewriteKey(child, key, parent)); + } + let cur: AstMaybeNode = chainNode; + while (cur != null && cur.type === 'MemberExpression') { + if (cur.computed && cur.property != null) { + rewriteKey(cur.property, 'property', cur); + } + cur = cur.object; + } + return reps; +} + +function rootReplacementsCollector( + rootToParam: ReadonlyMap +): RangeReplacementCollector { + return (n, ctx) => { + if (isSignalValueAccess(n) || isDeepStoreAccess(n, new Set())) { + const keyRewrites = collectComputedKeyRewrites(n, rootToParam, ctx.exprStart); + const rootId = findRootIdentifier(n); + if (rootId && rootToParam.has(rootId.name)) { + return { + replacements: [ + { + start: rootId.start - ctx.exprStart, + end: rootId.end - ctx.exprStart, + replacement: rootToParam.get(rootId.name)!, + }, + ...keyRewrites, + ], + skipSubtree: true, + }; + } + if (isSignalValueAccess(n) && !rootId) { + // Complex object (e.g. `(a || b).value`) — let orchestrator + // recurse so the bare-identifier arm catches inner identifiers. + return null; + } + return { replacements: keyRewrites, skipSubtree: true }; + } + + if (isStoreFieldAccess(n, new Set())) { + const memberNode = n as MemberExpressionNode; + const keyRewrites = collectComputedKeyRewrites(memberNode.object, rootToParam, ctx.exprStart); + const rootId = findRootIdentifier(memberNode.object); + if (rootId && rootToParam.has(rootId.name)) { + return { + replacements: [ + { + start: rootId.start - ctx.exprStart, + end: rootId.end - ctx.exprStart, + replacement: rootToParam.get(rootId.name)!, + }, + ...keyRewrites, + ], + skipSubtree: true, + }; + } + return { replacements: keyRewrites, skipSubtree: true }; + } + + if ( + n.type === 'Identifier' && + rootToParam.has(n.name) && + isReplaceableIdentifierPosition(ctx.parentKey, ctx.parentNode) + ) { + return { + replacements: [ + { + start: n.start - ctx.exprStart, + end: n.end - ctx.exprStart, + replacement: rootToParam.get(n.name)!, + }, + ], + skipSubtree: true, + }; + } + + return null; + }; +} + +function findRootIdentifier(node: AstMaybeNode): IdentifierNode | null { + if (!node) { + return null; + } + if (node.type === 'Identifier') { + return node; + } + if (node.type === 'MemberExpression') { + return findRootIdentifier(node.object); + } + return null; +} + +/** + * Remove whitespace around operators for the minimal string form, preserving whitespace inside + * string literals and inserting a space only where adjacent tokens would merge (e.g. `in`, + * `instanceof`). Intentionally a character-level tokenizer, not AST-based — it runs on tiny + * already-extracted fragments where re-parsing would be more complex. + */ +function removeWhitespace(text: string): string { + const tokens: string[] = []; + let i = 0; + + while (i < text.length) { + const ch = text[i]; + + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + i++; + continue; + } + + if (ch === '"' || ch === "'") { + let tok = ch; + i++; + while (i < text.length && text[i] !== ch) { + if (text[i] === '\\') { + tok += text[i]; + i++; + } + if (i < text.length) { + tok += text[i]; + i++; + } + } + if (i < text.length) { + tok += text[i]; + i++; + } + tokens.push(tok); + continue; + } + + if (ch === '`') { + let tok = ch; + i++; + while (i < text.length && text[i] !== '`') { + tok += text[i]; + i++; + } + if (i < text.length) { + tok += text[i]; + i++; + } + tokens.push(tok); + continue; + } + + let tok = ''; + while (i < text.length) { + const c = text[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { + break; + } + if (c === '"' || c === "'" || c === '`') { + break; + } + tok += c; + i++; + } + if (tok) { + tokens.push(tok); + } + } + + let result = ''; + for (let t = 0; t < tokens.length; t++) { + if (t > 0) { + const prevLast = result[result.length - 1]; + const curFirst = tokens[t][0]; + if (isWordChar(prevLast) && isWordChar(curFirst)) { + result += ' '; + } + } + result += tokens[t]; + } + + return result; +} + +function isWordChar(ch: string): boolean { + return /[a-zA-Z0-9_$]/.test(ch); +} + +/** Strip trailing commas before closing delimiters (dropped by AST re-serialization). */ +function stripTrailingCommas(text: string): string { + return replaceOutsideStrings(text, trailingComma, '$1'); +} + +/** + * Find the matching close-paren for an open paren at position 0, skipping string/template literals; + * returns -1 if not found. Intentionally text-based — runs on tiny fragments where re-parsing isn't + * worth it. + */ +function findMatchingParen(text: string): number { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + if (depth === 0) { + return i; + } + } else if (ch === '"' || ch === "'" || ch === '`') { + i = skipStringLiteralForward(text, i); + } + } + return -1; +} + +/** + * Strip balanced outer parentheses that wrap the entire expression (unnecessary parens are omitted + * by AST re-serialization). + */ +function stripOuterParens(text: string): string { + while (text.length >= 2 && text[0] === '(') { + const matchPos = findMatchingParen(text); + if (matchPos === text.length - 1) { + text = text.slice(1, -1); + } else { + break; + } + } + return text; +} + +/** Strip redundant parentheses around a ternary condition: `(cond)?cons:alt` -> `cond?cons:alt`. */ +function stripTernaryConditionParens(text: string): string { + if (text.length < 4 || text[0] !== '(') { + return text; + } + + const matchPos = findMatchingParen(text); + + if (matchPos > 0 && matchPos < text.length - 1 && text[matchPos + 1] === '?') { + return text.slice(1, matchPos) + text.slice(matchPos + 1); + } + + return text; +} + +/** + * Normalize single-quoted string literals to double quotes (the serialized double-quote + * convention). Intentionally character-level — runs on tiny fragments where re-parsing isn't worth + * it. + */ +function normalizeStringQuotes(text: string): string { + let result = ''; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "'") { + let j = i + 1; + let content = ''; + while (j < text.length && text[j] !== "'") { + if (text[j] === '\\') { + if (text[j + 1] === "'") { + // Escaped single quote -> unescaped in double-quote context + content += "'"; + j += 2; + } else { + content += text[j] + text[j + 1]; + j += 2; + } + } else { + if (text[j] === '"') { + content += '\\"'; + } else { + content += text[j]; + } + j++; + } + } + result += '"' + content + '"'; + i = j; + } else if (ch === '"') { + result += ch; + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') { + result += text[i] + text[i + 1]; + i += 2; + } else { + result += text[i]; + i++; + } + } + if (i < text.length) { + result += text[i]; + } + } else { + result += ch; + } + } + return result; +} + +export function analyzeSignalExpression( + exprNode: AstMaybeNode, + source: string, + importedNames: Set, + localNames?: Set +): SignalExprResult { + // A local declaration shadows a same-named import (e.g. `const jsx = + // useSignal(...)` under `import { jsx }`); analyzing it as an import would + // veto the fnSignal hoist. + if (localNames) { + let shadowFree: Set | null = null; + for (const name of importedNames) { + if (localNames.has(name)) { + shadowFree ??= new Set(importedNames); + shadowFree.delete(name); + } + } + if (shadowFree) { + importedNames = shadowFree; + } + } + const peeled = peelExpressionWrappers(exprNode); + if (peeled == null) { + return { type: 'none' }; + } + if (peeled !== exprNode) { + return analyzeSignalExpression(peeled, source, importedNames, localNames); + } + exprNode = peeled; + + if (exprNode.type === 'Literal') { + return { type: 'none' }; + } + + if (exprNode.type === 'Identifier') { + return { type: 'none' }; + } + + if ( + exprNode.type === 'TemplateLiteral' && + (!exprNode.expressions || exprNode.expressions.length === 0) + ) { + return { type: 'none' }; + } + + if (exprNode.type === 'ChainExpression') { + return tryBuildFnSignal(exprNode, source, importedNames, localNames); + } + + if (exprNode.type === 'CallExpression') { + return analyzeCallExpression(exprNode, source, importedNames, localNames); + } + + if (exprNode.type === 'MemberExpression') { + return analyzeMemberExpression(exprNode, source, importedNames, localNames); + } + + if (exprNode.type === 'ObjectExpression') { + const { roots, allDeps } = collectSignalDeps(exprNode, importedNames, localNames); + if (roots.length === 0) { + return { type: 'none' }; + } + if (containsNonOptionalCall(exprNode)) { + return { type: 'none' }; + } + // The serialized _fnSignal string form cannot resolve imports. + if (containsImportedReference(exprNode, importedNames)) { + return { type: 'none' }; + } + // Hoisting would orphan raw JSX outside the JSX transform. + if (containsJsx(exprNode)) { + return { type: 'none' }; + } + const { hoistedFn, hoistedStr, strBodyLen } = generateFnSignal(exprNode, source, allDeps); + if (strBodyLen > MAX_FN_SIGNAL_BODY_LEN) { + return { type: 'none' }; + } + return { type: 'fnSignal', deps: allDeps, hoistedFn, hoistedStr, isObjectExpr: true }; + } + + if (exprNode.type === 'ArrayExpression') { + const { roots, allDeps } = collectSignalDeps(exprNode, importedNames, localNames); + if (roots.length === 0) { + return { type: 'none' }; + } + if (containsNonOptionalCall(exprNode)) { + return { type: 'none' }; + } + // The serialized _fnSignal string form cannot resolve imports. + if (containsImportedReference(exprNode, importedNames)) { + return { type: 'none' }; + } + // Hoisting would orphan raw JSX outside the JSX transform. + if (containsJsx(exprNode)) { + return { type: 'none' }; + } + const { hoistedFn, hoistedStr, strBodyLen } = generateFnSignal(exprNode, source, allDeps); + if (strBodyLen > MAX_FN_SIGNAL_BODY_LEN) { + return { type: 'none' }; + } + return { type: 'fnSignal', deps: allDeps, hoistedFn, hoistedStr }; + } + + if ( + exprNode.type === 'BinaryExpression' || + exprNode.type === 'ConditionalExpression' || + exprNode.type === 'LogicalExpression' || + exprNode.type === 'UnaryExpression' || + exprNode.type === 'TemplateLiteral' + ) { + return tryBuildFnSignal(exprNode, source, importedNames, localNames); + } + + return { type: 'none' }; +} + +function tryBuildFnSignal( + exprNode: AstNode, + source: string, + importedNames: Set, + localNames?: Set +): SignalExprResult { + const { roots, allDeps } = collectSignalDeps(exprNode, importedNames, localNames); + if (roots.length === 0) { + return { type: 'none' }; + } + + if (containsUnknownCall(exprNode, importedNames)) { + return { type: 'none' }; + } + if (containsNonOptionalCall(exprNode)) { + return { type: 'none' }; + } + if (containsImportedReference(exprNode, importedNames)) { + return { type: 'none' }; + } + if (containsJsx(exprNode)) { + return { type: 'none' }; + } + + const { hoistedFn, hoistedStr, strBodyLen } = generateFnSignal(exprNode, source, allDeps); + if (strBodyLen > MAX_FN_SIGNAL_BODY_LEN) { + return { type: 'none' }; + } + return { type: 'fnSignal', deps: allDeps, hoistedFn, hoistedStr }; +} + +function analyzeCallExpression( + exprNode: Extract, + source: string, + importedNames: Set, + localNames?: Set +): SignalExprResult { + const calleeName = getCalleeIdentifierName(exprNode.callee); + if (calleeName === 'mutable') { + return { type: 'none' }; + } + if (isSignalValueAccess(exprNode.callee)) { + return { type: 'none' }; + } + + return tryBuildFnSignal(exprNode, source, importedNames, localNames); +} + +function analyzeMemberExpression( + exprNode: Extract, + source: string, + importedNames: Set, + localNames?: Set +): SignalExprResult { + const unwrapped = peelExpressionWrappers(exprNode.object); + const objIdent = unwrapped?.type === 'Identifier' ? unwrapped.name : null; + + // Only apply signal treatment to identifiers resolvable in scope. + const isKnownIdent = + objIdent === null || + importedNames.has(objIdent) || + GLOBAL_NAMES.has(objIdent) || + (localNames?.has(objIdent) ?? true); + + if (isSignalValueAccess(exprNode) && isKnownIdent) { + if (objIdent !== null) { + return { type: 'wrapProp', code: `_wrapProp(${objIdent})` }; + } + const { allDeps } = collectSignalDeps(exprNode, importedNames, localNames); + if (allDeps.length > 0) { + const { hoistedFn, hoistedStr, strBodyLen } = generateFnSignal(exprNode, source, allDeps); + if (strBodyLen <= MAX_FN_SIGNAL_BODY_LEN) { + return { type: 'fnSignal', deps: allDeps, hoistedFn, hoistedStr }; + } + } + const objText = source.slice(exprNode.object.start, exprNode.object.end); + return { type: 'wrapProp', code: `_wrapProp(${objText})` }; + } + + if (isKnownIdent && isDeepStoreAccess(exprNode, importedNames, localNames)) { + const { allDeps } = collectSignalDeps(exprNode, importedNames, localNames); + if (allDeps.length > 0) { + const { hoistedFn, hoistedStr, strBodyLen } = generateFnSignal(exprNode, source, allDeps); + if (strBodyLen <= MAX_FN_SIGNAL_BODY_LEN) { + return { type: 'fnSignal', deps: allDeps, hoistedFn, hoistedStr }; + } + } + } + + if (isKnownIdent && isStoreFieldAccess(exprNode, importedNames, localNames)) { + const objText = source.slice(exprNode.object.start, exprNode.object.end); + const propName = memberStaticPropName(exprNode)!; + return { + type: 'wrapProp', + code: `_wrapProp(${objText}, "${propName}")`, + isStoreField: true, + }; + } + + // Computed non-literal property (`results[i]`, `obj[key]`, `obj[42]`) — the + // case the branches above miss: `exprNode.computed === true` and the property + // isn't a string literal (so `isStoreFieldAccess` didn't fire). Emits + // `_fnSignal((p0,p1) => p1[p0], deps, str)`. + if (isKnownIdent && exprNode.computed) { + const { allDeps } = collectSignalDeps(exprNode, importedNames, localNames); + if ( + allDeps.length > 0 && + !containsUnknownCall(exprNode, importedNames) && + !containsNonOptionalCall(exprNode) && + !containsImportedReference(exprNode, importedNames) && + !containsJsx(exprNode) + ) { + const { hoistedFn, hoistedStr, strBodyLen } = generateFnSignal(exprNode, source, allDeps); + if (strBodyLen <= MAX_FN_SIGNAL_BODY_LEN) { + return { type: 'fnSignal', deps: allDeps, hoistedFn, hoistedStr }; + } + } + } + + return { type: 'none' }; +} + +export class SignalHoister { + counter = 0; + hoistedFunctions: Array<{ name: string; fn: string; str: string; sourcePos: number }> = []; + private dedupMap = new Map(); + + /** `base` offsets `_hf` numbering past another hoister emitting into the same module scope. */ + constructor(private base: number = 0) {} + + hoist(fn: string, str: string, sourcePos: number = 0): string { + const existing = this.dedupMap.get(fn); + if (existing) { + return existing; + } + + const name = `_hf${this.base + this.counter}`; + this.hoistedFunctions.push({ name, fn, str, sourcePos }); + this.dedupMap.set(fn, name); + this.counter++; + return name; + } + + getDeclarations(): string[] { + const lines: string[] = []; + for (const h of this.hoistedFunctions) { + lines.push(`const ${h.name} = ${h.fn};`); + lines.push(`const ${h.name}_str = ${h.str};`); + } + return lines; + } + + /** + * Build a map renumbering `_hf` variables to source-position order. The walk is bottom-up (leave + * callback), so emitted `_hf` numbers must be remapped to top-down source order. Returns null if + * already in order. + */ + buildRenameMap(startIndex = 0): Map | null { + // Only functions added since `startIndex` may be renamed: earlier entries + // belong to bodies whose text is already frozen (shared hoister). + const slice = this.hoistedFunctions.slice(startIndex); + if (slice.length <= 1) { + return null; + } + + const sorted = [...slice].sort((a, b) => a.sourcePos - b.sourcePos); + + let needsRename = false; + for (let i = 0; i < sorted.length; i++) { + if (sorted[i].name !== `_hf${this.base + startIndex + i}`) { + needsRename = true; + break; + } + } + if (!needsRename) { + return null; + } + + const renameMap = new Map(); + for (let i = 0; i < sorted.length; i++) { + const oldName = sorted[i].name; + const newName = `_hf${this.base + startIndex + i}`; + if (oldName !== newName) { + renameMap.set(oldName, newName); + } + } + + const renamed = sorted.map((h, i) => ({ + ...h, + name: `_hf${this.base + startIndex + i}`, + })); + this.hoistedFunctions = [...this.hoistedFunctions.slice(0, startIndex), ...renamed]; + for (const h of renamed) { + this.dedupMap.set(h.fn, h.name); + } + + return renameMap; + } +} diff --git a/packages/ts-optimizer/src/optimizer/jsx/simplify.ts b/packages/ts-optimizer/src/optimizer/jsx/simplify.ts new file mode 100644 index 00000000000..f6a5d9c7d90 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/jsx/simplify.ts @@ -0,0 +1,262 @@ +/** + * Expression simplifier for JSX prop values: evaluates compile-time-constant subtrees and rewrites + * them as literals to shrink emitted code, so `prop={'true' + 1 ? 'true' : ''}` emits as `prop: + * 'true'`. Post-order so nested subtrees collapse from the leaves up. Conservative — only primitive + * literal operands (string/number/boolean/null/undefined); BigInt, divide-by-zero, and other exotic + * coercions are left untouched. + */ +import type { AstMaybeNode } from '../../ast-types.js'; +import { createTransformSession } from '../edit/transform-session.js'; +import { + applyReplacements, + collectRangeReplacements, + type RangeReplacementCollector, +} from '../edit/range-replace.js'; + +export function simplifyExpression(node: AstMaybeNode): SimplifyResult { + if (!node) { + return UNSIMPLIFIED; + } + switch (node.type) { + case 'Literal': { + // The typeof guards admit only the four primitive-valued Literal variants; + // BigInt (bigint value) and RegExp (object value) fall through to + // UNSIMPLIFIED. + const v = node.value; + if ( + v === null || + v === undefined || + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' + ) { + return { simplified: true, value: v }; + } + return UNSIMPLIFIED; + } + + case 'UnaryExpression': { + const arg = simplifyExpression(node.argument); + if (!arg.simplified) { + return UNSIMPLIFIED; + } + const v = arg.value; + switch (node.operator) { + case '!': + return { simplified: true, value: !v }; + case 'typeof': + return { simplified: true, value: typeof v }; + case 'void': + return { simplified: true, value: undefined }; + case '-': + return typeof v === 'number' ? { simplified: true, value: -v } : UNSIMPLIFIED; + case '+': + return typeof v === 'number' ? { simplified: true, value: +v } : UNSIMPLIFIED; + case '~': + return typeof v === 'number' ? { simplified: true, value: ~v } : UNSIMPLIFIED; + } + return UNSIMPLIFIED; + } + + case 'BinaryExpression': { + const left = simplifyExpression(node.left); + if (!left.simplified) { + return UNSIMPLIFIED; + } + const right = simplifyExpression(node.right); + if (!right.simplified) { + return UNSIMPLIFIED; + } + // The `as never` casts are load-bearing: TS refuses `string + boolean`, + // but JS-side folding must allow it. The casts bridge the TS-vs-JS + // arithmetic gap; the result stays typed via the typeof guards on each + // non-`+` branch. + const l = left.value as never; + const r = right.value as never; + switch (node.operator) { + case '+': + return { simplified: true, value: (l as never) + (r as never) }; + case '-': + return typeof l === 'number' && typeof r === 'number' + ? { simplified: true, value: l - r } + : UNSIMPLIFIED; + case '*': + return typeof l === 'number' && typeof r === 'number' + ? { simplified: true, value: l * r } + : UNSIMPLIFIED; + case '/': + return typeof l === 'number' && typeof r === 'number' && r !== 0 + ? { simplified: true, value: l / r } + : UNSIMPLIFIED; + case '%': + return typeof l === 'number' && typeof r === 'number' && r !== 0 + ? { simplified: true, value: l % r } + : UNSIMPLIFIED; + case '===': + return { simplified: true, value: l === r }; + case '!==': + return { simplified: true, value: l !== r }; + + case '==': + return { simplified: true, value: l == r }; + + case '!=': + return { simplified: true, value: l != r }; + case '<': + return { simplified: true, value: l < r }; + case '>': + return { simplified: true, value: l > r }; + case '<=': + return { simplified: true, value: l <= r }; + case '>=': + return { simplified: true, value: l >= r }; + } + return UNSIMPLIFIED; + } + + case 'LogicalExpression': { + const left = simplifyExpression(node.left); + if (!left.simplified) { + return UNSIMPLIFIED; + } + const l = left.value; + switch (node.operator) { + case '&&': + return l ? simplifyExpression(node.right) : left; + case '||': + return l ? left : simplifyExpression(node.right); + case '??': + return l === null || l === undefined ? simplifyExpression(node.right) : left; + } + return UNSIMPLIFIED; + } + + case 'ConditionalExpression': { + const t = simplifyExpression(node.test); + if (!t.simplified) { + return UNSIMPLIFIED; + } + return simplifyExpression(t.value ? node.consequent : node.alternate); + } + } + return UNSIMPLIFIED; +} + +/** + * Format a simplified primitive value as a JS source string for splicing into emitted code. Strings + * use single quotes; other primitives use their canonical form. + */ +export function formatSimplifiedLiteral(value: unknown): string { + if (typeof value === 'string') { + // Single-quoted with minimal escaping: embedded single quotes escaped, + // double quotes left alone. + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`; + } + if (value === undefined) { + return 'void 0'; + } + if (value === null) { + return 'null'; + } + if (typeof value === 'number') { + if (Number.isNaN(value)) { + return 'NaN'; + } + if (value === Infinity) { + return 'Infinity'; + } + if (value === -Infinity) { + return '-Infinity'; + } + return String(value); + } + return String(value); +} + +export type SimplifyResult = + | { simplified: true; value: string | number | boolean | null | undefined } + | { simplified: false }; + +const UNSIMPLIFIED: SimplifyResult = { simplified: false }; + +/** + * Shared implementation behind the two collector factories. `skipLiterals = true` suppresses + * Literal matching so source-form literals stay as-written (body-source emit) instead of being + * re-canonicalized to single-quoted form (lambda-body emit wants canonical). Returns `skipSubtree: + * true` on every match — children are subsumed by the parent's emit, and recursing would emit + * overlapping ranges into the replaced subtree. + */ +function buildSimplificationsCollector(skipLiterals: boolean): RangeReplacementCollector { + return (node, ctx) => { + if (skipLiterals && node.type === 'Literal') { + return null; + } + if (typeof node.start !== 'number' || typeof node.end !== 'number') { + return null; + } + + const result = simplifyExpression(node); + if (!result.simplified) { + return null; + } + + const formatted = formatSimplifiedLiteral(result.value); + const sliceStart = node.start - ctx.exprStart; + const sliceEnd = node.end - ctx.exprStart; + if (sliceStart < 0 || sliceEnd > ctx.exprText.length) { + // Out-of-bounds — skip but still suppress recursion so children + // don't fire either (would be misaligned by the same offset). + return { replacements: [], skipSubtree: true }; + } + const originalText = ctx.exprText.slice(sliceStart, sliceEnd); + const replacements = + formatted === originalText + ? [] // no-op: source already has the canonical form + : [{ start: sliceStart, end: sliceEnd, replacement: formatted }]; + return { replacements, skipSubtree: true }; + }; +} + +/** + * Collector for the hoisted lambda body (`_hf`) emit. Folds ANY simplifiable subtree including + * `Literal` nodes (re-canonicalizes source quote style to single-quoted form). + */ +export function lambdaBodySimplificationsCollector(): RangeReplacementCollector { + return buildSimplificationsCollector(false); +} + +/** + * Collector for body-source emit — used by {@link foldBodySimplifiableExpressions}. Folds only + * _computed_ subtrees (Binary/Unary/Logical/Conditional with primitive operands); `Literal` nodes + * are left as-written so the user's source quote style is preserved. + */ +export function bodySourceSimplificationsCollector(): RangeReplacementCollector { + return buildSimplificationsCollector(true); +} + +/** + * Fold constant-foldable subtrees inside a segment-body source, as a post-JSX-transform pass. By + * that timing JSX-prop positions are already `_fnSignal(...)` calls, so remaining foldable patterns + * live in non-JSX positions (`console.log(_rawProps.X ?? 1+2)`). Only folds subtrees + * `simplifyExpression` can collapse to a primitive literal. Wraps the body in `const __body__ = …` + * so a bare arrow parses; returns the body unchanged on parse failure. + */ +export function foldBodySimplifiableExpressions(bodyText: string): string { + if (bodyText.length === 0) { + return bodyText; + } + const session = createTransformSession(bodyText); + if (!session) { + return bodyText; + } + + const simplifications = collectRangeReplacements(session.program, 0, session.wrappedSource, [ + bodySourceSimplificationsCollector(), + ]); + if (simplifications.length === 0) { + return bodyText; + } + + const folded = applyReplacements(session.wrappedSource, simplifications); + return folded.slice(session.wrapperPrefix.length); +} diff --git a/packages/ts-optimizer/src/optimizer/prepare/flatten-destructures.ts b/packages/ts-optimizer/src/optimizer/prepare/flatten-destructures.ts new file mode 100644 index 00000000000..aa7af7f5ec8 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/prepare/flatten-destructures.ts @@ -0,0 +1,349 @@ +/** + * Flatten `const = useFoo()` destructures inside `component$` bodies as a + * pre-extraction code-size optimization. `const { store5 } = useForm2()` becomes `const form2 = + * useForm2()` and every `store5` reference rewrites to `form2.store5` (the new binding drops the + * `use` prefix and lowercases the first char). Only object-pattern destructures of a `use`-prefixed + * identifier callee are handled; array and member-init forms are left alone. + */ + +import MagicString from 'magic-string'; +import { walk } from 'oxc-walker'; +import type { AstNode, AstParentNode, AstProgram, CallExpression } from '../../ast-types.js'; + +type Substitution = { from: string; to: string }; + +export function flattenDestructureUseCalls( + source: string, + relPath: string, + program: AstProgram, + target?: MagicString +): { source: string; changed: boolean } { + // Sound prefilter: the walk only fires on a callee literally named + // `component$`, and that token appears verbatim at its source position, so + // a module whose text lacks it cannot match. + if (!source.includes('component$')) { + return { source, changed: false }; + } + + // Lazily materialize MagicString on first overwrite — most prefiltered + // modules still have no flattenable decls. + let s = target; + const edits = (): MagicString => (s ??= new MagicString(source)); + const decls: FlattenableDecl[] = []; + const subsByScope = new Map(); + + walk(program, { + enter(node: AstNode, parent: AstParentNode) { + // Manually iterating the body's top-level statements (rather than the + // walker's VariableDeclaration visits) keeps nested function-body decls + // from being flattened. + if ( + node.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + node.callee.name === 'component$' + ) { + collectAndApplyDeclsForComponentCall(node, edits, decls, subsByScope); + return; + } + + if (node.type !== 'Identifier' || !node.name) { + return; + } + if (decls.length === 0) { + return; + } + if (isDeclaringIdentifierPosition(node, parent)) { + return; + } + // Skip identifiers inside an already-overwritten decl pattern range — + // magic-string throws on splitting an edited chunk. Shorthand + // destructure values (`{url}`) resolve to the same Identifier and slip + // past the declaring-position check above. + for (const d of decls) { + if (node.start >= d.idStart && node.end <= d.idEnd) { + return; + } + } + // First scope match in source order wins (outermost scope governs). + for (const decl of decls) { + if (node.start < decl.scopeStart || node.end > decl.scopeEnd) { + continue; + } + const subs = subsByScope.get(decl.scopeStart); + if (!subs) { + continue; + } + const hit = subs.find((sub) => sub.from === node.name); + if (!hit) { + continue; + } + let replacement = hit.to; + if (isShorthandPropertyValue(node, parent)) { + replacement = `${node.name}: ${hit.to}`; + } + edits().overwrite(node.start, node.end, replacement); + return; + } + }, + }); + + if (s === undefined || !s.hasChanged()) { + return { source, changed: false }; + } + return { source: s.toString(), changed: true }; +} + +/** + * True when an Identifier sits in a declaring position (introducing a binding, an object-literal + * key, a label) rather than a reference — only references to flattened names get rewritten. + */ +function isDeclaringIdentifierPosition(node: AstNode, parent: AstParentNode): boolean { + if (!parent) { + return false; + } + switch (parent.type) { + case 'VariableDeclarator': + return parent.id === node; + case 'Property': + return parent.key === node && !parent.computed; + case 'MemberExpression': + return parent.property === node && !parent.computed; + case 'ImportSpecifier': + return parent.imported === node || parent.local === node; + case 'ImportDefaultSpecifier': + case 'ImportNamespaceSpecifier': + return parent.local === node; + case 'ExportSpecifier': + return parent.exported === node || parent.local === node; + case 'LabeledStatement': + case 'BreakStatement': + case 'ContinueStatement': + return parent.label === node; + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ClassDeclaration': + case 'ClassExpression': + return parent.id === node; + default: + return false; + } +} + +export function isShorthandPropertyValue(node: AstNode, parent: AstParentNode): boolean { + return parent?.type === 'Property' && parent.shorthand === true && parent.value === node; +} + +interface FlattenableDecl { + idStart: number; + idEnd: number; + scopeStart: number; + scopeEnd: number; + declStart: number; + declEnd: number; + newBinding: string; + fields: Array<{ localName: string; keyName: string }>; +} + +interface PatternFields { + fields: Array<{ localName: string; keyName: string }>; +} + +function extractPatternFields(id: AstNode): PatternFields | null { + const props = (id as unknown as { properties?: AstNode[] }).properties ?? []; + const fields: Array<{ localName: string; keyName: string }> = []; + for (const prop of props) { + if (prop.type !== 'Property') { + return null; + } + if (prop.computed) { + return null; + } + const key = prop.key; + const keyName = + key.type === 'Identifier' ? key.name : key.type === 'Literal' ? String(key.value) : null; + if (keyName === null) { + return null; + } + const val = prop.value; + if (val.type !== 'Identifier') { + return null; + } + fields.push({ localName: val.name, keyName }); + } + return fields.length > 0 ? { fields } : null; +} + +/** + * For each flattenable store-rooted declaration chain at the top level of a `component$(arrow)` + * body: the seed `const {…} = use*(…)[.path]` becomes `const = use*(…)`, and follow-up + * aliases (`const y = x`, `const b = y.bye`, `const {c} = b.z`) fold into the root member chain + * with their declarations removed — every reference rides the store object so subscriptions track + * the full path. + */ +function collectAndApplyDeclsForComponentCall( + callNode: CallExpression, + edits: () => MagicString, + decls: FlattenableDecl[], + subsByScope: Map +): void { + const arrow = callNode.arguments?.[0]; + if (!arrow || arrow.type !== 'ArrowFunctionExpression') { + return; + } + const body = arrow.body; + if (!body || body.type !== 'BlockStatement') { + return; + } + const scopeStart = body.start; + const scopeEnd = body.end; + + const localSubs = new Map(); + const pushSub = (from: string, to: string): void => { + localSubs.set(from, to); + const existing = subsByScope.get(scopeStart) ?? []; + subsByScope.set(scopeStart, [...existing, { from, to }]); + }; + + // Resolve an init to `{ callNode, suffix }` when it is a `use*()` call plus + // an optional non-computed member path. + const resolveSeedInit = (init: AstNode): { call: CallExpression; suffix: string } | null => { + let suffix = ''; + let current: AstNode = init; + while (current.type === 'MemberExpression') { + const member = current as unknown as { + computed?: boolean; + property: AstNode & { name?: string }; + object: AstNode; + }; + if (member.computed || member.property.type !== 'Identifier') { + return null; + } + suffix = `.${member.property.name}${suffix}`; + current = member.object; + } + if (current.type !== 'CallExpression') { + return null; + } + const call = current as CallExpression; + if (!call.callee || call.callee.type !== 'Identifier') { + return null; + } + const calleeName = call.callee.name; + if (!calleeName.startsWith('use') || calleeName.length <= 3) { + return null; + } + // Skip marker hooks (`use*$` / `use*Qrl`) — the qrl rewrite re-targets + // these, so flattening them here would conflict. + if (calleeName.endsWith('$') || calleeName.endsWith('Qrl')) { + return null; + } + return { call, suffix }; + }; + + // Resolve an init already rooted at a tracked local to a substituted path. + const resolveTrackedInit = (init: AstNode): string | null => { + let suffix = ''; + let current: AstNode = init; + while (current.type === 'MemberExpression') { + const member = current as unknown as { + computed?: boolean; + property: AstNode & { name?: string }; + object: AstNode; + }; + if (member.computed || member.property.type !== 'Identifier') { + return null; + } + suffix = `.${member.property.name}${suffix}`; + current = member.object; + } + if (current.type !== 'Identifier') { + return null; + } + const root = localSubs.get((current as unknown as { name: string }).name); + if (root === undefined) { + return null; + } + return root + suffix; + }; + + const removeStmt = (stmt: AstNode): void => { + edits().remove(stmt.start, stmt.end); + decls.push({ + idStart: stmt.start, + idEnd: stmt.end, + scopeStart, + scopeEnd, + declStart: stmt.start, + declEnd: stmt.end, + newBinding: '', + fields: [], + }); + }; + + for (const stmt of body.body ?? []) { + if (stmt.type !== 'VariableDeclaration' || stmt.kind !== 'const') { + continue; + } + if ((stmt.declarations ?? []).length !== 1) { + continue; + } + const declarator = stmt.declarations[0]; + const init = declarator.init; + const id = declarator.id; + if (!init) { + continue; + } + + const seed = id.type === 'ObjectPattern' ? resolveSeedInit(init) : null; + if (seed) { + const pattern = extractPatternFields(id); + if (!pattern) { + continue; + } + const calleeName = (seed.call.callee as unknown as { name: string }).name; + const newBinding = `${calleeName.slice(3, 4).toLowerCase()}${calleeName.slice(4)}`; + const decl: FlattenableDecl = { + idStart: id.start, + idEnd: id.end, + scopeStart, + scopeEnd, + declStart: stmt.start, + declEnd: stmt.end, + newBinding, + fields: pattern.fields, + }; + decls.push(decl); + edits().overwrite(decl.idStart, decl.idEnd, newBinding); + // Move the member suffix off the declaration into the references. + if (seed.suffix !== '') { + edits().remove(seed.call.end, init.end); + } + for (const field of pattern.fields) { + pushSub(field.localName, `${newBinding}${seed.suffix}.${field.keyName}`); + } + continue; + } + + if (localSubs.size === 0) { + continue; + } + const resolved = resolveTrackedInit(init); + if (resolved === null) { + continue; + } + + if (id.type === 'Identifier') { + pushSub((id as unknown as { name: string }).name, resolved); + removeStmt(stmt); + } else if (id.type === 'ObjectPattern') { + const pattern = extractPatternFields(id); + if (!pattern) { + continue; + } + for (const field of pattern.fields) { + pushSub(field.localName, `${resolved}.${field.keyName}`); + } + removeStmt(stmt); + } + } +} diff --git a/packages/ts-optimizer/src/optimizer/prepare/inline-component-props.ts b/packages/ts-optimizer/src/optimizer/prepare/inline-component-props.ts new file mode 100644 index 00000000000..df472eba875 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/prepare/inline-component-props.ts @@ -0,0 +1,262 @@ +/** + * Pre-extraction normalization of inline (lightweight) components: a module-level ARROW component + * with a destructured props param becomes `(_rawProps) => …` with field references rewritten to + * `_rawProps.`, matching the Rust optimizer. Function declarations keep their signature. + * Running before extraction means captures, fnSignal roots, and segment bodies all see the + * normalized form. + */ + +import type { AstNode, AstProgram } from '../../ast-types.js'; +import MagicString from 'magic-string'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { forEachAstChild } from '../ast/guards.js'; +import { applyReplacements } from '../edit/range-replace.js'; +import { isNonReferenceIdentifier } from '../analysis/variable-migration.js'; + +interface Replacement { + start: number; + end: number; + replacement: string; +} + +interface NormalizeResult { + readonly changed: boolean; + readonly source: string; +} + +function containsJsx(node: AstNode): boolean { + let found = false; + const visit = (n: AstNode | null | undefined): void => { + if (!n || found) { + return; + } + if (n.type === 'JSXElement' || n.type === 'JSXFragment') { + found = true; + return; + } + forEachAstChild(n, (child) => visit(child as AstNode)); + }; + visit(node); + return found; +} + +/** Key → local name, or null when the pattern has unsupported shapes. */ +function extractSimpleFieldMap(pattern: AstNode): Map | null { + const props = (pattern as unknown as { properties?: AstNode[] }).properties; + if (!props) { + return null; + } + const localToKey = new Map(); + for (const prop of props) { + const p = prop as unknown as { + type: string; + computed?: boolean; + key?: { type: string; name?: string; value?: unknown }; + value?: { type: string; name?: string }; + shorthand?: boolean; + }; + if (p.type !== 'Property' && p.type !== 'BindingProperty') { + return null; + } + if (p.computed) { + return null; + } + const keyName = + p.key?.type === 'Identifier' + ? p.key.name + : typeof p.key?.value === 'string' + ? (p.key.value as string) + : undefined; + if (!keyName) { + return null; + } + if (p.value?.type !== 'Identifier' || !p.value.name) { + return null; + } + localToKey.set(p.value.name, keyName); + } + return localToKey.size > 0 ? localToKey : null; +} + +function unwrapParenthesized(node: AstNode | null | undefined): AstNode | null { + let current = node ?? null; + while (current && current.type === 'ParenthesizedExpression') { + current = (current as unknown as { expression: AstNode }).expression; + } + return current; +} + +/** Module-level arrow components (default export or const init, not marker-call args). */ +function collectInlineArrowComponents(program: AstProgram): AstNode[] { + const arrows: AstNode[] = []; + const consider = (candidate: AstNode | null | undefined): void => { + const node = unwrapParenthesized(candidate); + if (node?.type === 'ArrowFunctionExpression') { + arrows.push(node); + } + }; + for (const stmt of program.body) { + if (stmt.type === 'ExportDefaultDeclaration') { + consider((stmt as unknown as { declaration: AstNode }).declaration); + continue; + } + const varDecl = + stmt.type === 'VariableDeclaration' + ? stmt + : stmt.type === 'ExportNamedDeclaration' && + (stmt as unknown as { declaration?: AstNode }).declaration?.type === + 'VariableDeclaration' + ? (stmt as unknown as { declaration: AstNode }).declaration + : null; + if (!varDecl) { + continue; + } + for (const decl of (varDecl as unknown as { declarations: AstNode[] }).declarations) { + consider((decl as unknown as { init?: AstNode }).init); + } + } + return arrows; +} + +export function normalizeInlineComponentProps( + code: string, + filename: string, + preParsedProgram?: AstProgram, + target?: MagicString +): NormalizeResult { + let program: AstProgram; + if (preParsedProgram) { + program = preParsedProgram; + } else { + try { + program = parseWithRawTransfer(filename, code).program; + } catch { + return { changed: false, source: code }; + } + } + + const replacements: Replacement[] = []; + + for (const arrow of collectInlineArrowComponents(program)) { + const params = (arrow as unknown as { params: AstNode[] }).params; + const body = (arrow as unknown as { body: AstNode }).body; + if (!params || params.length === 0 || !body) { + continue; + } + if (!containsJsx(body)) { + continue; + } + const removedRanges: Array<{ start: number; end: number }> = []; + const pattern = params[0]; + + let localToKey: Map | null = null; + let propsName = '_rawProps'; + + if (pattern.type === 'ObjectPattern') { + localToKey = extractSimpleFieldMap(pattern); + if (!localToKey) { + continue; + } + const typeAnnotation = (pattern as unknown as { typeAnnotation?: { end: number } }) + .typeAnnotation; + replacements.push({ + start: pattern.start, + end: typeAnnotation?.end ?? pattern.end, + replacement: '_rawProps', + }); + } else if (pattern.type === 'Identifier') { + // `(props) => { const { data } = props; … }` — fold the body destructure + // so field access rides the props object directly. + propsName = (pattern as unknown as { name: string }).name; + const blockBody = + body.type === 'BlockStatement' ? ((body as unknown as { body: AstNode[] }).body ?? []) : []; + for (const stmt of blockBody) { + if (stmt.type !== 'VariableDeclaration') { + continue; + } + const decls = (stmt as unknown as { declarations: AstNode[] }).declarations; + if (decls.length !== 1) { + continue; + } + const decl = decls[0] as unknown as { id: AstNode; init?: AstNode }; + if (decl.id?.type !== 'ObjectPattern') { + continue; + } + if ( + decl.init?.type !== 'Identifier' || + (decl.init as unknown as { name: string }).name !== propsName + ) { + continue; + } + localToKey = extractSimpleFieldMap(decl.id); + if (!localToKey) { + continue; + } + let end = stmt.end; + if (end < code.length && code[end] === '\n') { + end++; + } + replacements.push({ start: stmt.start, end, replacement: '' }); + removedRanges.push({ start: stmt.start, end }); + break; + } + if (!localToKey) { + continue; + } + } else { + continue; + } + + // Rewrite reference positions; bail out of a name on shadowing redeclarations. + const shadowed = new Set(); + const collectDecl = (n: AstNode | null | undefined): void => { + if (!n) { + return; + } + if ( + (n.type === 'VariableDeclarator' || n.type === 'FunctionDeclaration') && + (n as unknown as { id?: { type: string; name?: string } }).id?.type === 'Identifier' + ) { + const name = (n as unknown as { id: { name: string } }).id.name; + if (localToKey.has(name)) { + shadowed.add(name); + } + } + forEachAstChild(n, (child) => collectDecl(child as AstNode)); + }; + collectDecl(body); + + const visit = (n: AstNode | null | undefined, parent: AstNode | null): void => { + if (!n) { + return; + } + if (n.type.startsWith('TS')) { + return; + } + if (removedRanges.some((r) => n.start >= r.start && n.end <= r.end)) { + return; + } + if (n.type === 'Identifier') { + const name = (n as unknown as { name: string }).name; + const key = localToKey!.get(name); + if (key !== undefined && !shadowed.has(name) && !isNonReferenceIdentifier(n, parent)) { + replacements.push({ start: n.start, end: n.end, replacement: `${propsName}.${key}` }); + } + return; + } + forEachAstChild(n, (child) => visit(child as AstNode, n)); + }; + visit(body, arrow); + } + + if (replacements.length === 0) { + return { changed: false, source: code }; + } + if (target) { + for (const replacement of replacements) { + target.overwrite(replacement.start, replacement.end, replacement.replacement); + } + return { changed: true, source: target.toString() }; + } + return { changed: true, source: applyReplacements(code, replacements) }; +} diff --git a/packages/ts-optimizer/src/optimizer/qwik/event-attrs.ts b/packages/ts-optimizer/src/optimizer/qwik/event-attrs.ts new file mode 100644 index 00000000000..933b8cbc49e --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/qwik/event-attrs.ts @@ -0,0 +1,22 @@ +/** Rewritten event-handler prefixes: `e`/`ep` element, `d`/`dp` document, `w`/`wp` window. */ +export const REWRITTEN_EVENT_PREFIXES = [ + 'q-e:', + 'q-ep:', + 'q-dp:', + 'q-wp:', + 'q-d:', + 'q-w:', +] as const; + +export function startsWithRewrittenEventPrefix(name: string): boolean { + for (const prefix of REWRITTEN_EVENT_PREFIXES) { + if (name.startsWith(prefix)) { + return true; + } + } + return false; +} + +export function isEventAttributeName(name: string): boolean { + return name.endsWith('$') || startsWithRewrittenEventPrefix(name); +} diff --git a/packages/ts-optimizer/src/optimizer/qwik/qrl-naming.ts b/packages/ts-optimizer/src/optimizer/qwik/qrl-naming.ts new file mode 100644 index 00000000000..3e1f3cae7db --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/qwik/qrl-naming.ts @@ -0,0 +1,22 @@ +export function plainQrlName(markerName: string): string { + return markerName.slice(0, -1) + 'Qrl'; +} + +export function getQrlCalleeName(markerName: string): string { + if (markerName === '$') { + return ''; + } + if (markerName === 'sync$') { + return '_qrlSync'; + } + return plainQrlName(markerName); +} + +/** + * A `$`-suffixed marker other than the bare `$`. Lib mode preserves these imports so downstream + * consumers can re-import the markers; the bare `$` has no marker semantics after extraction and is + * dropped. + */ +export function isLibModePreservedMarker(importedName: string): boolean { + return importedName.length > 1 && importedName.endsWith('$'); +} diff --git a/packages/ts-optimizer/src/optimizer/qwik/qwik-packages.ts b/packages/ts-optimizer/src/optimizer/qwik/qwik-packages.ts new file mode 100644 index 00000000000..ce425eede30 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/qwik/qwik-packages.ts @@ -0,0 +1,14 @@ +const QWIK_PACKAGE_PREFIXES = [ + '@qwik.dev/core', + '@qwik.dev/react', + '@qwik.dev/router', + '@builder.io/qwik-react', + '@builder.io/qwik-city', + '@builder.io/qwik', +]; + +export function isQwikPackageSource(source: string): boolean { + return QWIK_PACKAGE_PREFIXES.some( + (prefix) => source === prefix || source.startsWith(prefix + '/') + ); +} diff --git a/packages/ts-optimizer/src/optimizer/qwik/w-call.ts b/packages/ts-optimizer/src/optimizer/qwik/w-call.ts new file mode 100644 index 00000000000..1f9d03c85f1 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/qwik/w-call.ts @@ -0,0 +1,87 @@ +/** + * Builders for the `.w([captures])` "with-captures" call appended to a QRL reference so the runtime + * injects the bound values into the segment. The capture list is laid out one-per-line with + * symmetric indentation. + */ + +import type { AstNode } from '../../ast-types.js'; + +export function isCaptureWrappingQrlCall(node: AstNode): boolean { + if (node.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + return ( + callee?.type === 'MemberExpression' && + callee.object?.type === 'Identifier' && + callee.object.name.startsWith('q_') && + callee.property?.type === 'Identifier' && + callee.property.name === 'w' + ); +} + +export function wCallSuffix( + captures: readonly string[], + innerIndent: string, + closeIndent: string +): string { + return `.w([\n${innerIndent}${captures.join(',\n' + innerIndent)}\n${closeIndent}])`; +} + +export function formatWCall( + qrlVar: string, + captures: readonly string[], + innerIndent: string, + closeIndent: string +): string { + return qrlVar + wCallSuffix(captures, innerIndent, closeIndent); +} + +/** + * Split a bracketed array-literal string into top-level element texts. Commas inside nested + * `()`/`[]`/`{}` or string literals don't split, so a capture that is itself a call survives + * intact. + */ +export function parseArrayItems(arrayText: string): string[] { + let inner = arrayText.trim(); + if (inner.startsWith('[')) { + inner = inner.slice(1); + } + if (inner.endsWith(']')) { + inner = inner.slice(0, -1); + } + + const items: string[] = []; + let depth = 0; + let start = 0; + let quote: string | null = null; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (quote !== null) { + if (ch === '\\') { + i++; + } else if (ch === quote) { + quote = null; + } + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { + quote = ch; + } else if (ch === '(' || ch === '[' || ch === '{') { + depth++; + } else if (ch === ')' || ch === ']' || ch === '}') { + depth--; + } else if (ch === ',' && depth === 0) { + const item = inner.slice(start, i).trim(); + if (item.length > 0) { + items.push(item); + } + start = i + 1; + } + } + const last = inner.slice(start).trim(); + if (last.length > 0) { + items.push(last); + } + return items; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/const-propagation.ts b/packages/ts-optimizer/src/optimizer/rewrite/const-propagation.ts new file mode 100644 index 00000000000..c15acbf9c78 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/const-propagation.ts @@ -0,0 +1,611 @@ +/** Const literal propagation and inlining utilities. */ + +import { forEachAstChild } from '../ast/guards.js'; +import { applyReplacements, isReplaceableIdentifierPosition } from '../edit/range-replace.js'; +import { isShorthandPropertyValue } from '../prepare/flatten-destructures.js'; +import { createTransformSession } from '../edit/transform-session.js'; +import { type AstFunction, type AstNode, type Expression } from '../../ast-types.js'; + +function collectConstLiteralValues( + root: AstNode | null | undefined, + source: string, + offset: number, + captureSet: Set, + result: Map +): void { + function walkNode(node: AstNode | null | undefined): void { + if (!node) { + return; + } + + if (node.type !== 'VariableDeclaration' || node.kind !== 'const') { + forEachAstChild(node, (child) => walkNode(child)); + return; + } + + for (const decl of node.declarations) { + if (decl.id.type !== 'Identifier' || !captureSet.has(decl.id.name) || !decl.init) { + continue; + } + const init = decl.init; + if (!isLiteralNode(init)) { + continue; + } + const literalStart = init.start - offset; + const literalEnd = init.end - offset; + if (literalStart >= 0 && literalEnd <= source.length) { + result.set(decl.id.name, source.slice(literalStart, literalEnd)); + } + } + + forEachAstChild(node, (child) => walkNode(child)); + } + + walkNode(root); +} + +export function resolveConstLiterals( + parentBody: string, + captureNames: string[] +): Map { + const result = new Map(); + if (captureNames.length === 0) { + return result; + } + + const session = createTransformSession(parentBody); + if (!session) { + return result; + } + + collectConstLiteralValues( + session.program, + parentBody, + session.offset, + new Set(captureNames), + result + ); + return result; +} + +/** + * Walks an already-parsed closure node directly. `init.start`/`init.end` on each node are + * source-absolute offsets into `source`, not body-relative. + */ +export function resolveConstLiteralsInClosure( + closureNode: AstFunction, + source: string, + captureNames: string[] +): Map { + const result = new Map(); + if (captureNames.length === 0) { + return result; + } + if (!closureNode.body) { + return result; + } + + collectConstLiteralValues(closureNode.body, source, 0, new Set(captureNames), result); + return result; +} + +/** AST-based (not textual) so property names sharing a captured identifier's name are not replaced. */ +export function inlineConstCaptures(body: string, constValues: Map): string { + const session = createTransformSession(body); + if (!session) { + return body; + } + + const offset = session.offset; + const replacements: Array<{ start: number; end: number; replacement: string }> = []; + + function walkNode( + node: AstNode | null | undefined, + parentKey?: string, + parentNode?: AstNode + ): void { + if (!node) { + return; + } + + if (node.type === 'Identifier' && constValues.has(node.name)) { + if (isReplaceableIdentifierPosition(parentKey, parentNode)) { + replacements.push({ + start: node.start - offset, + end: node.end - offset, + replacement: constValues.get(node.name)!, + }); + } + } + + forEachAstChild(node, (child, key, parent) => { + walkNode(child, key, parent); + }); + } + + walkNode(session.program); + + return applyReplacements(body, replacements); +} + +interface ConstDecl { + name: string; + initText: string; + initNode: Expression; + stmtStart: number; + stmtEnd: number; + isLiteral: boolean; + isSideEffectFree: boolean; + initRefersTo: string[]; +} + +interface IdentRef { + name: string; + start: number; + end: number; + insideDeclOf: string | null; + shorthandKey: string | null; +} + +function isSimpleSideEffectFree(node: AstNode | null | undefined): boolean { + if (!node) { + return false; + } + switch (node.type) { + case 'Identifier': + return !node.name.startsWith('_'); + case 'Literal': + return true; + case 'MemberExpression': + return isSimpleSideEffectFree(node.object); + default: + return false; + } +} + +function isLiteralNode(node: AstNode): boolean { + return node.type === 'Literal'; +} + +/** + * Resolve a whole-body identifier segment (`$(render)`, `useStyles$(style)`) to its const init's + * source text, matching Rust's static-expression inlining. Only fires when the init is a + * serializable expression (literal, template, function) whose free identifiers are not locals of + * the enclosing scope — those would need real captures. + */ +export function resolveWholeBodyIdentifier( + scopeNode: AstNode, + source: string, + name: string +): { text: string; start: number; end: number } | null { + let initText: string | null = null; + let initNode: AstNode | null = null; + + const localBindings = new Set(); + const visit = (node: AstNode | null | undefined): void => { + if (!node) { + return; + } + if (node.type === 'VariableDeclaration') { + for (const decl of node.declarations) { + if (decl.id.type === 'Identifier') { + localBindings.add(decl.id.name); + if (decl.id.name === name && decl.init && node.kind === 'const') { + initNode = decl.init as AstNode; + initText = source.slice(decl.init.start, decl.init.end); + } + } + } + } else if (node.type === 'FunctionDeclaration' && node.id) { + localBindings.add(node.id.name); + } + forEachAstChild(node, (child) => visit(child)); + }; + visit(scopeNode); + + if (initText === null || initNode === null) { + return null; + } + const init = initNode as AstNode; + const serializable = + init.type === 'Literal' || + init.type === 'TemplateLiteral' || + init.type === 'ArrowFunctionExpression' || + init.type === 'FunctionExpression'; + if (!serializable) { + return null; + } + + for (const ident of collectIdentifiers(init)) { + if (ident !== name && localBindings.has(ident)) { + return null; + } + } + return { text: initText, start: init.start, end: init.end }; +} + +function collectIdentifiers(node: AstNode): string[] { + const ids: string[] = []; + function walk(n: AstNode | null | undefined): void { + if (!n) { + return; + } + if (n.type === 'Identifier') { + ids.push(n.name); + return; + } + forEachAstChild(n, (child) => walk(child)); + } + walk(node); + return ids; +} + +function memberRootName(member: AstNode): string | null { + let obj: AstNode | null | undefined = member.type === 'MemberExpression' ? member.object : member; + while (obj?.type === 'MemberExpression') { + obj = obj.object; + } + return obj?.type === 'Identifier' ? obj.name : null; +} + +/** + * Inlining a member read of a mutated object is unsound: `const i = ctx.n` before `ctx.n++` must + * not fold into `return ctx.n` after it. + */ +function readsMutatedObject(node: AstNode, mutatedObjects: ReadonlySet): boolean { + if (mutatedObjects.size === 0) { + return false; + } + let found = false; + function walk(n: AstNode | null | undefined): void { + if (!n || found) { + return; + } + if (n.type === 'MemberExpression') { + const root = memberRootName(n); + if (root && mutatedObjects.has(root)) { + found = true; + return; + } + } + forEachAstChild(n, (child) => walk(child)); + } + walk(node); + return found; +} + +/** Textual, conservative: a false positive only skips an optimization. */ +function bodyHasAwaitBetween(body: string, from: number, to: number): boolean { + if (to <= from) { + return false; + } + return /\b(await|yield)\b/.test(body.slice(from, to)); +} + +export function propagateConstLiteralsInBody(body: string): string { + const session = createTransformSession(body); + if (!session) { + return body; + } + + const offset = session.offset; + + const constDecls = new Map(); + const identRefs: IdentRef[] = []; + const mutableVars = new Set(); + const mutatedObjects = new Set(); + // Identifiers inside `q_X.w([...])` capture arrays — serialization + // contracts aligned with `_captures[N]` reads; never inline or remove. + const protectedNames = new Set(); + + let currentDeclName: string | null = null; + + function walkCollect( + node: AstNode | null | undefined, + parentKey?: string, + parentNode?: AstNode + ): void { + if (!node) { + return; + } + + if (node.type === 'VariableDeclaration' && (node.kind === 'let' || node.kind === 'var')) { + for (const decl of node.declarations) { + if (decl.id.type === 'Identifier') { + mutableVars.add(decl.id.name); + } + } + } + + if (node.type === 'UpdateExpression' && node.argument?.type === 'MemberExpression') { + const root = memberRootName(node.argument); + if (root) { + mutatedObjects.add(root); + } + } + + // A `q_X.w([...])` capture array is a serialization contract aligned with + // the segment's `_captures[N]` reads — its identifiers must stay verbatim. + if ( + node.type === 'CallExpression' && + (node.callee as { type?: string; property?: { type?: string; name?: string } })?.type === + 'MemberExpression' && + (node.callee as { property?: { type?: string; name?: string } }).property?.type === + 'Identifier' && + (node.callee as { property: { name: string } }).property.name === 'w' + ) { + const firstArg = (node as { arguments?: AstNode[] }).arguments?.[0]; + if (firstArg?.type === 'ArrayExpression') { + for (const el of (firstArg as { elements?: Array }).elements ?? []) { + if (el?.type === 'Identifier') { + protectedNames.add(el.name); + } + } + } + } + if (node.type === 'AssignmentExpression' && node.left?.type === 'MemberExpression') { + const root = memberRootName(node.left); + if (root) { + mutatedObjects.add(root); + } + } + + if ( + node.type === 'VariableDeclaration' && + node.kind === 'const' && + node.declarations.length === 1 + ) { + const decl = node.declarations[0]; + if (decl.id.type === 'Identifier' && decl.init) { + const init = decl.init; + const initStart = init.start - offset; + const initEnd = init.end - offset; + const stmtStart = node.start - offset; + const stmtEnd = node.end - offset; + + if (initStart >= 0 && initEnd <= body.length && stmtStart >= 0) { + constDecls.set(decl.id.name, { + name: decl.id.name, + initText: body.slice(initStart, initEnd), + initNode: init, + stmtStart, + stmtEnd, + isLiteral: isLiteralNode(init), + isSideEffectFree: isSimpleSideEffectFree(init), + initRefersTo: collectIdentifiers(init), + }); + + collectReferencesInInitializer(init, decl, decl.id.name); + return; + } + } + } + + // A JSX tag reference (``) can't take an inlined expression — + // any const it names must keep its declaration. + if (node.type === 'JSXIdentifier' && typeof node.name === 'string') { + protectedNames.add(node.name); + } + + if (node.type === 'Identifier' && isReplaceableIdentifierPosition(parentKey, parentNode)) { + const refStart = node.start - offset; + const refEnd = node.end - offset; + if (refStart >= 0 && refEnd <= body.length) { + identRefs.push({ + name: node.name, + start: refStart, + end: refEnd, + insideDeclOf: currentDeclName, + shorthandKey: isShorthandPropertyValue(node, parentNode ?? null) ? node.name : null, + }); + } + } + + forEachAstChild(node, (child, key, parent) => { + walkCollect(child, key, parent); + }); + } + + function collectReferencesInInitializer( + init: AstNode, + declNode: AstNode, + declName: string + ): void { + const savedDeclName = currentDeclName; + currentDeclName = declName; + walkCollect(init, 'init', declNode); + currentDeclName = savedDeclName; + } + + walkCollect(session.program); + + if (constDecls.size === 0) { + return body; + } + + const resolvedValues = new Map(); + + function resolveValue(name: string, visited: Set): string | null { + if (resolvedValues.has(name)) { + return resolvedValues.get(name)!; + } + if (visited.has(name)) { + return null; + } + visited.add(name); + + const decl = constDecls.get(name); + if (!decl) { + return null; + } + + if (decl.isLiteral) { + resolvedValues.set(name, decl.initText); + return decl.initText; + } + + if (decl.initNode?.type === 'Identifier' && decl.initRefersTo.length === 1) { + const target = decl.initRefersTo[0]; + const resolved = resolveValue(target, visited); + if (resolved !== null) { + resolvedValues.set(name, resolved); + return resolved; + } + } + + return null; + } + + for (const name of constDecls.keys()) { + resolveValue(name, new Set()); + } + for (const name of protectedNames) { + resolvedValues.delete(name); + } + + const toRemove = new Set(); + + for (const [name] of resolvedValues) { + toRemove.add(name); + } + + // External ref counts depend on which consts are removed, so count in two passes. + function countExternalRefs(removedSet: Set): Map { + const counts = new Map(); + for (const ref of identRefs) { + if (!constDecls.has(ref.name)) { + continue; + } + if (ref.insideDeclOf !== null && removedSet.has(ref.insideDeclOf)) { + continue; + } + counts.set(ref.name, (counts.get(ref.name) ?? 0) + 1); + } + return counts; + } + + let externalRefCounts = countExternalRefs(toRemove); + + for (const [name, decl] of constDecls) { + if (resolvedValues.has(name)) { + continue; + } + if (protectedNames.has(name)) { + continue; + } + if (!decl.isSideEffectFree) { + continue; + } + if (decl.isLiteral) { + continue; + } + + const referencesMutable = decl.initRefersTo.some((id) => mutableVars.has(id)); + if (referencesMutable) { + continue; + } + + if (readsMutatedObject(decl.initNode, mutatedObjects)) { + continue; + } + + const refs = externalRefCounts.get(name) ?? 0; + if (refs <= 1) { + // A member read is position-sensitive: signal/store reads are tracked, + // and moving one across an await leaves the invoke context. Keep the + // decl when its single use sits past an await. + if (refs === 1 && decl.initNode.type === 'MemberExpression') { + const ref = identRefs.find((r) => r.name === name && r.insideDeclOf === null); + if (ref && bodyHasAwaitBetween(body, decl.stmtEnd, ref.start)) { + continue; + } + } + toRemove.add(name); + } + } + + externalRefCounts = countExternalRefs(toRemove); + + const toInline = new Map(); + + for (const [name, value] of resolvedValues) { + const refs = externalRefCounts.get(name) ?? 0; + if (refs > 0) { + toInline.set(name, value); + } + } + + for (const [name, decl] of constDecls) { + if (resolvedValues.has(name)) { + continue; + } + if (!toRemove.has(name)) { + continue; + } + const refs = externalRefCounts.get(name) ?? 0; + if (refs === 1) { + let initText = decl.initText; + const refsInInit = identRefs.filter( + (r) => r.insideDeclOf === name && resolvedValues.has(r.name) + ); + if (refsInInit.length > 0) { + // Ref positions are body-relative; initText starts at the decl's init position. + const initOffset = decl.initNode.start - offset; + const initReplacements = refsInInit + .map((r) => ({ + start: r.start - initOffset, + end: r.end - initOffset, + replacement: resolvedValues.get(r.name)!, + })) + .filter((rep) => rep.start >= 0 && rep.end <= initText.length); + initText = applyReplacements(initText, initReplacements); + } + toInline.set(name, initText); + } + } + + if (toInline.size === 0 && toRemove.size === 0) { + return body; + } + + const edits: Array<{ start: number; end: number; replacement: string }> = []; + + for (const ref of identRefs) { + if (!toInline.has(ref.name)) { + continue; + } + if (ref.insideDeclOf !== null && toRemove.has(ref.insideDeclOf)) { + continue; + } + const value = toInline.get(ref.name)!; + // Re-emit the key when inlining into a shorthand `{ x }`, else the object is invalid. + const replacement = ref.shorthandKey !== null ? `${ref.shorthandKey}: ${value}` : value; + edits.push({ + start: ref.start, + end: ref.end, + replacement, + }); + } + + for (const name of toRemove) { + const decl = constDecls.get(name)!; + let start = decl.stmtStart; + let end = decl.stmtEnd; + while (end < body.length && (body[end] === ';' || body[end] === ' ' || body[end] === '\t')) { + end++; + } + if (end < body.length && body[end] === '\n') { + end++; + } + while (start > 0 && (body[start - 1] === ' ' || body[start - 1] === '\t')) { + start--; + } + edits.push({ + start, + end, + replacement: '', + }); + } + + return applyReplacements(body, edits); +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/const-replacement.ts b/packages/ts-optimizer/src/optimizer/rewrite/const-replacement.ts new file mode 100644 index 00000000000..db4248943d0 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/const-replacement.ts @@ -0,0 +1,182 @@ +/** + * Replaces isServer/isBrowser/isDev identifiers imported from Qwik packages with boolean literals + * based on build configuration. + */ + +import MagicString from 'magic-string'; +import { parseSync } from 'oxc-parser'; +import { walk } from 'oxc-walker'; +import { ScopeQueryTracker } from '../analysis/scope-query-tracker.js'; +import type { AstNode, AstParentNode, AstProgram } from '../../ast-types.js'; +import { RAW_TRANSFER_PARSER_OPTIONS } from '../../ast-types.js'; +import type { EmitMode } from '../types/types.js'; +import type { ImportInfo } from '../extraction/marker-detection.js'; + +const CONST_SOURCES = [ + '@qwik.dev/core', + '@qwik.dev/core/build', + '@builder.io/qwik', + '@builder.io/qwik/build', + '@builder.io/qwik-city/build', +]; + +function isConstSource(source: string): boolean { + return CONST_SOURCES.includes(source); +} + +export function deriveIsDev(mode: EmitMode | undefined): boolean | undefined { + if (mode === 'dev' || mode === 'hmr') { + return true; + } + if (mode === 'prod') { + return false; + } + return undefined; +} + +export function buildConstReplacementMap( + importMap: Map, + isServer?: boolean, + isDev?: boolean +): Map { + const replacements = new Map(); + + for (const [localName, info] of importMap) { + if (!isConstSource(info.source)) { + continue; + } + + const { importedName } = info; + + if (isServer !== undefined) { + if (importedName === 'isServer') { + replacements.set(localName, String(isServer)); + } else if (importedName === 'isBrowser') { + replacements.set(localName, String(!isServer)); + } + } + + if (isDev !== undefined && importedName === 'isDev') { + replacements.set(localName, String(isDev)); + } + } + + return replacements; +} + +/** + * Scope-aware identifier→literal replacement: a local binding (param, let, catch) shadowing the + * folded name keeps both the binding and its references untouched. + */ +export function replaceResolvedConstIdentifiers( + s: MagicString, + program: AstProgram, + replacements: ReadonlyMap, + importRanges?: ReadonlySet +): number { + const tracker = new ScopeQueryTracker({ preserveExitedScopes: true }); + walk(program, { scopeTracker: tracker }); + tracker.freeze(); + + let replacedCount = 0; + const expandedShorthands = new Set(); + walk(program, { + scopeTracker: tracker, + enter(node: AstNode, parent: AstParentNode) { + if (node.type !== 'Identifier') { + return; + } + + const replacement = replacements.get(node.name); + if (replacement === undefined) { + return; + } + + if (importRanges?.has(`${node.start}:${node.end}`)) { + return; + } + if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) { + return; + } + if (parent?.type === 'VariableDeclarator' && parent.id === node) { + return; + } + if (parent?.type === 'ImportSpecifier' && parent.imported === node) { + return; + } + const decl = tracker.getDeclaration(node.name); + if (decl && decl.type !== 'Import') { + return; + } + // Key/value of a shorthand property are distinct nodes sharing a span, + // so compare spans, and expand only once per property. + if ( + parent?.type === 'Property' && + !parent.computed && + parent.key?.start === node.start && + parent.key?.end === node.end + ) { + // `{ isServer }` means `{ isServer: isServer }` — keep the key, + // replace only the value. A plain key is not a reference at all. + if (parent.shorthand && !expandedShorthands.has(node.start)) { + expandedShorthands.add(node.start); + s.overwrite(node.start, node.end, `${node.name}: ${replacement}`); + replacedCount++; + } + return; + } + + s.overwrite(node.start, node.end, replacement); + replacedCount++; + }, + }); + + return replacedCount; +} + +/** + * Fold isServer/isBrowser/isDev in an inline/hoist body. These bodies are re-emitted outside the + * parent MagicString, so `replaceConstants` never reaches them. + */ +export function foldConstantsInBodyText( + body: string, + importMap: Map, + isServer?: boolean, + isDev?: boolean +): string { + const replacements = buildConstReplacementMap(importMap, isServer, isDev); + if (replacements.size === 0) { + return body; + } + + let mentionsCandidate = false; + for (const name of replacements.keys()) { + if (body.includes(name)) { + mentionsCandidate = true; + break; + } + } + if (!mentionsCandidate) { + return body; + } + + // Parenthesise so the arrow/function body parses as an expression. + const wrapped = `(${body})`; + let parsed; + try { + parsed = parseSync('__const_fold__.tsx', wrapped, RAW_TRANSFER_PARSER_OPTIONS); + } catch { + return body; + } + if (!parsed.program || parsed.errors?.length) { + return body; + } + + const s = new MagicString(wrapped); + const count = replaceResolvedConstIdentifiers(s, parsed.program, replacements); + if (count === 0) { + return body; + } + + return s.toString().slice(1, -1); +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/index.ts b/packages/ts-optimizer/src/optimizer/rewrite/index.ts new file mode 100644 index 00000000000..68269b0d3b3 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/index.ts @@ -0,0 +1,1188 @@ +/** + * Parent module rewriting engine. + * + * Output structure: [optimizer-added imports] [original non-marker imports] // [QRL const + * declarations] // [rewritten module body] [_auto_ exports if any] + */ + +import MagicString from 'magic-string'; +import { parseSync } from 'oxc-parser'; +import { walk } from 'oxc-walker'; +import type { ConsolidatedSegment, ExtractionResult, Mutable } from '../extraction/extract.js'; +import type { ImportInfo } from '../extraction/marker-detection.js'; +import { + autoExportedNames, + type MigrationDecision, + type ModuleLevelDecl, +} from '../analysis/variable-migration.js'; +import type { RelativePath } from '../types/brands.js'; +import { rewriteImportSource } from './rewrite-imports.js'; +import { buildSyncTransform, isWorkerExtraction, needsPureAnnotation } from './rewrite-calls.js'; +import { getQrlCalleeName, isLibModePreservedMarker } from '../qwik/qrl-naming.js'; +import { + inlineSentinelStep, + isEventHandlerOrJsxProp, + isStrippedExtraction, + matchesRegCtxName, +} from './predicates.js'; +import { transformEventPropName } from '../jsx/event-handlers.js'; +import { transformAllJsx, JsxKeyCounter, type ScopeAwareCollectResult } from '../jsx/jsx.js'; +import { computeKeyPrefix } from '../jsx/key-prefix.js'; +import { eventHandlerQpParams } from '../jsx/loop-hoisting.js'; +import { + collectJsxFunctionNamesFromIterable, + transformJsxCalls, +} from '../jsx/jsx-call-transform.js'; +import { stripExportDeclarations } from './strip-exports.js'; +import type { EmitMode } from '../types/types.js'; +import { collectBindingNamesFromPattern } from '../ast/binding-pattern.js'; +import type { + AstFunction, + AstNode, + AstProgram, + ImportDeclarationSpecifier, + ImportSpecifier, +} from '../../ast-types.js'; +import { forEachAstChild } from '../ast/guards.js'; +import { getJsxAttributeName } from '../jsx/jsx-attr-name.js'; +import { wCallSuffix, parseArrayItems } from '../qwik/w-call.js'; +import { pureAwareOverwriteStart } from '../edit/text-scanning.js'; +import { wholeIdentifierPattern } from '../edit/identifier-boundary.js'; +import { + formatImportParts, + formatImportStatement, + formatNamedImportPart, +} from '../edit/import-format.js'; +import { RAW_TRANSFER_PARSER_OPTIONS } from '../../ast-types.js'; +import type { RewriteContext } from './rewrite-context.js'; +import { + collectNeededImports, + buildQrlDeclarations, + buildInlineSCalls, + assembleOutput, +} from './output-assembly.js'; +import { detectAndRenameCollisions, reserveGeneratedImportAliases } from './symbol-collision.js'; +import { countJsxKeysInNode } from '../segment/segment-generation.js'; + +export { + resolveConstLiteralsInClosure, + resolveWholeBodyIdentifier, + inlineConstCaptures, +} from './const-propagation.js'; +export { + extractDestructuredFieldInfo, + consolidateRawPropsInWCalls, + applyRawPropsTransform, + bodyConsolidatesToRawProps, + consolidateQpCaptureValues, +} from './raw-props.js'; + +import { extractDestructuredFieldInfo } from './raw-props.js'; + +export interface InlineStrategyOptions { + readonly inline: boolean; + readonly entryType?: 'inline' | 'hoist'; + readonly isLibMode?: boolean; + readonly stripCtxName?: readonly string[]; + readonly stripEventHandlers?: boolean; + readonly regCtxName?: readonly string[]; +} + +export interface ParentRewriteResult { + /** Rewritten parent module source code. */ + code: string; + /** + * All extractions after `resolveNesting`, mutated in place — the same array passed to + * {@link rewriteParentModule}, now `phase: 'consolidated'`. + */ + extractions: ConsolidatedSegment[]; + /** + * Post-JSX-rewrite source for each module-level decl migration will MOVE, keyed by `varName` — + * carries the Qwik-form JSX into the segment file so it isn't re-parsed as raw source and + * React-transformed by the TS-strip pass. + */ + movedDeclSnapshots: Map; + /** Final JSX key counter value, for segment continuation. */ + jsxKeyCounterValue?: number; + /** Per-extraction key bases recorded by the module-order walk (keyed by argStart). */ + jsxRegionKeyBases?: ReadonlyMap; +} + +function isMarkerSpecifier(importedName: string, extractedCalleeNames: Set): boolean { + return extractedCalleeNames.has(importedName); +} + +/** + * The imported name of a specifier. For `import { "foo" as bar }` (StringLiteral imported) falls + * through to the local binding name. + */ +function importedSpecifierName(spec: ImportSpecifier): string { + const imported = spec.imported; + if (imported.type === 'Identifier') { + return imported.name; + } + return spec.local.name; +} + +export interface JsxRewriteOptions { + enableJsx: boolean; + importedNames: Set; + enableSignals?: boolean; + precomputedScopeBindings?: ScopeAwareCollectResult; +} + +/** Rewrite a parent module source using magic-string. */ +export function rewriteParentModule( + source: string, + relPath: RelativePath, + extractions: ExtractionResult[], + originalImports: Map, + migrationDecisions?: MigrationDecision[], + moduleLevelDecls?: ModuleLevelDecl[], + jsxOptions?: JsxRewriteOptions, + mode?: EmitMode, + devFilePath?: string, + inlineOptions?: InlineStrategyOptions, + stripExports?: readonly string[], + isServer?: boolean, + explicitExtensions?: boolean, + transpileTs?: boolean, + minify?: string, + outputExtension?: string, + existingProgram?: AstProgram, + closureNodes?: Map, + userDevPath?: string, + hasForeignJsxRuntime?: boolean, + elementQpParamsMap?: ReadonlyMap +): ParentRewriteResult { + const s = new MagicString(source); + const program = + existingProgram ?? parseSync(relPath, source, RAW_TRANSFER_PARSER_OPTIONS).program; + + const ctx: RewriteContext = { + source, + relPath, + s, + program, + closureNodes, + extractions, + originalImports, + migrationDecisions, + moduleLevelDecls, + elementQpParamsMap, + jsxOptions, + mode, + devFilePath, + userDevPath, + inlineOptions, + stripExports, + isServer, + explicitExtensions, + transpileTs, + minify, + outputExtension, + extractedCalleeNames: new Set(), + alreadyImported: new Set(), + survivingUserImports: [], + survivingImportInfos: [], + topLevel: [], + earlyQrlVarNames: new Map(), + neededImports: new Map(), + injectedImportAliases: new Map(), + qrlVarNames: new Map(), + qrlDecls: [], + sCalls: [], + inlineHoistedDeclarations: [], + inlinedQrlSymbols: new Set(), + eventHandlerExtraImports: [], + noArgQrlCallees: [], + jsxResult: null, + jsxKeyCounterValue: 0, + isDevMode: mode === 'dev' || mode === 'hmr', + isInline: inlineOptions?.inline === true, + isLibMode: inlineOptions?.isLibMode === true, + hasForeignJsxRuntime: hasForeignJsxRuntime === true, + }; + + collectExtractedCalleeNames(ctx); + processImports(ctx); + applyModeTransforms(ctx); + resolveNesting(ctx); + + // Flip phase 'captured' → 'consolidated' now that resolveNesting settled the + // last phase-spanning field (`parent`); downstream sees ConsolidatedSegment. + for (const ext of extractions) { + (ext as Mutable).phase = 'consolidated'; + } + + preConsolidateRawPropsCaptures(ctx); + ctx.topLevel = extractions.filter((e) => e.parent === null); + + // When the parent has keyable JSX of its own interleaved with extracted + // bodies, key numbers must follow the module-order walk — reserve each + // body's range as the parent walk passes it. This applies to every + // strategy: segment builds continue from the recorded per-region bases so + // SSR and client assign identical keys to the same source node. Without + // parent-level JSX after a region, extraction-order numbering already + // matches, so the mechanism stays off. + { + const reservationJsxFns = collectJsxFunctionNamesFromIterable(ctx.originalImports.values()); + const regions: Array<{ start: number; end: number; count: number }> = []; + for (const ext of ctx.topLevel) { + if (ext.isSync) { + continue; + } + const node = ctx.closureNodes?.get(ext.symbolName); + if (!node) { + continue; + } + const count = countJsxKeysInNode(node, reservationJsxFns); + if (count > 0) { + regions.push({ start: ext.argStart, end: ext.argEnd, count }); + } + } + // Engage only when a parent-level keyable site FOLLOWS a region in source + // — the shape where extraction-order numbering diverges from the + // module-order walk. (Parent sites before all regions number identically.) + const minRegionStart = regions.reduce((min, r) => Math.min(min, r.start), Infinity); + let parentSiteAfterRegion = false; + if (regions.length > 0) { + const visit = (n: AstNode | null | undefined): void => { + if (!n || parentSiteAfterRegion) { + return; + } + const inRegion = regions.some((r) => n.start >= r.start && n.start < r.end); + if (!inRegion) { + const isSyntax = n.type === 'JSXElement' || n.type === 'JSXFragment'; + const isCall = + n.type === 'CallExpression' && + (n as unknown as { callee?: { type?: string; name?: string } }).callee?.type === + 'Identifier' && + reservationJsxFns.has((n as unknown as { callee: { name: string } }).callee.name); + if ((isSyntax || isCall) && n.start > minRegionStart) { + parentSiteAfterRegion = true; + return; + } + } + forEachAstChild(n, (child) => visit(child as AstNode)); + }; + visit(ctx.program as unknown as AstNode); + } + if (regions.length > 0 && parentSiteAfterRegion) { + const bases = new Map(); + ctx.jsxKeyReservations = { + bases, + findRegion(pos: number) { + return regions.find((r) => pos >= r.start && pos < r.end); + }, + }; + ctx.jsxCallSkipKeyBases = bases; + } + } + + preComputeQrlVarNames(ctx); + reserveGeneratedImportAliases(ctx); + rewriteCallSites(ctx); + rewriteNoArgMarkers(ctx); + renameUnextractedEventAttrs(ctx); + removeUnusedBindings(ctx); + removeDuplicateExports(ctx); + addCaptureWrapping(ctx); + runJsxTransform(ctx); + runPeerToolJsxCallTransform(ctx); + + // Slice post-JSX-rewrite text for each MOVE'd decl so its JSX reaches the + // segment in Qwik form, not raw source that oxc-transform re-emits as `_jsx`. + const movedDeclSnapshots = new Map(); + if (migrationDecisions && moduleLevelDecls) { + const declsByName = new Map(); + for (const decl of moduleLevelDecls) { + declsByName.set(decl.name, decl); + } + for (const decision of migrationDecisions) { + if (decision.action !== 'move') { + continue; + } + const decl = declsByName.get(decision.varName); + if (!decl) { + continue; + } + movedDeclSnapshots.set(decision.varName, ctx.s.slice(decl.declStart, decl.declEnd)); + } + } + + collectNeededImports(ctx); + // Must run after collectNeededImports (needs the injected-name set) and + // before buildQrlDeclarations (its `qrl(...)` text refers to the injected name). + detectAndRenameCollisions(ctx); + buildQrlDeclarations(ctx); + buildInlineSCalls(ctx); + const finalCode = assembleOutput(ctx); + + return { + code: finalCode, + extractions: extractions as ConsolidatedSegment[], + movedDeclSnapshots, + jsxKeyCounterValue: ctx.jsxKeyCounterValue || undefined, + jsxRegionKeyBases: ctx.jsxCallSkipKeyBases, + }; +} + +function collectExtractedCalleeNames(ctx: RewriteContext): void { + for (const ext of ctx.extractions) { + ctx.extractedCalleeNames.add(ext.calleeName); + if (ext.isInlinedQrl) { + // `_captures` is a runtime helper used inside inlinedQrl bodies, not a + // marker callee. Stripping its import is only safe when bodies extract to + // segment files; under inline/hoist they stay in the parent and still need it. + if (!ctx.isInline) { + ctx.extractedCalleeNames.add('_captures'); + } + ctx.extractedCalleeNames.add('_inlinedQrl'); + } + } + if (ctx.extractions.length > 0) { + ctx.extractedCalleeNames.add('$'); + } + for (const [localName] of ctx.originalImports) { + ctx.alreadyImported.add(localName); + } +} + +function processImports(ctx: RewriteContext): void { + const { s, program, source, extractedCalleeNames, minify, isLibMode } = ctx; + + for (const node of program.body) { + if (node.type !== 'ImportDeclaration') { + continue; + } + + const specifiers = node.specifiers; + const sourceNode = node.source; + const rewrittenSource = rewriteImportSource(sourceNode.value); + + const rawSource = sourceNode.raw ?? sourceNode.value; + const quoteChar = rawSource.startsWith("'") ? "'" : '"'; + + if (!specifiers || specifiers.length === 0) { + // Side-effect imports move into the rebuilt top import block in original + // relative order — leaving them in the body would sink them below the + // qrl declarations, changing module evaluation order in bundled cycles. + let end = node.end; + if (end < source.length && source[end] === '\n') { + end++; + } + s.remove(node.start, end); + ctx.survivingUserImports.push(`import ${quoteChar}${rewrittenSource}${quoteChar};`); + ctx.survivingImportInfos.push({ + defaultPart: '', + nsPart: '', + namedParts: [], + quote: quoteChar, + source: rewrittenSource, + isSideEffect: true, + preservedAll: false, + }); + continue; + } + + const toRemove: number[] = []; + for (let i = 0; i < specifiers.length; i++) { + const spec = specifiers[i]; + if (spec.type !== 'ImportSpecifier') { + continue; + } + const importedName = importedSpecifierName(spec); + if (isMarkerSpecifier(importedName, extractedCalleeNames)) { + if (isLibMode && isLibModePreservedMarker(importedName)) { + continue; + } + toRemove.push(i); + } + } + + let end = node.end; + if (end < source.length && source[end] === '\n') { + end++; + } + s.remove(node.start, end); + + if (toRemove.length === specifiers.length) { + continue; + } + + const isQwikSource = + rewrittenSource.startsWith('@qwik.dev/') || rewrittenSource.startsWith('@builder.io/qwik'); + let preserveAll = false; + if (isQwikSource && quoteChar === "'" && minify === 'none') { + const hasNonDollarSurvivor = specifiers.some( + (spec: ImportDeclarationSpecifier, i: number) => { + if (toRemove.includes(i)) { + return false; + } + if (spec.type !== 'ImportSpecifier') { + return true; + } + const importedName = importedSpecifierName(spec); + return !importedName.endsWith('$'); + } + ); + if (hasNonDollarSurvivor) { + preserveAll = true; + } + } + + let defaultPart = ''; + let nsPart = ''; + const namedParts: string[] = []; + const namedPartsStructured: { local: string; imported: string }[] = []; + for (let i = 0; i < specifiers.length; i++) { + if (!preserveAll && toRemove.includes(i)) { + continue; + } + const spec = specifiers[i]; + if (spec.type === 'ImportDefaultSpecifier') { + defaultPart = spec.local.name; + } else if (spec.type === 'ImportNamespaceSpecifier') { + nsPart = `* as ${spec.local.name}`; + } else { + const localName = spec.local.name; + const importedName = importedSpecifierName(spec); + namedPartsStructured.push({ local: localName, imported: importedName }); + namedParts.push(formatNamedImportPart(importedName, localName)); + } + } + + const importParts = formatImportParts(defaultPart, nsPart, namedParts); + + if (importParts) { + ctx.survivingUserImports.push(formatImportStatement(importParts, quoteChar, rewrittenSource)); + ctx.survivingImportInfos.push({ + defaultPart, + nsPart, + namedParts: namedPartsStructured, + quote: quoteChar, + source: rewrittenSource, + isSideEffect: false, + preservedAll: preserveAll, + }); + } + } +} + +function applyModeTransforms(ctx: RewriteContext): void { + if (ctx.stripExports && ctx.stripExports.length > 0) { + stripExportDeclarations(ctx.s, ctx.program, ctx.stripExports); + } +} + +function resolveNesting(ctx: RewriteContext): void { + const sorted = [...ctx.extractions].sort((a, b) => a.callStart - b.callStart); + + for (let i = 0; i < sorted.length; i++) { + let bestParent: (typeof sorted)[0] | null = null; + let bestRange = Infinity; + for (let j = 0; j < sorted.length; j++) { + if (i === j) { + continue; + } + if (sorted[i].callStart >= sorted[j].argStart && sorted[i].callEnd <= sorted[j].argEnd) { + const range = sorted[j].argEnd - sorted[j].argStart; + if (range < bestRange) { + bestRange = range; + bestParent = sorted[j]; + } + } + } + if (bestParent) { + (sorted[i] as Mutable).parent = bestParent.symbolName; + } + } + + for (const ext of sorted) { + const orig = ctx.extractions.find((e) => e.symbolName === ext.symbolName); + if (orig) { + (orig as Mutable).parent = ext.parent; + } + } +} + +function preConsolidateRawPropsCaptures(ctx: RewriteContext): void { + // `.w()`-capture consolidation is an inline/hoist concern; promoted-param + // handlers (q:p slots) consolidate under every strategy so the SSR-emitted + // slot value and the handler segment's params stay paired. + const isInline = ctx.inlineOptions?.inline === true; + for (const ext of ctx.extractions) { + if (ext.parent === null) { + continue; + } + const hasPromotedParams = ext.captureNames.length === 0 && ext.paramNames.length > 2; + if (!isInline && !hasPromotedParams) { + continue; + } + if (ext.captureNames.length === 0 && !hasPromotedParams) { + continue; + } + + const parentExt = ctx.extractions.find((e) => e.symbolName === ext.parent); + if (!parentExt) { + continue; + } + + // Defaults let nested-segment field rewrites emit `(_rawProps. ?? )` + // for fields the parent destructure defaulted; undefaulted fields stay bare. + const { fieldMap, fieldDefaults: fieldDefaultsMap } = extractDestructuredFieldInfo( + parentExt.bodyText + ); + if (fieldMap.size === 0) { + continue; + } + + const nonPropsCaptures: string[] = []; + let hasPropsFields = false; + const propsFieldCaptures = new Map(); + const propsFieldDefaults = new Map(); + const collectField = (name: string): boolean => { + if (!fieldMap.has(name)) { + return false; + } + hasPropsFields = true; + propsFieldCaptures.set(name, fieldMap.get(name)!); + const defaultExpr = fieldDefaultsMap.get(name); + if (defaultExpr !== undefined) { + propsFieldDefaults.set(name, defaultExpr); + } + return true; + }; + for (const name of ext.captureNames) { + if (!collectField(name)) { + nonPropsCaptures.push(name); + } + } + // Promoted handler params (`(_, _1, field)`) consolidate the same way: + // the positional slot must carry the whole props proxy, not a field read + // that loses the proxy identity through serialization. + let consolidatedParams: string[] | null = null; + if (hasPromotedParams) { + const mapped: string[] = ext.paramNames.slice(0, 2) as string[]; + for (let i = 2; i < ext.paramNames.length; i++) { + const p = ext.paramNames[i]; + const target = collectField(p) ? '_rawProps' : p; + if (!mapped.includes(target) || target !== '_rawProps') { + mapped.push(target); + } + } + if (hasPropsFields) { + consolidatedParams = mapped; + } + } + if (hasPropsFields) { + const wip = ext as Mutable; + wip.propsFieldCaptures = propsFieldCaptures; + if (propsFieldDefaults.size > 0) { + wip.propsFieldDefaults = propsFieldDefaults; + } + if (consolidatedParams) { + wip.paramNames = consolidatedParams; + } else { + wip.captureNames = [...nonPropsCaptures, '_rawProps'].sort(); + wip.captures = wip.captureNames.length > 0; + } + } + } +} + +function preComputeQrlVarNames(ctx: RewriteContext): void { + let earlyStrippedCounter = 0; + let inlineSentinelOffset = 0; + for (const ext of ctx.extractions) { + if (ext.isSync) { + continue; + } + // Segment-strategy worker QRLs take a sentinel binding — their decl + // carries the symbol name inside the `_qrlWithChunk` call instead. Under + // inline/hoist the binding pairs with a named `q_.s(...)` + // registration, so it keeps the symbol name. + if (isWorkerExtraction(ext)) { + if (ctx.inlineOptions?.inline) { + ctx.earlyQrlVarNames.set(ext.symbolName, `q_${ext.symbolName}`); + inlineSentinelOffset += inlineSentinelStep(ext, false, ctx.inlineOptions.regCtxName); + } else { + const counter = 0xffff0000 + earlyStrippedCounter++ * 2; + ctx.earlyQrlVarNames.set(ext.symbolName, `q_qrl_${counter}`); + } + continue; + } + if (!ctx.inlineOptions) { + continue; + } + const isRegCtx = matchesRegCtxName(ext, ctx.inlineOptions.regCtxName); + const stripped = + !isRegCtx && + isStrippedExtraction( + ext, + ctx.inlineOptions.stripCtxName, + ctx.inlineOptions.stripEventHandlers + ); + const offset = ctx.inlineOptions.inline ? inlineSentinelOffset : earlyStrippedCounter * 2; + if (ctx.inlineOptions.inline) { + inlineSentinelOffset += inlineSentinelStep(ext, stripped, ctx.inlineOptions.regCtxName); + } + if (stripped) { + const counter = 0xffff0000 + offset; + ctx.earlyQrlVarNames.set(ext.symbolName, `q_qrl_${counter}`); + } else { + ctx.earlyQrlVarNames.set(ext.symbolName, `q_${ext.symbolName}`); + } + } +} + +function getQrlVarName(ctx: RewriteContext, symbolName: string): string { + return ctx.earlyQrlVarNames.get(symbolName) ?? `q_${symbolName}`; +} + +function rewriteCallSites(ctx: RewriteContext): void { + const { s, topLevel, inlineOptions } = ctx; + + for (const ext of topLevel) { + if (ext.isSync) { + s.overwrite(ext.callStart, ext.callEnd, buildSyncTransform(ext.bodyText)); + } else if (ext.isInlinedQrl) { + s.overwrite( + pureAwareOverwriteStart(ctx.source, ext.callStart), + ext.callEnd, + getQrlVarName(ctx, ext.symbolName) + ); + } else if (ext.isBare) { + // Consume any preceding PURE annotation so it isn't stranded before the + // `q_` identifier. + s.overwrite( + pureAwareOverwriteStart(ctx.source, ext.callStart), + ext.callEnd, + getQrlVarName(ctx, ext.symbolName) + ); + } else if (isEventHandlerOrJsxProp(ext.ctxKind) && !ext.qrlCallee) { + let propName: string; + if (ext.isComponentEvent) { + propName = ext.ctxName; + } else { + propName = transformEventPropName(ext.ctxName, new Set()) ?? ext.ctxName; + } + + const isRegCtx = matchesRegCtxName(ext, inlineOptions?.regCtxName); + let qrlRef = isRegCtx + ? `serverQrl(${getQrlVarName(ctx, ext.symbolName)})` + : getQrlVarName(ctx, ext.symbolName); + if (isRegCtx) { + const serverQrlSource = ext.importSource || '@qwik.dev/core'; + ctx.eventHandlerExtraImports.push({ sym: 'serverQrl', src: serverQrlSource }); + } + + if (!isRegCtx && ext.captureNames.length > 0) { + qrlRef += wCallSuffix(ext.captureNames, ' ', ' '); + } + + // Separate name/value overwrites keep the value range sliceable for the + // JSX transform (a whole-attr overwrite makes its value slice throw and + // fall back to raw source). + s.overwrite(ext.calleeStart, ext.calleeEnd, propName); + s.overwrite(ext.argStart, ext.argEnd, qrlRef); + } else { + const qrlCallee = ctx.injectedImportAliases.get(ext.qrlCallee) ?? ext.qrlCallee; + s.overwrite(ext.calleeStart, ext.calleeEnd, qrlCallee); + s.overwrite(ext.argStart, ext.argEnd, getQrlVarName(ctx, ext.symbolName)); + if (needsPureAnnotation(ext.qrlCallee)) { + s.prependRight(ext.callStart, '/*#__PURE__*/ '); + } + } + } +} + +function rewriteNoArgMarkers(ctx: RewriteContext): void { + const { s, program, originalImports, extractedCalleeNames, alreadyImported } = ctx; + const extractedCallStarts = new Set(ctx.extractions.map((e) => e.callStart)); + + function walk(node: AstNode | null | undefined): void { + if (!node) { + return; + } + if (node.type === 'CallExpression' && !extractedCallStarts.has(node.start)) { + const calleeName = node.callee.type === 'Identifier' ? node.callee.name : null; + if (calleeName) { + const importInfo = originalImports.get(calleeName); + if ( + importInfo && + importInfo.importedName.endsWith('$') && + importInfo.importedName !== '$' && + importInfo.importedName !== 'sync$' + ) { + if (node.arguments.length === 0) { + const qrlCallee = getQrlCalleeName(importInfo.importedName); + s.overwrite(node.callee.start, node.callee.end, qrlCallee); + if (needsPureAnnotation(qrlCallee)) { + s.prependRight(node.start, '/*#__PURE__*/ '); + } + extractedCalleeNames.add(importInfo.importedName); + ctx.noArgQrlCallees.push({ callee: qrlCallee, source: importInfo.source }); + if (!alreadyImported.has(qrlCallee)) { + alreadyImported.add(qrlCallee); + } + } + } + } + } + forEachAstChild(node, (child) => walk(child)); + } + for (const stmt of program.body) { + walk(stmt); + } +} + +function removeUnusedBindings(ctx: RewriteContext): void { + if (ctx.minify === 'none') { + return; + } + + const { s, source, program, topLevel } = ctx; + + // Reexported bindings (`export { X as _auto_X }`) MUST survive even when they + // look unused here: the reexport is appended after this pass (not in the scan + // below), so stripping the binding would leave the reexport dangling. + const reexportedNames = new Set(autoExportedNames(ctx.migrationDecisions, ctx.moduleLevelDecls)); + + for (const stmt of program.body) { + if (stmt.type === 'ExportNamedDeclaration') { + continue; + } + if (stmt.type !== 'VariableDeclaration') { + continue; + } + + const decl = stmt; + if (!decl.declarations || decl.declarations.length !== 1) { + continue; + } + + const declarator = decl.declarations[0]; + if (!declarator.init) { + continue; + } + + const initStart = declarator.init.start; + const initEnd = declarator.init.end; + const matchingExtractions = topLevel.filter( + (ext) => !ext.isSync && ext.callStart >= initStart && ext.callEnd <= initEnd + ); + + const isInlinedQrlCall = + declarator.init.type === 'CallExpression' && + declarator.init.callee?.type === 'Identifier' && + declarator.init.callee.name === 'inlinedQrl'; + + if (matchingExtractions.length === 0 && !isInlinedQrlCall) { + continue; + } + + const varName = declarator.id?.type === 'Identifier' ? declarator.id.name : null; + if (!varName) { + continue; + } + if (reexportedNames.has(varName)) { + continue; + } + + const wordBoundaryRegex = wholeIdentifierPattern(varName); + let bodyText = ''; + for (const bodyStmt of program.body) { + if (bodyStmt.type === 'ImportDeclaration') { + continue; + } + if (bodyStmt === decl) { + continue; + } + bodyText += source.slice(bodyStmt.start, bodyStmt.end) + '\n'; + } + + if (!wordBoundaryRegex.test(bodyText)) { + const bareExtractions = matchingExtractions.filter( + (ext) => ext.isBare && ext.callStart === initStart && ext.callEnd === initEnd + ); + s.remove(decl.start, bareExtractions.length > 0 ? decl.end : initStart); + for (const ext of bareExtractions) { + registerUnboundQrl(ctx, ext); + } + } + } +} + +function registerUnboundQrl(ctx: RewriteContext, ext: ExtractionResult): void { + if (ctx.inlinedQrlSymbols.has(ext.symbolName)) { + return; + } + ctx.inlinedQrlSymbols.add(ext.symbolName); + const inlineExt = ctx.explicitExtensions ? (ctx.outputExtension ?? '.js') : ''; + ctx.qrlDecls.push( + `/*#__PURE__*/ qrl(()=>import("./${ext.canonicalFilename}${inlineExt}"), "${ext.symbolName}");` + ); +} + +function removeDuplicateExports(ctx: RewriteContext): void { + if (ctx.minify === 'none') { + return; + } + + const seenExportNames = new Set(); + for (const stmt of ctx.program.body) { + if (stmt.type !== 'ExportNamedDeclaration') { + continue; + } + const innerDecl = stmt.declaration; + if (!innerDecl || innerDecl.type !== 'VariableDeclaration') { + continue; + } + if (!innerDecl.declarations || innerDecl.declarations.length !== 1) { + continue; + } + + const declarator = innerDecl.declarations[0]; + if (!declarator.init) { + continue; + } + + const exportedNames = collectBindingNamesFromPattern(declarator.id); + if (exportedNames.length === 0) { + continue; + } + + const hasDuplicate = exportedNames.some((n) => seenExportNames.has(n)); + if (hasDuplicate) { + for (const ext of ctx.topLevel) { + if ( + !ext.isSync && + ext.callStart >= declarator.init.start && + ext.callEnd <= declarator.init.end + ) { + registerUnboundQrl(ctx, ext); + } + } + ctx.s.remove(stmt.start, stmt.end); + } else { + for (const name of exportedNames) { + seenExportNames.add(name); + } + } + } +} + +function addCaptureWrapping(ctx: RewriteContext): void { + const { s, topLevel, migrationDecisions } = ctx; + + const migratedNames = new Set( + (migrationDecisions ?? []) + .filter((d) => d.action === 'reexport' || d.action === 'move' || d.action === 'drop') + .map((d) => d.varName) + ); + + for (const ext of topLevel) { + if (ext.isSync) { + continue; + } + + if (ext.isInlinedQrl) { + if (!ext.explicitCaptures) { + continue; + } + const captureItems = parseArrayItems(ext.explicitCaptures); + if (captureItems.length === 0) { + continue; + } + const wrapVars = captureItems.join(',\n '); + s.appendLeft(ext.callEnd, `.w([\n ${wrapVars}\n])`); + continue; + } + + if (ext.captureNames.length === 0) { + continue; + } + + if (isEventHandlerOrJsxProp(ext.ctxKind) && !ext.qrlCallee) { + continue; + } + + const effectiveCaptures = ext.captureNames.filter((name) => !migratedNames.has(name)); + if (effectiveCaptures.length === 0) { + continue; + } + + const wrapVars = effectiveCaptures.join(',\n '); + const wText = `.w([\n ${wrapVars}\n ])`; + + if (ext.isBare) { + s.appendLeft(ext.callEnd, wText); + } else { + s.appendLeft(ext.argEnd, wText); + } + } +} + +function runJsxTransform(ctx: RewriteContext): void { + if (!ctx.jsxOptions?.enableJsx) { + return; + } + // Foreign `@jsxImportSource` pragma — leave JSX intact so oxc-transform's + // default JSX transform handles it via the pragma-named runtime. + if (ctx.hasForeignJsxRuntime) { + return; + } + + const skipRanges = ctx.topLevel.map((ext) => ({ + start: ext.argStart, + end: ext.argEnd, + })); + + // Built here (not upstream) so stored positions match what `injectQpProp` + // reads during the same transformAllJsx walk — a later parseSync overwrites + // the shared oxc raw-transfer buffer, invalidating cross-phase positions. + const strippedQpOverrides = buildStrippedEventQpOverrides(ctx); + const liftedQpOverrides = buildParentLiftedQpOverrides(ctx); + let qpOverrides = strippedQpOverrides; + if (liftedQpOverrides) { + qpOverrides = new Map([...(strippedQpOverrides ?? []), ...liftedQpOverrides]); + } + + ctx.jsxResult = transformAllJsx( + { + source: ctx.source, + s: ctx.s, + program: ctx.program, + importedNames: ctx.jsxOptions.importedNames, + }, + { + skipRanges, + // JSX dev-info `fileName:` switches to the user dev path only when + // explicitly set; otherwise keep `relPath`. + devOptions: ctx.isDevMode ? { relPath: ctx.userDevPath ?? ctx.relPath } : undefined, + enableSignals: ctx.jsxOptions.enableSignals !== false, + qpOverrides, + keyReservations: ctx.jsxKeyReservations, + relPath: ctx.relPath, + precomputedScopeBindings: ctx.jsxOptions.precomputedScopeBindings, + } + ); + ctx.jsxKeyCounterValue = ctx.jsxResult.keyCounterValue; +} + +/** + * Rewrite peer-tool `jsx()`/`jsxs()`/`jsxDEV()` calls in the parent body to + * `_jsxSorted`/`_jsxSplit` form. Runs as a sibling of `runJsxTransform`, not inside it: a `.mjs` + * extension leaves `ctx.jsxOptions` undefined (so `runJsxTransform` short-circuits) but peer-tool + * `jsx()` calls still rewrite. + */ +function runPeerToolJsxCallTransform(ctx: RewriteContext): void { + // Foreign `@jsxImportSource` governs JSX syntax, not the peer-tool form: a + // `jsx(...)` resolving to a Qwik-runtime import still rewrites. + const parentJsxFunctions = collectJsxFunctionNamesFromIterable(ctx.originalImports.values()); + if (parentJsxFunctions.size === 0) { + return; + } + + const neededParentImports = new Set(); + const parentKeyPrefix = ctx.relPath ? computeKeyPrefix(ctx.relPath) : 'u6'; + const parentKeyCounter = new JsxKeyCounter(ctx.jsxKeyCounterValue ?? 0, parentKeyPrefix); + // Skip jsx() inside extraction arg ranges: segment codegen handles those and + // the parent MagicString already replaced them — reading a replaced anchor throws. + const skipRanges = ctx.extractions.map((e) => ({ + start: e.argStart, + end: e.argEnd, + })); + // Map each event handler's parent-side QRL var to its capture params so the + // peer-tool rewriter injects the owning element's `q:p`/`q:ps` prop — needed + // for client resumption even when the handler body stays inline or is stripped. + const qpByQrl = new Map(); + for (const ext of ctx.extractions) { + if (ext.ctxKind !== 'eventHandler' && ext.ctxKind !== 'jSXProp') { + continue; + } + const params = eventHandlerQpParams(ext.paramNames); + if (params.length > 0) { + qpByQrl.set(getQrlVarName(ctx, ext.symbolName), params); + } + } + transformJsxCalls(ctx.source, ctx.s, ctx.program, { + jsxFunctions: parentJsxFunctions, + keyCounter: parentKeyCounter, + neededImports: neededParentImports, + skipRanges, + keyReservations: ctx.jsxKeyReservations, + qpByQrl: qpByQrl.size > 0 ? qpByQrl : undefined, + }); + ctx.jsxKeyCounterValue = parentKeyCounter.current(); + + for (const name of neededParentImports) { + if (!ctx.neededImports.has(name)) { + ctx.neededImports.set(name, '@qwik.dev/core'); + } + } +} + +/** + * Map each JSXElement containing stripped event handlers to the union of their capture names, read + * by `injectQpProp` at `node.start`. Disambiguates same-named attrs by dequeuing a per-calleeName + * queue in source (walk) order. + */ +function buildStrippedEventQpOverrides(ctx: RewriteContext): Map | undefined { + if (!ctx.inlineOptions) { + return undefined; + } + const { stripCtxName, stripEventHandlers } = ctx.inlineOptions; + if (!stripCtxName && !stripEventHandlers) { + return undefined; + } + + const stripByName = new Map(); + for (const ext of ctx.extractions) { + if (ext.ctxKind !== 'eventHandler') { + continue; + } + if (!ext.captures || ext.captureNames.length === 0) { + continue; + } + if (!isStrippedExtraction(ext, stripCtxName, stripEventHandlers)) { + continue; + } + const name = ext.calleeName; + const bucket = stripByName.get(name); + if (bucket) { + bucket.push(ext); + } else { + stripByName.set(name, [ext]); + } + } + if (stripByName.size === 0) { + return undefined; + } + + const overrides = new Map(); + + walk(ctx.program, { + enter(node: AstNode) { + if (node.type !== 'JSXElement') { + return; + } + const attrs = node.openingElement?.attributes ?? []; + const collectedCaps: string[] = []; + const seen = new Set(); + for (const attr of attrs) { + if (attr.type !== 'JSXAttribute') { + continue; + } + const attrName = getJsxAttributeName(attr); + if (!attrName.endsWith('$')) { + continue; + } + const queue = stripByName.get(attrName); + if (!queue || queue.length === 0) { + continue; + } + const ext = queue.shift()!; + for (const c of ext.captureNames) { + if (!seen.has(c)) { + seen.add(c); + collectedCaps.push(c); + } + } + } + if (collectedCaps.length > 0) { + overrides.set(node.start, collectedCaps); + } + }, + }); + + return overrides.size > 0 ? overrides : undefined; +} + +/** + * Raw-JSX parents (no transpile) still rename event attrs whose values were NOT extracted (e.g. + * `onColor$={color}` with an identifier value) to their runtime form (`q-e:color`). + */ +function renameUnextractedEventAttrs(ctx: RewriteContext): void { + if (ctx.jsxOptions?.enableJsx) { + return; + } + + const extractedAttrNameRanges = new Set(); + for (const ext of ctx.extractions) { + extractedAttrNameRanges.add(ext.calleeStart); + } + + walk(ctx.program, { + enter(node: AstNode) { + if (node.type !== 'JSXElement') { + return; + } + const opening = node.openingElement; + const tagName = opening?.name; + const isNative = + tagName?.type === 'JSXIdentifier' && /^[a-z]/.test((tagName as { name: string }).name); + if (isNative) { + for (const attr of opening?.attributes ?? []) { + if (attr.type !== 'JSXAttribute' || !attr.value) { + continue; + } + if (extractedAttrNameRanges.has(attr.name.start)) { + continue; + } + const attrName = getJsxAttributeName(attr); + const transformed = transformEventPropName(attrName, new Set()); + if (transformed !== null && transformed !== attrName) { + ctx.s.overwrite(attr.name.start, attr.name.end, transformed); + } + } + } + }, + }); +} + +/** + * Parent-level handlers whose captures were lifted into params need the same `q:p` element wiring + * the segment path gets from `buildQpOverrides` — keyed by element start position. + */ +function buildParentLiftedQpOverrides(ctx: RewriteContext): Map | undefined { + const lifted = ctx.topLevel.filter( + (ext) => + ext.ctxKind === 'eventHandler' && + !ext.isSync && + eventHandlerQpParams(ext.paramNames).length > 0 + ); + if (lifted.length === 0) { + return undefined; + } + + const overrides = new Map(); + + walk(ctx.program, { + enter(node: AstNode) { + if (node.type !== 'JSXElement') { + return; + } + const opening = node.openingElement; + const collected: string[] = []; + const seen = new Set(); + for (const ext of lifted) { + if (opening && ext.argStart >= opening.start && ext.argEnd <= opening.end) { + for (const p of eventHandlerQpParams(ext.paramNames)) { + if (!seen.has(p)) { + seen.add(p); + collected.push(p); + } + } + } + } + if (collected.length > 0) { + overrides.set(node.start, collected); + } + }, + }); + + return overrides.size > 0 ? overrides : undefined; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/inline-body.ts b/packages/ts-optimizer/src/optimizer/rewrite/inline-body.ts new file mode 100644 index 00000000000..85f9e5ff7fd --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/inline-body.ts @@ -0,0 +1,574 @@ +/** Inline .s() body transformation for extracted segments. */ + +import MagicString from 'magic-string'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { walkAstForQp } from '../jsx/qp-walk.js'; +import { formatWCall, parseArrayItems, wCallSuffix } from '../qwik/w-call.js'; +import type { AstFunction } from '../../ast-types.js'; +import type { ExtractionResult, Mutable } from '../extraction/extract.js'; +import type { ImportInfo } from '../extraction/marker-detection.js'; +import { eventHandlerPropName } from '../jsx/event-handlers.js'; +import { transformAllJsx, JsxKeyCounter } from '../jsx/jsx.js'; +import { + transformJsxCalls, + collectJsxFunctionNamesFromIterable, +} from '../jsx/jsx-call-transform.js'; +import { eventHandlerQpParams } from '../jsx/loop-hoisting.js'; +import { computeKeyPrefix } from '../jsx/key-prefix.js'; +import { SignalHoister } from '../jsx/signal-analysis.js'; +import { foldBodySimplifiableExpressions } from '../jsx/simplify.js'; +import { getQrlImportSource, buildSyncTransform } from './rewrite-calls.js'; +import { foldConstantsInBodyText } from './const-replacement.js'; +import { injectCapturesUnpacking, removeDeadConstLiterals } from '../segment/segment-codegen.js'; +import { passiveEventsFromDisplayName } from '../segment/segment-generation.js'; +import { + resolveConstLiterals, + resolveConstLiteralsInClosure, + inlineConstCaptures, + propagateConstLiteralsInBody, +} from './const-propagation.js'; +import { + applyRawPropsTransform, + consolidateRawPropsInWCalls, + replacePropsFieldReferencesInBody, + bodyConsolidatesToRawProps, + consolidateQpCaptureValues, + extractDestructuredFieldInfo, + type InlineSegmentJsxOptions, +} from './raw-props.js'; +import { + hasUnderscorePlaceholderParams, + isEventHandlerOrJsxProp, + isStrippedExtraction, + matchesRegCtxName, +} from './predicates.js'; + +/** + * Transform an inline segment body: nested-call rewriting, const inlining, capture unpacking, + * _rawProps, JSX, and dead-const removal. + */ +export function transformInlineSegmentBody( + ext: ExtractionResult, + allExtractions: ExtractionResult[], + qrlVarNames: Map, + jsxBodyOptions?: InlineSegmentJsxOptions, + regCtxName?: readonly string[], + sharedSignalHoister?: SignalHoister, + closureNodes?: Map, + source?: string, + originalImports?: Map, + parentRelPath?: string, + sharedKeyCounterStart?: number, + /** + * Module-level decls that migration reexports or moves. Filtered from `captureNames` here because + * under inline/hoist the body references them from module scope directly — no `_captures[N]` + * indirection. + */ + migratedNames?: ReadonlySet, + /** + * Gates suppression of `.w([captures])` on stripped child QRLs in JSX-prop position: a stripped + * child's body is `export const X = null` and cannot consume captures, so emit the bare `q_X` ref + * instead. + */ + stripCtxName?: readonly string[], + stripEventHandlers?: boolean, + /** + * IsServer/isBrowser/isDev folding flags, applied here since this body sits outside the parent + * MagicString. + */ + isServer?: boolean, + isDev?: boolean, + /** + * Cross-body hoister for `_fnSignal(...)` values from the `_jsxDEV(...)` rewrite; separate from + * the JSX hoister (which gets reordered) so emitted `_hf` refs stay aligned with their + * declarations. + */ + jsxCallHoister?: SignalHoister, + /** Unified per-element q:ps arrays (slot order) — overrides per-child capture order. */ + elementQpParamsMap?: ReadonlyMap +): { + transformedBody: string; + additionalImports: Map; + hoistedDeclarations: string[]; + keyCounterValue?: number; +} { + let body: string = ext.bodyText; + const additionalImports = new Map(); + const hoistedDeclarations: string[] = []; + + const nested = allExtractions.filter((e) => e.parent === ext.symbolName); + + const rawPropsFieldMap: ReadonlyMap | undefined = bodyConsolidatesToRawProps( + ext.bodyText + ) + ? extractDestructuredFieldInfo(ext.bodyText).fieldMap + : undefined; + const qpValues = (params: string[]): string[] => + rawPropsFieldMap === undefined ? params : consolidateQpCaptureValues(params, rawPropsFieldMap); + + if (nested.length > 0) { + const bodyOffset = ext.argStart; + const sortedNested = [...nested].sort((a, b) => b.callStart - a.callStart); + const strippedLoopWDecls: Array<{ decl: string; symbolName: string }> = []; + + for (const child of sortedNested) { + const childVarName = qrlVarNames.get(child.symbolName) ?? `q_${child.symbolName}`; + + const relCallStart = child.callStart - bodyOffset; + const relCallEnd = child.callEnd - bodyOffset; + + if (relCallStart >= 0 && relCallEnd <= body.length) { + if (child.isSync) { + additionalImports.set('_qrlSync', '@qwik.dev/core'); + body = + body.slice(0, relCallStart) + + buildSyncTransform(child.bodyText) + + body.slice(relCallEnd); + } else if (child.isBare) { + let replacement = childVarName; + if (child.captureNames.length > 0) { + replacement += wCallSuffix(child.captureNames, ' ', ' '); + } + body = body.slice(0, relCallStart) + replacement + body.slice(relCallEnd); + } else if (isEventHandlerOrJsxProp(child.ctxKind) && !child.qrlCallee) { + const propName = eventHandlerPropName( + child.ctxName, + child.isComponentEvent, + passiveEventsFromDisplayName(child) + ); + + const isRegCtx = matchesRegCtxName(child, regCtxName); + let qrlRef = isRegCtx ? `serverQrl(${childVarName})` : childVarName; + if (isRegCtx) { + const serverQrlSource = child.importSource || '@qwik.dev/core'; + additionalImports.set('serverQrl', serverQrlSource); + } + + const hasLoopCrossCaptures = + !isRegCtx && + child.captures && + child.captureNames.length > 0 && + hasUnderscorePlaceholderParams(child.paramNames, child.movedCaptures); + + // Stripped child segments emit `= null` bodies; their captures reach + // the client positionally via the element's `q:p` prop instead of + // `.w([…])` — except when the positional slots carry promoted loop + // params, where the remaining lexical captures still need `.w()` via + // a component-scope binding (the attr value must stay an identifier + // for the `q:p` walk). + const childIsStripped = isStrippedExtraction(child, stripCtxName, stripEventHandlers); + const promotedParams = childIsStripped ? eventHandlerQpParams(child.paramNames) : []; + + if (childIsStripped && promotedParams.length > 0 && child.captureNames.length > 0) { + const wCall = formatWCall(childVarName, child.captureNames, ' ', ' '); + strippedLoopWDecls.push({ + decl: `const ${child.symbolName} = ${wCall};`, + symbolName: child.symbolName, + }); + qrlRef = child.symbolName; + } else if (hasLoopCrossCaptures && !childIsStripped) { + // The captures are component-scoped, so the `.w()` binding must + // live in the component body, not at module level. + const wCall = formatWCall(childVarName, child.captureNames, ' ', ' '); + strippedLoopWDecls.push({ + decl: `const ${child.symbolName} = ${wCall};`, + symbolName: child.symbolName, + }); + qrlRef = child.symbolName; + } else if (!isRegCtx && !childIsStripped && child.captureNames.length > 0) { + qrlRef += wCallSuffix(child.captureNames, ' ', ' '); + } + + // A handler from a pre-transformed `_jsxDEV(...)` props bag is an + // object property, not a JSX attribute — replace with the bare QRL ref + // (`onClick$: q_X`); the JSX-attribute form would break the object literal. + // A kebab-cased name whose local part starts with '-' (onDOMContentLoaded$ + // → q-d:-d-o-m-...) can't re-parse as a JSX attribute — keep the original + // attr name and let the JSX pass emit the final quoted key. + const attrName = /:-/.test(propName) ? child.ctxName : propName; + const replacement = child.isJsxObjectProp ? qrlRef : `${attrName}={${qrlRef}}`; + body = body.slice(0, relCallStart) + replacement + body.slice(relCallEnd); + } else if (child.qrlCallee) { + let replacement = child.qrlCallee + '(' + childVarName; + + if (child.captureNames.length > 0) { + replacement += wCallSuffix(child.captureNames, ' ', ' '); + } + + // Preserve arguments after the extracted closure (e.g. task options). + const relArgEnd = child.argEnd - bodyOffset; + const trailingArgs = + relArgEnd > relCallStart && relArgEnd < relCallEnd + ? body.slice(relArgEnd, relCallEnd - 1) + : ''; + replacement += trailingArgs + ')'; + body = body.slice(0, relCallStart) + replacement + body.slice(relCallEnd); + + additionalImports.set( + child.qrlCallee, + getQrlImportSource(child.qrlCallee, child.importSource) + ); + } else { + // inlinedQrl children have empty `qrlCallee` (peer-tool spec args), + // so emit the bare `q_X.w([captures])` ref without a wrapper call. + // Explicit capture arrays pass through verbatim — the body indexes + // `_captures[N]`, so dropping or reordering entries breaks it. + let replacement = childVarName; + const childCaptureItems = + child.isInlinedQrl && child.explicitCaptures + ? parseArrayItems(child.explicitCaptures) + : child.captureNames; + if (childCaptureItems.length > 0) { + replacement += wCallSuffix(childCaptureItems, ' ', ' '); + } + body = body.slice(0, relCallStart) + replacement + body.slice(relCallEnd); + } + } + } + + // Component-scope `.w([…])` bindings for stripped loop handlers, placed + // before the top-level statement that references them so their captures + // (declared earlier in the body) are in scope. Statement boundaries come + // from a parse — text scanning breaks on `;` inside type annotations. + if (strippedLoopWDecls.length > 0) { + const wrapperPrefixText = 'const __w__ = '; + const wrapped = wrapperPrefixText + body + ';'; + const parsed = parseWithRawTransfer('__w__.tsx', wrapped); + const init = parsed.program?.body?.[0]; + const arrow = init?.type === 'VariableDeclaration' ? init.declarations?.[0]?.init : undefined; + const block = + arrow && + (arrow.type === 'ArrowFunctionExpression' || arrow.type === 'FunctionExpression') && + arrow.body?.type === 'BlockStatement' + ? arrow.body + : null; + if (block) { + const inserts: Array<{ at: number; decl: string }> = []; + for (const { decl, symbolName } of strippedLoopWDecls) { + const usePos = body.indexOf(symbolName); + if (usePos < 0) { + continue; + } + const stmt = block.body.find( + (st) => + st.start - wrapperPrefixText.length <= usePos && + usePos < st.end - wrapperPrefixText.length + ); + if (stmt) { + inserts.push({ at: stmt.start - wrapperPrefixText.length, decl }); + } + } + inserts.sort((a, b) => b.at - a.at); + for (const { at, decl } of inserts) { + body = body.slice(0, at) + `${decl}\n ` + body.slice(at); + } + } + } + } + + if (ext.constLiterals && ext.constLiterals.size > 0) { + body = inlineConstCaptures(body, ext.constLiterals); + } + + // Explicit inlinedQrl captures are a pre-compiled contract with the body's + // `_captures[N]` reads — never const-inline entries out of them. + const hasExplicitCaptureContract = ext.isInlinedQrl && ext.explicitCaptures !== null; + if (ext.captureNames.length > 0 && ext.parent !== null && !hasExplicitCaptureContract) { + const parentExt = allExtractions.find((e) => e.symbolName === ext.parent); + if (parentExt) { + const parentClosure = closureNodes?.get(parentExt.symbolName); + const constValues = + parentClosure && source !== undefined + ? resolveConstLiteralsInClosure(parentClosure, source, ext.captureNames) + : resolveConstLiterals(parentExt.bodyText, ext.captureNames); + if (constValues.size > 0) { + body = inlineConstCaptures(body, constValues); + const wip = ext as Mutable; + wip.captureNames = wip.captureNames.filter((n) => !constValues.has(n)); + wip.captures = wip.captureNames.length > 0; + if (!wip.constLiterals) { + wip.constLiterals = constValues; + } else { + const accumulator = wip.constLiterals as Map; + for (const [k, v] of constValues) { + accumulator.set(k, v); + } + } + } + } + } + + if (ext.isInlinedQrl) { + // Peer-tool `inlinedQrl(...)` bodies destructure captures themselves; + // injecting `_captures` unpacking would duplicate the destructuring. + } else if (ext.captureNames.length > 0) { + // Migrated names are in module scope under inline/hoist, so filter them + // out — they don't need `_captures[N]` indirection. + const effectiveCaptures = + migratedNames && migratedNames.size > 0 + ? ext.captureNames.filter((n) => !migratedNames.has(n)) + : ext.captureNames; + if (effectiveCaptures.length > 0) { + body = injectCapturesUnpacking(body, effectiveCaptures); + additionalImports.set('_captures', '@qwik.dev/core'); + } + } + { + // Consolidate any destructured first param (component props AND hook + // contexts like useComputed$'s { cleanup }) — a bare destructured method + // call loses `this`. inlinedQrl bodies are pre-compiled and excluded. + const rawPropsResult = !ext.isInlinedQrl ? applyRawPropsTransform(body) : body; + if (rawPropsResult !== body) { + body = rawPropsResult; + if (body.includes('_restProps(')) { + additionalImports.set('_restProps', '@qwik.dev/core'); + } + body = consolidateRawPropsInWCalls(body); + } + } + + if (ext.propsFieldCaptures && ext.propsFieldCaptures.size > 0) { + // Pass `propsFieldDefaults` so defaulted fields emit `(_rawProps. ?? )`. + body = replacePropsFieldReferencesInBody(body, ext.propsFieldCaptures, ext.propsFieldDefaults); + } + + body = propagateConstLiteralsInBody(body); + + let finalKeyCounterValue: number | undefined; + + // Rewrite peer-tool `jsx(...)` calls (e.g. qwik-react codegen inside + // `inlinedQrl(...)` bodies) to `_jsxSorted(...)` form. Runs here because + // under inline strategy the body stays in the parent, not a segment file. + if (originalImports && originalImports.size > 0) { + const jsxFunctions = collectJsxFunctionNamesFromIterable(originalImports.values()); + if (jsxFunctions.size > 0) { + let mentionsAny = false; + for (const name of jsxFunctions) { + if (body.includes(name + '(')) { + mentionsAny = true; + break; + } + } + if (mentionsAny) { + try { + const wrappedBody = `(${body})`; + const bodyParse = parseWithRawTransfer('__inline_body__.tsx', wrappedBody); + if (bodyParse.program && !bodyParse.errors?.length) { + const callS = new MagicString(wrappedBody); + const relPathForPrefix = parentRelPath ?? jsxBodyOptions?.relPath; + const prefix = relPathForPrefix ? computeKeyPrefix(relPathForPrefix) : 'u6'; + const startAt = sharedKeyCounterStart ?? jsxBodyOptions?.keyCounterStart ?? 0; + const keyCounter = new JsxKeyCounter(startAt, prefix); + const callNeededImports = new Set(); + // Map each child event handler's QRL var to its capture params so + // the rewriter injects the owning element's capture prop — needed + // for client resumption even when the body is inline or stripped. + const qpByQrl = new Map(); + for (const child of nested) { + if (child.ctxKind !== 'eventHandler' && child.ctxKind !== 'jSXProp') { + continue; + } + const params = eventHandlerQpParams(child.paramNames); + if (params.length > 0) { + qpByQrl.set( + qrlVarNames.get(child.symbolName) ?? `q_${child.symbolName}`, + qpValues(params) + ); + } + } + const bodyJsxCallHoister = jsxCallHoister ?? new SignalHoister(); + const declsBefore = bodyJsxCallHoister.getDeclarations().length; + const jsxCallImportedNames = originalImports + ? new Set([...originalImports.values()].map((i) => i.localName)) + : new Set(); + transformJsxCalls(wrappedBody, callS, bodyParse.program, { + jsxFunctions, + keyCounter, + neededImports: callNeededImports, + qpByQrl: qpByQrl.size > 0 ? qpByQrl : undefined, + importedNames: jsxCallImportedNames, + signalHoister: bodyJsxCallHoister, + paramNames: ext.paramNames, + }); + const rewritten = callS.toString(); + if (rewritten !== wrappedBody) { + body = rewritten.slice(1, -1); + for (const sym of callNeededImports) { + additionalImports.set(sym, '@qwik.dev/core'); + } + hoistedDeclarations.push(...bodyJsxCallHoister.getDeclarations().slice(declsBefore)); + finalKeyCounterValue = keyCounter.current(); + } + } + } catch { + // jsx-call rewrite failed; fall through with body unchanged. + } + } + } + } + + if (jsxBodyOptions?.enableJsx) { + const wrapperPrefix = 'const __body__ = '; + const wrappedSource = wrapperPrefix + body; + // JSX dev-info needs source-relative line/col; without `sourcePosition`, + // positions would be wrappedSource-relative (off by the body's line offset). + let devOptionsForCall = jsxBodyOptions.devOptions; + if (devOptionsForCall && jsxBodyOptions.source != null) { + devOptionsForCall = { + ...devOptionsForCall, + sourcePosition: { + source: jsxBodyOptions.source, + bodyOriginOffset: ext.argStart, + wrapperPrefixLen: wrapperPrefix.length, + }, + }; + } + + const parseResult = parseWithRawTransfer('__body__.tsx', wrappedSource); + if (parseResult.program && !parseResult.errors?.length) { + const bodyS = new MagicString(wrappedSource); + + const bodyImportedNames = new Set(jsxBodyOptions.importedNames); + for (const [, varName] of qrlVarNames) { + bodyImportedNames.add(varName); + } + + let bodyQpOverrides: Map | undefined; + let bodyQrlsWithCaptures: Set | undefined; + { + const qrlParamMap = new Map(); + for (const child of nested) { + if (child.ctxKind !== 'eventHandler') { + continue; + } + // The unified element array carries the client build's slot order; + // per-child capture order would scramble multi-handler elements. + const unified = elementQpParamsMap?.get(child.symbolName); + const captureParams = unified ? [...unified] : eventHandlerQpParams(child.paramNames); + if (captureParams.length === 0) { + continue; + } + const childVarName = qrlVarNames.get(child.symbolName) ?? `q_${child.symbolName}`; + const consolidated = qpValues(captureParams); + qrlParamMap.set(childVarName, consolidated); + qrlParamMap.set(child.symbolName, consolidated); + } + + // Stripped event handlers emit `= null` bodies, so their captures can't + // reach the runtime via `_captures[N]`; propagate them to the parent JSX + // element's `q:p` var-prop instead, keyed by the post-rewrite QRL var. + if (stripCtxName || stripEventHandlers) { + for (const child of nested) { + if (child.ctxKind !== 'eventHandler') { + continue; + } + if (!child.captures || child.captureNames.length === 0) { + continue; + } + const isStripped = + (stripCtxName && stripCtxName.some((v) => child.ctxName.startsWith(v))) || + stripEventHandlers === true; + if (!isStripped) { + continue; + } + const childVarName = qrlVarNames.get(child.symbolName) ?? `q_${child.symbolName}`; + if (qrlParamMap.has(childVarName)) { + continue; + } + // The unified element array carries the client build's slot order; + // per-child capture order would scramble multi-handler elements. + const unified = elementQpParamsMap?.get(child.symbolName); + const consolidated = qpValues(unified ? [...unified] : [...child.captureNames]); + qrlParamMap.set(childVarName, consolidated); + qrlParamMap.set(child.symbolName, consolidated); + } + } + + if (qrlParamMap.size > 0) { + bodyQpOverrides = new Map(); + bodyQrlsWithCaptures = new Set(); + walkAstForQp( + parseResult.program, + (name) => qrlParamMap.get(name), + bodyQpOverrides, + bodyQrlsWithCaptures + ); + if (bodyQpOverrides.size === 0) { + bodyQpOverrides = undefined; + bodyQrlsWithCaptures = undefined; + } + } + } + + // A pre-compiled inlinedQrl handler value is a runtime call, not a + // statically-known-const handler — its event entry classifies var. + let bodyQrlsNonConst: Set | undefined; + for (const child of nested) { + if (!child.isInlinedQrl) { + continue; + } + (bodyQrlsNonConst ??= new Set()).add( + qrlVarNames.get(child.symbolName) ?? `q_${child.symbolName}` + ); + } + + const bodyJsxResult = transformAllJsx( + { + source: wrappedSource, + s: bodyS, + program: parseResult.program, + importedNames: bodyImportedNames, + }, + { + skipRanges: [], + devOptions: devOptionsForCall, + keyCounterStart: sharedKeyCounterStart ?? jsxBodyOptions.keyCounterStart, + qpOverrides: bodyQpOverrides, + qrlsWithCaptures: bodyQrlsWithCaptures, + qrlsNonConst: bodyQrlsNonConst, + relPath: jsxBodyOptions.relPath, + sharedSignalHoister, + paramNames: ext.paramNames ? new Set(ext.paramNames) : undefined, + } + ); + + const transformedWrapped = bodyS.toString(); + body = transformedWrapped.slice(wrapperPrefix.length); + + if (body.endsWith(';') && !ext.bodyText.endsWith(';')) { + body = body.slice(0, -1); + } + + for (const sym of bodyJsxResult.neededImports) { + additionalImports.set(sym, '@qwik.dev/core'); + } + if (bodyJsxResult.needsFragment) { + additionalImports.set('Fragment as _Fragment', '@qwik.dev/core/jsx-runtime'); + } + + hoistedDeclarations.push(...bodyJsxResult.hoistedDeclarations); + finalKeyCounterValue = bodyJsxResult.keyCounterValue; + } + } + + // Fold const flags before the simplify pass so dead branches collapse for DCE. + if (originalImports && (isServer !== undefined || isDev !== undefined)) { + body = foldConstantsInBodyText(body, originalImports, isServer, isDev); + } + + const hasNestedExts = allExtractions.some((e) => e.parent === ext.symbolName); + if (hasNestedExts) { + body = removeDeadConstLiterals(body); + } + + // Fold constant-foldable subtrees left by earlier passes (e.g. the `?? ` + // RHS in non-JSX positions). Runs after JSX so `_hf_str` stays source-form. + body = foldBodySimplifiableExpressions(body); + + return { + transformedBody: body, + additionalImports, + hoistedDeclarations, + keyCounterValue: finalKeyCounterValue, + }; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/lib-mode-collapse.ts b/packages/ts-optimizer/src/optimizer/rewrite/lib-mode-collapse.ts new file mode 100644 index 00000000000..6ce581f0b2a --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/lib-mode-collapse.ts @@ -0,0 +1,480 @@ +/** + * Lib-mode emission collapse. + * + * Post-pass that runs on the assembled inline-strategy output and collapses the three-statement + * triple + * + * Const q_X = _noopQrl("X"); const X_BODY = (...args) => { ...body... }; q_X.s(X_BODY); + * + * (plus any `q_X` / `q_X.w([captures])` references in the parent module body) into a single inlined + * `inlinedQrl(body, "X", [captures])` literal at every reference site. The decls + `.s()` call + * disappear. + * + * Used when `RewriteContext.isLibMode` is true. Re-uses the inline-strategy machinery for body + * emission + capture wiring (extraction, capture analysis, `q_X.s(body)` synthesis); only the final + * emission shape differs. + * + * Emits library-mode form: + * + * Export const Works = componentQrl(inlinedQrl((props) => { useStyleQrl(inlinedQrl(STYLES, + * "Works_component_useStyle_...")); ... useTaskQrl(inlinedQrl(() => { ... }, "Works_...", [sig])); + * ... }, "Works_component_...")); + * + * Bodies are substituted bottom-up: each q_X's body has inner q_Y references replaced first, so the + * outer literal already contains the collapsed inner ones. + * + * Import side: removes `_noopQrl`, adds `inlinedQrl`; `_captures` stays. + */ + +import MagicString from 'magic-string'; +import { walk } from 'oxc-walker'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import type { AstNode, AstProgram, CallExpression } from '../../ast-types.js'; +import { createTransformSession } from '../edit/transform-session.js'; + +interface NoopQrlDecl { + readonly qVarName: string; + readonly qrlSymbolName: string; + readonly start: number; + readonly end: number; +} + +interface SCallStatement { + readonly qVarName: string; + readonly bodyText: string; + readonly start: number; + readonly end: number; + readonly bodyDecl?: { name: string; start: number; end: number }; +} + +/** + * Collapse the inline-strategy output into lib-mode emit shape. Returns the source unchanged when + * no `_noopQrl(...)` decls are found. + */ +export function collapseToLibInlinedQrl(source: string): string { + // JSX is already lowered by this point, so parse as .ts first — in .tsx a + // generic arrow like `(x: T) => x` is a JSX ambiguity and fails to parse. + let parsed = parseWithRawTransfer('lib-collapse.ts', source); + if (!parsed.program || parsed.errors?.length) { + parsed = parseWithRawTransfer('lib-collapse.tsx', source); + } + if (!parsed.program || parsed.errors?.length) { + return source; + } + const program = parsed.program as AstProgram; + + const noopDecls = collectNoopQrlDecls(program, source); + if (noopDecls.size === 0) { + return source; + } + + const sCalls = collectSCallStatements(program, source, noopDecls); + if (sCalls.size === 0) { + return source; + } + + const inlinedLiteralsByVar = new Map(); + const inProgress = new Set(); + function buildInlinedLiteral(qVar: string): string | null { + const cached = inlinedLiteralsByVar.get(qVar); + if (cached !== undefined) { + return cached; + } + if (inProgress.has(qVar)) { + return null; + } + const decl = noopDecls.get(qVar); + const sCall = sCalls.get(qVar); + if (!decl || !sCall) { + return null; + } + inProgress.add(qVar); + const collapsedBody = substituteInnerQVarsInText(sCall.bodyText, buildInlinedLiteral); + inProgress.delete(qVar); + // Bare form; the reference site appends the captures array when it sees `.w([...])`. + const literal = `/*#__PURE__*/ inlinedQrl(${collapsedBody}, "${decl.qrlSymbolName}")`; + inlinedLiteralsByVar.set(qVar, literal); + return literal; + } + + // Pre-build all literals so the body strings are settled before applying edits. + for (const qVar of noopDecls.keys()) { + if (sCalls.has(qVar)) { + buildInlinedLiteral(qVar); + } + } + if (inlinedLiteralsByVar.size === 0) { + return source; + } + + const edits = new MagicString(source); + + // Skip references inside noop-decl init or the body decls being deleted — + // those are folded into the body strings via `substituteInnerQVarsInText`. + const skipRanges = collectSkipRanges(noopDecls, sCalls); + const referenceRanges = collectQVarReferenceRanges( + program, + source, + inlinedLiteralsByVar, + skipRanges + ); + for (const ref of referenceRanges) { + const literal = inlinedLiteralsByVar.get(ref.qVar)!; + const replacement = + ref.captureArgsText !== undefined + ? insertCapturesIntoInlinedQrl(literal, ref.captureArgsText) + : literal; + edits.overwrite(ref.start, ref.end, replacement); + } + + const ranges: Array<{ start: number; end: number }> = []; + for (const decl of noopDecls.values()) { + if (!inlinedLiteralsByVar.has(decl.qVarName)) { + continue; + } + ranges.push({ start: decl.start, end: decl.end }); + } + for (const sCall of sCalls.values()) { + if (!inlinedLiteralsByVar.has(sCall.qVarName)) { + continue; + } + ranges.push({ start: sCall.start, end: sCall.end }); + if (sCall.bodyDecl) { + ranges.push({ start: sCall.bodyDecl.start, end: sCall.bodyDecl.end }); + } + } + ranges.sort((a, b) => b.start - a.start); + for (const r of ranges) { + edits.remove(r.start, r.end); + } + + rewriteImports(edits, source); + + return edits.toString(); +} + +/** + * Substitute inner `q_Y`/`q_Y.w([...])` references in `bodyText` with their collapsed inlinedQrl + * literals. + */ +function substituteInnerQVarsInText( + bodyText: string, + buildInlinedLiteral: (qVar: string) => string | null +): string { + const session = createTransformSession(bodyText); + if (!session) { + return bodyText; + } + + const edits = session.edits; + const wrappedSource = session.wrappedSource; + const decl = (session.program.body[0] as { declarations?: Array<{ init?: AstNode }> }) + ?.declarations?.[0]; + const bodyNode = decl?.init; + if (!bodyNode) { + return bodyText; + } + + walk(bodyNode, { + enter(node: AstNode) { + if ( + node.type === 'CallExpression' && + node.callee?.type === 'MemberExpression' && + !node.callee.computed && + node.callee.object?.type === 'Identifier' && + node.callee.property?.type === 'Identifier' && + node.callee.property.name === 'w' + ) { + const literal = buildInlinedLiteral(node.callee.object.name); + if (literal) { + const captureArg = node.arguments?.[0]; + const captureText = + captureArg && captureArg.type !== 'SpreadElement' + ? wrappedSource.slice(captureArg.start, captureArg.end) + : undefined; + const replacement = + captureText === undefined + ? literal + : insertCapturesIntoInlinedQrl(literal, captureText); + edits.overwrite(node.start, node.end, replacement); + this.skip(); + } + return; + } + + if (node.type === 'Identifier') { + const literal = buildInlinedLiteral(node.name); + if (literal) { + edits.overwrite(node.start, node.end, literal); + this.skip(); + } + } + }, + }); + + return session.toSource(); +} + +function insertCapturesIntoInlinedQrl(literal: string, captureArgsText: string): string { + const lastParen = literal.lastIndexOf(')'); + if (lastParen < 0) { + return literal; + } + return literal.slice(0, lastParen) + `, ${captureArgsText}` + literal.slice(lastParen); +} + +function unwrapNoopQrlCall(node: AstNode): CallExpression | null { + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === '_noopQrl' + ) { + return node; + } + if ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'm' && + node.callee.object.type === 'CallExpression' + ) { + return unwrapNoopQrlCall(node.callee.object); + } + return null; +} + +function collectNoopQrlDecls(program: AstProgram, source: string): Map { + const out = new Map(); + for (const stmt of program.body ?? []) { + if (stmt.type !== 'VariableDeclaration') { + continue; + } + if (stmt.declarations?.length !== 1) { + continue; + } + const decl = stmt.declarations[0]; + if (!decl.id || decl.id.type !== 'Identifier') { + continue; + } + if (!decl.init) { + continue; + } + const noopQrlCall = unwrapNoopQrlCall(decl.init); + if (!noopQrlCall) { + continue; + } + const args = noopQrlCall.arguments ?? []; + if (args.length < 1) { + continue; + } + const nameArg = args[0]; + if (!nameArg || nameArg.type !== 'Literal' || typeof nameArg.value !== 'string') { + continue; + } + out.set(decl.id.name, { + qVarName: decl.id.name, + qrlSymbolName: nameArg.value, + start: stmt.start, + end: includeTrailingNewline(source, stmt.end), + }); + } + return out; +} + +function collectSCallStatements( + program: AstProgram, + source: string, + noopDecls: Map +): Map { + const constDeclByName = new Map< + string, + { name: string; bodyText: string; start: number; end: number } + >(); + for (const stmt of program.body ?? []) { + if (stmt.type !== 'VariableDeclaration' || stmt.kind !== 'const') { + continue; + } + if (stmt.declarations?.length !== 1) { + continue; + } + const decl = stmt.declarations[0]; + if (!decl.id || decl.id.type !== 'Identifier') { + continue; + } + if (!decl.init) { + continue; + } + if (unwrapNoopQrlCall(decl.init)) { + continue; + } + constDeclByName.set(decl.id.name, { + name: decl.id.name, + bodyText: source.slice(decl.init.start, decl.init.end), + start: stmt.start, + end: includeTrailingNewline(source, stmt.end), + }); + } + + const out = new Map(); + for (const stmt of program.body ?? []) { + if (stmt.type !== 'ExpressionStatement') { + continue; + } + const expr = stmt.expression; + if (!expr || expr.type !== 'CallExpression') { + continue; + } + const callee = expr.callee; + if ( + !callee || + callee.type !== 'MemberExpression' || + callee.computed || + callee.object?.type !== 'Identifier' || + callee.property?.type !== 'Identifier' || + callee.property.name !== 's' + ) { + continue; + } + const qVarName = callee.object.name; + if (!noopDecls.has(qVarName)) { + continue; + } + const args = expr.arguments ?? []; + if (args.length !== 1) { + continue; + } + const bodyArg = args[0]; + if (!bodyArg || bodyArg.type === 'SpreadElement') { + continue; + } + + let bodyText: string; + let bodyDecl: SCallStatement['bodyDecl']; + if (bodyArg.type === 'Identifier') { + // Two cases: (1) synthesized body var `q_X.s(X)` where `X` matches the qrl + // symbol name — resolve to its init text and delete the decl; (2) user + // binding `q_X.s(STYLES)` — keep the identifier and preserve the decl. + const decl = constDeclByName.get(bodyArg.name); + const noopDecl = noopDecls.get(qVarName); + const isSyntheticBody = + decl != null && noopDecl != null && bodyArg.name === noopDecl.qrlSymbolName; + if (isSyntheticBody && decl) { + bodyText = decl.bodyText; + bodyDecl = { name: decl.name, start: decl.start, end: decl.end }; + } else { + bodyText = source.slice(bodyArg.start, bodyArg.end); + } + } else { + bodyText = source.slice(bodyArg.start, bodyArg.end); + } + out.set(qVarName, { + qVarName, + bodyText, + start: stmt.start, + end: includeTrailingNewline(source, stmt.end), + bodyDecl, + }); + } + return out; +} + +interface QVarReferenceRange { + readonly qVar: string; + readonly start: number; + readonly end: number; + readonly captureArgsText?: string; +} + +/** + * Ranges to skip when walking for q_X references — the decls/statements deleted or replaced by + * separate codepaths. + */ +function collectSkipRanges( + noopDecls: Map, + sCalls: Map +): readonly { start: number; end: number }[] { + const out: { start: number; end: number }[] = []; + for (const decl of noopDecls.values()) { + out.push({ start: decl.start, end: decl.end }); + } + for (const sCall of sCalls.values()) { + out.push({ start: sCall.start, end: sCall.end }); + if (sCall.bodyDecl) { + out.push({ start: sCall.bodyDecl.start, end: sCall.bodyDecl.end }); + } + } + return out; +} + +function isInsideAnyRange(pos: number, ranges: readonly { start: number; end: number }[]): boolean { + for (const r of ranges) { + if (pos >= r.start && pos < r.end) { + return true; + } + } + return false; +} + +function collectQVarReferenceRanges( + program: AstProgram, + source: string, + buildersByVar: ReadonlyMap, + skipRanges: readonly { start: number; end: number }[] +): readonly QVarReferenceRange[] { + const out: QVarReferenceRange[] = []; + + walk(program, { + enter(node: AstNode) { + if (isInsideAnyRange(node.start, skipRanges)) { + this.skip(); + return; + } + + if ( + node.type === 'CallExpression' && + node.callee?.type === 'MemberExpression' && + !node.callee.computed && + node.callee.object?.type === 'Identifier' && + node.callee.property?.type === 'Identifier' && + node.callee.property.name === 'w' && + buildersByVar.has(node.callee.object.name) + ) { + const arg = node.arguments?.[0]; + if (node.arguments?.length === 1 && arg) { + out.push({ + qVar: node.callee.object.name, + start: node.start, + end: node.end, + captureArgsText: + arg.type === 'SpreadElement' ? undefined : source.slice(arg.start, arg.end), + }); + this.skip(); + } + return; + } + + if (node.type === 'Identifier' && buildersByVar.has(node.name)) { + out.push({ qVar: node.name, start: node.start, end: node.end }); + this.skip(); + } + }, + }); + return out; +} + +function rewriteImports(edits: MagicString, source: string): void { + const noopImportRe = /import\s*\{\s*_noopQrl\s*\}\s*from\s*(["'])@qwik\.dev\/core\1\s*;\s*\n?/g; + const match = noopImportRe.exec(source); + if (match) { + edits.overwrite( + match.index, + match.index + match[0].length, + `import { inlinedQrl } from "@qwik.dev/core";\n` + ); + } +} + +function includeTrailingNewline(source: string, end: number): number { + return end < source.length && source[end] === '\n' ? end + 1 : end; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/output-assembly.ts b/packages/ts-optimizer/src/optimizer/rewrite/output-assembly.ts new file mode 100644 index 00000000000..4abc341c28d --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/output-assembly.ts @@ -0,0 +1,1110 @@ +import type MagicString from 'magic-string'; +import { type TransformOptions } from 'oxc-transform'; +import type { AstNode, AstProgram } from '../../ast-types.js'; +import type { ExtractionResult } from '../extraction/extract.js'; +import type { ImportInfo } from '../extraction/marker-detection.js'; +import { + autoExportedNames, + MIG_REASON, + type ModuleLevelDecl, +} from '../analysis/variable-migration.js'; +import { + buildQrlDeclaration, + buildWorkerQrlDeclaration, + getQrlImportSource, + isWorkerExtraction, + markMovedCaptures, +} from './rewrite-calls.js'; +import { escapeSymbol } from '../../hashing/naming.js'; +import { buildQrlDevDeclaration, buildDevFilePath, formatDevMeta } from '../segment/dev-mode.js'; +import { + buildNoopQrlDeclaration, + buildNoopQrlDevDeclaration, + buildNoopQrlDevForVar, + buildNoopQrlForVar, + buildStrippedNoopQrl, + buildStrippedNoopQrlDev, + buildSCall, + buildHoistConstDecl, + buildHoistSCall, + getSentinelCounter, +} from '../segment/inline-strategy.js'; +import { rewriteFunctionSignature } from '../segment/segment-codegen.js'; +import { collapseToLibInlinedQrl } from './lib-mode-collapse.js'; +import { SignalHoister } from '../jsx/signal-analysis.js'; +import { transformInlineSegmentBody } from './inline-body.js'; +import { deriveIsDev } from './const-replacement.js'; +import { + hasUnderscorePlaceholderParams, + isAnyComponentCtx, + isStrippedExtraction, + inlineSentinelStep, + matchesRegCtxName, +} from './predicates.js'; +import { injectUseHmrIntoInlineBody } from '../transform/module-cleanup.js'; +import type { InlineSegmentJsxOptions } from './raw-props.js'; +import type { RewriteContext } from './rewrite-context.js'; +import { wholeIdentifierPattern } from '../edit/identifier-boundary.js'; +import { stripTypeScript } from '../edit/strip-types.js'; + +function isCustomInlined(ext: ExtractionResult, originalImports: Map): boolean { + for (const [, info] of originalImports) { + if (info.importedName === ext.calleeName) { + return false; + } + } + return true; +} + +export function collectNeededImports(ctx: RewriteContext): void { + const { + neededImports, + alreadyImported, + topLevel, + extractions, + inlineOptions, + isDevMode, + isInline, + inlinedQrlSymbols, + noArgQrlCallees, + eventHandlerExtraImports, + jsxResult, + originalImports, + } = ctx; + const hasTopLevelNonSync = topLevel.some((e) => !e.isSync); + const hasAnyNonSync = extractions.some((e) => !e.isSync); + + if (isInline) { + if (hasAnyNonSync) { + const noopSymbol = isDevMode ? '_noopQrlDEV' : '_noopQrl'; + if (!alreadyImported.has(noopSymbol)) { + neededImports.set(noopSymbol, '@qwik.dev/core'); + } + } + const needsCapturesImport = extractions.some( + (e) => + !e.isSync && + e.captureNames.length > 0 && + !( + inlineOptions && + isStrippedExtraction(e, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers) + ) + ); + if (needsCapturesImport && !alreadyImported.has('_captures')) { + neededImports.set('_captures', '@qwik.dev/core'); + } + } else if (inlineOptions && !inlineOptions.inline) { + if (hasTopLevelNonSync) { + const hasNonStripped = topLevel.some( + (e) => + !e.isSync && + !isStrippedExtraction(e, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers) + ); + const hasStripped = topLevel.some( + (e) => + !e.isSync && + isStrippedExtraction(e, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers) + ); + if (hasNonStripped) { + const qrlSymbol = isDevMode ? 'qrlDEV' : 'qrl'; + if (!alreadyImported.has(qrlSymbol)) { + neededImports.set(qrlSymbol, '@qwik.dev/core'); + } + } + if (hasStripped) { + const noopSymbol = isDevMode ? '_noopQrlDEV' : '_noopQrl'; + if (!alreadyImported.has(noopSymbol)) { + neededImports.set(noopSymbol, '@qwik.dev/core'); + } + } + } + } else { + const hasWorker = topLevel.some((e) => !e.isSync && isWorkerExtraction(e)); + const hasNonWorkerNonSync = topLevel.some((e) => !e.isSync && !isWorkerExtraction(e)); + if (hasWorker) { + const chunkSymbol = isDevMode ? '_qrlWithChunkDEV' : '_qrlWithChunk'; + if (!alreadyImported.has(chunkSymbol)) { + neededImports.set(chunkSymbol, '@qwik.dev/core'); + } + } + if (hasNonWorkerNonSync) { + const qrlSymbol = isDevMode ? 'qrlDEV' : 'qrl'; + if (!alreadyImported.has(qrlSymbol)) { + neededImports.set(qrlSymbol, '@qwik.dev/core'); + } + const hasInlinedQrlLocal = topLevel.some( + (e) => e.isInlinedQrl && !ctx.relPath.includes('node_modules') + ); + if (hasInlinedQrlLocal && !isDevMode && !alreadyImported.has('qrlDEV')) { + neededImports.set('qrlDEV', '@qwik.dev/core'); + } + } + } + + for (const ext of topLevel) { + if (ext.isSync) { + if (!alreadyImported.has('_qrlSync')) { + neededImports.set('_qrlSync', '@qwik.dev/core'); + } + continue; + } + if (ext.isBare) { + if (inlinedQrlSymbols.has(ext.symbolName) && !alreadyImported.has('qrl')) { + neededImports.set('qrl', '@qwik.dev/core'); + } + continue; + } + + const qrlCallee = ext.qrlCallee; + if (qrlCallee && !alreadyImported.has(qrlCallee)) { + if (!isCustomInlined(ext, originalImports)) { + neededImports.set(qrlCallee, getQrlImportSource(qrlCallee, ext.importSource)); + } + } + } + + for (const { callee, source } of noArgQrlCallees) { + if (!neededImports.has(callee)) { + neededImports.set(callee, getQrlImportSource(callee, source)); + } + } + for (const { sym, src } of eventHandlerExtraImports) { + if (!alreadyImported.has(sym) && !neededImports.has(sym)) { + neededImports.set(sym, src); + } + } + + if (jsxResult) { + for (const sym of jsxResult.neededImports) { + if (!alreadyImported.has(sym)) { + neededImports.set(sym, '@qwik.dev/core'); + } + } + if (jsxResult.needsFragment && !alreadyImported.has('_Fragment')) { + neededImports.set('Fragment as _Fragment', '@qwik.dev/core/jsx-runtime'); + } + } + + // Lib mode keeps the JSX runtime import available for downstream + // consumers even when the parent module doesn't reference it. + if (ctx.isLibMode && jsxResult && !alreadyImported.has('jsx')) { + neededImports.set('jsx as _jsx', '@qwik.dev/core/jsx-runtime'); + } +} + +export function buildQrlDeclarations(ctx: RewriteContext): void { + const { + extractions, + inlineOptions, + isDevMode, + devFilePath, + isInline, + inlinedQrlSymbols, + explicitExtensions, + outputExtension, + relPath, + } = ctx; + const topLevelNonSync = extractions.filter( + (e) => !e.isSync && e.parent === null && !inlinedQrlSymbols.has(e.symbolName) + ); + const allNonSync = extractions.filter((e) => !e.isSync && !inlinedQrlSymbols.has(e.symbolName)); + + // A moved source-decl carries its `componentQrl(q_)` wrap into the + // sibling segment, so the parent emits a bare `qrl(...)` registration + // instead of `const q_ = qrl(...)` — the binding is unreferenced here. + const movedMarkerSymbols = new Set(); + if (ctx.migrationDecisions && ctx.moduleLevelDecls) { + const fileStem = relPath.split('/').pop() ?? relPath; + for (const decision of ctx.migrationDecisions) { + if (decision.action !== 'move') { + continue; + } + const varName = escapeSymbol(decision.varName); + const exact = `${fileStem}_${varName}`; + const prefix = `${exact}_`; + const bareVarPrefix = `${varName}_`; + // A moved helper can own several extractions, so scan all — every one + // loses its parent-side `q_` binding when the decl moves out. + for (const e of extractions) { + if (e.parent !== null) { + continue; + } + if ( + e.displayName === exact || + e.displayName.startsWith(prefix) || + e.displayName === varName || + e.displayName.startsWith(bareVarPrefix) + ) { + movedMarkerSymbols.add(e.symbolName); + } + } + } + } + + let strippedCounter = 0; + let inlineSentinelOffset = 0; + const deferredStrippedQrlVars = new Set(); + + if (isInline) { + for (const ext of allNonSync) { + const isRegCtx = matchesRegCtxName(ext, inlineOptions?.regCtxName); + const stripped = + !isRegCtx && + inlineOptions && + isStrippedExtraction(ext, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers); + + const offset = inlineSentinelOffset; + inlineSentinelOffset += inlineSentinelStep(ext, !!stripped, inlineOptions?.regCtxName); + if (stripped) { + const counter = 0xffff0000 + offset; + const qrlVar = `q_qrl_${counter}`; + if (isDevMode && devFilePath) { + ctx.qrlDecls.push( + markMovedCaptures( + buildNoopQrlDevForVar(qrlVar, ext.symbolName, { + file: devFilePath, + lo: 0, + hi: 0, + displayName: ext.displayName, + }), + ext + ) + ); + } else { + ctx.qrlDecls.push(markMovedCaptures(buildNoopQrlForVar(qrlVar, ext.symbolName), ext)); + } + ctx.qrlVarNames.set(ext.symbolName, qrlVar); + if (ext.parent !== null) { + deferredStrippedQrlVars.add(qrlVar); + } + } else { + if (isDevMode && devFilePath) { + ctx.qrlDecls.push( + markMovedCaptures( + buildNoopQrlDevDeclaration(ext.symbolName, { + file: devFilePath, + lo: ext.loc[0], + hi: ext.loc[1], + displayName: ext.displayName, + }), + ext + ) + ); + } else { + ctx.qrlDecls.push(markMovedCaptures(buildNoopQrlDeclaration(ext.symbolName), ext)); + } + ctx.qrlVarNames.set(ext.symbolName, `q_${ext.symbolName}`); + } + } + } else if (inlineOptions && !inlineOptions.inline) { + for (const ext of topLevelNonSync) { + const stripped = isStrippedExtraction( + ext, + inlineOptions.stripCtxName, + inlineOptions.stripEventHandlers + ); + + if (stripped) { + const idx = strippedCounter++; + if (isDevMode && devFilePath) { + ctx.qrlDecls.push( + markMovedCaptures( + buildStrippedNoopQrlDev(ext.symbolName, idx, { + file: devFilePath, + lo: 0, + hi: 0, + displayName: ext.displayName, + }), + ext + ) + ); + } else { + ctx.qrlDecls.push(markMovedCaptures(buildStrippedNoopQrl(ext.symbolName, idx), ext)); + } + const counter = 0xffff0000 + idx * 2; + ctx.qrlVarNames.set(ext.symbolName, `q_qrl_${counter}`); + } else { + if (isDevMode && devFilePath) { + const devExt = explicitExtensions ? (outputExtension ?? '.js') : undefined; + ctx.qrlDecls.push( + markMovedCaptures( + buildQrlDevDeclaration( + ext.symbolName, + ext.canonicalFilename, + devFilePath, + ext.loc[0], + ext.loc[1], + ext.displayName, + devExt + ), + ext + ) + ); + } else { + ctx.qrlDecls.push( + markMovedCaptures( + buildQrlDeclaration( + ext.symbolName, + ext.canonicalFilename, + explicitExtensions, + ext.extension, + outputExtension + ), + ext + ) + ); + } + ctx.qrlVarNames.set(ext.symbolName, `q_${ext.symbolName}`); + } + } + } else { + const devExt = explicitExtensions ? (outputExtension ?? '.js') : undefined; + for (const ext of topLevelNonSync) { + if (isWorkerExtraction(ext)) { + const varName = + ctx.earlyQrlVarNames.get(ext.symbolName) ?? + `q_qrl_${getSentinelCounter(strippedCounter++)}`; + const devMeta = + isDevMode && devFilePath + ? formatDevMeta({ + file: devFilePath, + lo: ext.loc[0], + hi: ext.loc[1], + displayName: ext.displayName, + }) + : undefined; + ctx.qrlDecls.push( + buildWorkerQrlDeclaration( + varName, + ext.symbolName, + ext.canonicalFilename, + explicitExtensions, + outputExtension, + devMeta + ) + ); + ctx.qrlVarNames.set(ext.symbolName, varName); + continue; + } + if (movedMarkerSymbols.has(ext.symbolName) && !(isDevMode && devFilePath)) { + const fileExt = explicitExtensions ? (outputExtension ?? '.js') : ''; + ctx.qrlDecls.push( + `qrl(()=>import("./${ext.canonicalFilename}${fileExt}"), "${ext.symbolName}");` + ); + // Intentionally not registering `q_` in `qrlVarNames`: the + // parent no longer declares it, so a stray reference should surface + // as an error rather than be silently named. + continue; + } + if (isDevMode && devFilePath) { + ctx.qrlDecls.push( + markMovedCaptures( + buildQrlDevDeclaration( + ext.symbolName, + ext.canonicalFilename, + devFilePath, + ext.loc[0], + ext.loc[1], + ext.displayName, + devExt + ), + ext + ) + ); + } else if (ext.isInlinedQrl && !relPath.includes('node_modules')) { + const inlinedDevFile = devFilePath ?? buildDevFilePath(relPath, '', undefined); + ctx.qrlDecls.push( + buildQrlDevDeclaration( + ext.symbolName, + ext.canonicalFilename, + inlinedDevFile, + ext.loc[0], + ext.loc[1], + ext.displayName, + devExt + ) + ); + } else { + ctx.qrlDecls.push( + markMovedCaptures( + buildQrlDeclaration( + ext.symbolName, + ext.canonicalFilename, + explicitExtensions, + ext.extension, + outputExtension + ), + ext + ) + ); + } + ctx.qrlVarNames.set(ext.symbolName, `q_${ext.symbolName}`); + } + } + + // Sort by segment symbol, not raw text — bare `qrl(...)` registrations + // interleave with `const q_* = ...` declarations in symbol order. + const declSortKey = (line: string): string => + /^const q_([\w$]+)/.exec(line)?.[1] ?? /"([^"]+)"\)?;?\s*$/.exec(line)?.[1] ?? line; + ctx.qrlDecls.sort((a, b) => { + const rankA = deferredStrippedQrlVars.has(/^const ([\w$]+)/.exec(a)?.[1] ?? '') ? 1 : 0; + const rankB = deferredStrippedQrlVars.has(/^const ([\w$]+)/.exec(b)?.[1] ?? '') ? 1 : 0; + if (rankA !== rankB) { + return rankA - rankB; + } + const ka = declSortKey(a); + const kb = declSortKey(b); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); +} + +export function buildInlineSCalls(ctx: RewriteContext): void { + if (!ctx.isInline) { + return; + } + + const { + extractions, + inlineOptions, + jsxOptions, + isDevMode, + relPath, + s, + program, + neededImports, + alreadyImported, + qrlVarNames, + inlinedQrlSymbols, + mode, + migrationDecisions, + } = ctx; + const allNonSync = extractions.filter((e) => !e.isSync && !inlinedQrlSymbols.has(e.symbolName)); + + // A migrated decl is reachable directly — module scope under inline/hoist, + // or an `_auto_` import under segment-file — never via `_captures`, so + // unpacking it from `_captures[N]` would deliver undefined. Exclude these. + const migratedNames: ReadonlySet = new Set( + (migrationDecisions ?? []) + .filter((d) => d.action === 'reexport' || d.action === 'move') + .map((d) => d.varName) + ); + + const isHoist = inlineOptions?.entryType === 'hoist'; + + let inlineSegmentJsxOptions: InlineSegmentJsxOptions | undefined = jsxOptions?.enableJsx + ? { + enableJsx: true, + importedNames: jsxOptions.importedNames, + // JSX dev-info `fileName:` honors only an explicit user `devPath`, + // otherwise falling back to `relPath` — not the composed `devFilePath`. + devOptions: isDevMode ? { relPath: ctx.userDevPath ?? relPath } : undefined, + source: isDevMode ? ctx.source : undefined, + keyCounterStart: isHoist ? ctx.jsxKeyCounterValue : undefined, + relPath, + } + : undefined; + + // The parent's JSX transform already hoisted `_hf` decls into module scope; + // number past them. + const parentHoistCount = ctx.jsxResult ? ctx.jsxResult.hoistedDeclarations.length / 2 : 0; + const sharedHoister = jsxOptions?.enableJsx ? new SignalHoister(parentHoistCount) : undefined; + // Separate from `sharedHoister` (which gets reordered) so emitted `_hf` + // refs stay aligned with their decls. + const sharedJsxCallHoister = new SignalHoister(parentHoistCount); + + const nestedExts: ExtractionResult[] = []; + const topNonComponent: ExtractionResult[] = []; + const topComponent: ExtractionResult[] = []; + + for (const ext of allNonSync) { + const isRegCtx = matchesRegCtxName(ext, inlineOptions?.regCtxName); + const isStrippedExt = + !isRegCtx && + inlineOptions && + isStrippedExtraction(ext, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers); + if (isStrippedExt) { + continue; + } + + if (ext.parent !== null) { + nestedExts.push(ext); + } else if (ext.ctxName === 'component') { + topComponent.push(ext); + } else { + topNonComponent.push(ext); + } + } + + const extContainingStmtStart = new Map(); + if (isHoist) { + for (const ext of allNonSync) { + for (const stmt of program.body) { + if (stmt.type === 'ImportDeclaration') { + continue; + } + if (ext.callStart >= stmt.start && ext.callStart < stmt.end) { + extContainingStmtStart.set(ext.symbolName, stmt.start); + break; + } + } + } + } + + const processExtraction = (ext: ExtractionResult) => { + const varName = qrlVarNames.get(ext.symbolName) ?? `q_${ext.symbolName}`; + const { + transformedBody: rawBody, + additionalImports, + hoistedDeclarations, + keyCounterValue, + } = transformInlineSegmentBody( + ext, + extractions, + qrlVarNames, + inlineSegmentJsxOptions, + inlineOptions?.regCtxName, + sharedHoister, + ctx.closureNodes, + ctx.source, + ctx.originalImports, + ctx.relPath, + ctx.jsxCallSkipKeyBases?.get(ext.argStart) ?? ctx.jsxKeyCounterValue, + migratedNames, + inlineOptions?.stripCtxName, + inlineOptions?.stripEventHandlers, + // Lib output serves both environments — never fold env consts (rust parity). + ctx.isLibMode ? undefined : ctx.isServer, + deriveIsDev(ctx.mode), + sharedJsxCallHoister, + ctx.elementQpParamsMap + ); + + let sigRewrittenBody = rawBody; + if (hasUnderscorePlaceholderParams(ext.paramNames, ext.movedCaptures)) { + sigRewrittenBody = rewriteFunctionSignature(rawBody, ext.paramNames); + } + + if (ctx.mode === 'hmr' && ctx.devFilePath && !ext.isBare && isAnyComponentCtx(ext.ctxName)) { + sigRewrittenBody = injectUseHmrIntoInlineBody(sigRewrittenBody, ctx.devFilePath); + neededImports.set('_useHmr', '@qwik.dev/core'); + } + + const isRegCtxMatch = matchesRegCtxName(ext, inlineOptions?.regCtxName); + let transformedBody = sigRewrittenBody; + if (isRegCtxMatch) { + transformedBody = `/*#__PURE__*/ _regSymbol(${rawBody}, "${ext.hash}")`; + neededImports.set('_regSymbol', '@qwik.dev/core'); + } + + // A body numbered from a reserved base must not rewind the shared counter. + const usedReservedBase = ctx.jsxCallSkipKeyBases?.has(ext.argStart) === true; + if (isHoist && keyCounterValue !== undefined && inlineSegmentJsxOptions && !usedReservedBase) { + ctx.jsxKeyCounterValue = keyCounterValue; + inlineSegmentJsxOptions = { + ...inlineSegmentJsxOptions, + keyCounterStart: ctx.jsxKeyCounterValue, + }; + } else if (keyCounterValue !== undefined && !usedReservedBase) { + // The JSX key counter is shared across every `.s(body)` block; without + // threading it, the next body's keys would restart at 0. + ctx.jsxKeyCounterValue = keyCounterValue; + } + ctx.inlineHoistedDeclarations.push(...hoistedDeclarations); + for (const [sym, src] of additionalImports) { + if (!alreadyImported.has(sym) && !neededImports.has(sym)) { + neededImports.set(sym, src); + } + } + + const forceInlineForRegCtx = isRegCtxMatch && inlineOptions?.entryType === 'inline'; + if (isHoist && !forceInlineForRegCtx) { + // For a bare-identifier body referencing a module-level decl + // (`useStyle$(STYLES)`), the hoist const-decl wrapper is redundant — + // emit `q_X.s(STYLES)` and route to `ctx.sCalls` so `placeSCalls` + // positions it after the referenced decl (declared later otherwise → TDZ). + const moduleDeclNames = ctx.moduleLevelDecls + ? new Set(ctx.moduleLevelDecls.map((d) => d.name)) + : undefined; + const trimmedBody = transformedBody.trim(); + const bareIdentMatch = /^[A-Za-z_$][\w$]*$/.exec(trimmedBody); + if ( + !isRegCtxMatch && + moduleDeclNames && + bareIdentMatch !== null && + moduleDeclNames.has(trimmedBody) + ) { + ctx.sCalls.push(buildSCall(varName, trimmedBody)); + } else { + let hoistBody = transformedBody; + // Parenthesize so an object-literal body parses as an expression, + // not a block with labeled statements. + let code = stripTypeScript( + '__body__.tsx', + `(${hoistBody})`, + {}, + `hoisted segment "${ext.symbolName}"`, + { filename: ctx.relPath, text: ctx.source } + ); + if (code.endsWith(';\n')) { + code = code.slice(0, -2); + } else if (code.endsWith(';')) { + code = code.slice(0, -1); + } + if (code.startsWith('(') && code.endsWith(')')) { + code = code.slice(1, -1); + } + hoistBody = code; + const constDecl = buildHoistConstDecl(ext.symbolName, hoistBody); + const sCall = buildHoistSCall(varName, ext.symbolName); + const stmtStart = extContainingStmtStart.get(ext.symbolName); + if (stmtStart !== undefined) { + s.appendLeft(stmtStart, constDecl + '\n' + sCall + '\n'); + } else { + ctx.sCalls.push(constDecl); + ctx.sCalls.push(sCall); + } + } + } else { + ctx.sCalls.push(buildSCall(varName, transformedBody)); + } + }; + + for (const ext of nestedExts) { + processExtraction(ext); + } + for (const ext of topNonComponent) { + processExtraction(ext); + } + for (const ext of topComponent) { + processExtraction(ext); + } + + const childrenByParent = new Map(); + for (const ext of allNonSync) { + if (ext.parent === null) { + continue; + } + const children = childrenByParent.get(ext.parent) ?? []; + children.push(ext); + childrenByParent.set(ext.parent, children); + } + const callOrder = new Map(); + const visit = (ext: ExtractionResult): void => { + for (const child of (childrenByParent.get(ext.symbolName) ?? []).sort( + (a, b) => a.callStart - b.callStart + )) { + visit(child); + } + const rank = callOrder.size; + callOrder.set(ext.symbolName, rank); + callOrder.set(qrlVarNames.get(ext.symbolName) ?? `q_${ext.symbolName}`, rank); + }; + for (const ext of allNonSync + .filter((ext) => ext.parent === null) + .sort((a, b) => a.callStart - b.callStart)) { + visit(ext); + } + const callRank = (statement: string): number => { + const name = /^(?:const\s+)?([\w$]+)(?:\s*=|\.s\()/.exec(statement)?.[1]; + return name === undefined + ? Number.MAX_SAFE_INTEGER + : (callOrder.get(name) ?? Number.MAX_SAFE_INTEGER); + }; + ctx.sCalls.sort((a, b) => callRank(a) - callRank(b)); + + const jsxCallDecls = sharedJsxCallHoister.getDeclarations(); + if (sharedHoister || jsxCallDecls.length > 0) { + ctx.inlineHoistedDeclarations.length = 0; + if (sharedHoister) { + ctx.inlineHoistedDeclarations.push(...sharedHoister.getDeclarations()); + } + ctx.inlineHoistedDeclarations.push(...jsxCallDecls); + } +} + +/** + * Marks an export whose init is QRL-wrapped (`component$`/`componentQrl`) — the sCall placement + * anchor: self-referencing sCalls must follow it to avoid TDZ at module load. + */ +function isMarkerLikeCall(init: AstNode | null | undefined): boolean { + if (!init || init.type !== 'CallExpression' || init.callee?.type !== 'Identifier') { + return false; + } + const name = init.callee.name; + return name.endsWith('$') || name.endsWith('Qrl'); +} + +function findExportedMarkerNames(program: AstProgram): Set { + const names = new Set(); + for (const stmt of program.body) { + if ( + stmt.type !== 'ExportNamedDeclaration' || + stmt.declaration?.type !== 'VariableDeclaration' + ) { + continue; + } + for (const decl of stmt.declaration.declarations ?? []) { + if (decl.id?.type !== 'Identifier' || !isMarkerLikeCall(decl.init)) { + continue; + } + names.add(decl.id.name); + } + } + return names; +} + +function findMarkerExportAnchor(program: AstProgram): { start: number; end: number } | null { + for (const stmt of program.body) { + if (stmt.type === 'ExportDefaultDeclaration') { + return { start: stmt.start, end: stmt.end }; + } + if ( + stmt.type !== 'ExportNamedDeclaration' || + stmt.declaration?.type !== 'VariableDeclaration' + ) { + continue; + } + if (isMarkerLikeCall(stmt.declaration.declarations?.[0]?.init)) { + return { start: stmt.start, end: stmt.end }; + } + } + return null; +} + +const wordBoundaryTesterCache = new Map(); + +function wordBoundaryTester(name: string): RegExp { + let tester = wordBoundaryTesterCache.get(name); + if (!tester) { + tester = wholeIdentifierPattern(name); + wordBoundaryTesterCache.set(name, tester); + } + return tester; +} + +function findLastReferencedDeclEnd( + sCalls: readonly string[], + decls: readonly ModuleLevelDecl[] +): number | null { + let maxEnd = -1; + for (const decl of decls) { + if (decl.declEnd <= maxEnd) { + continue; + } + const wb = wordBoundaryTester(decl.name); + if (sCalls.some((sc) => wb.test(sc))) { + maxEnd = decl.declEnd; + } + } + return maxEnd >= 0 ? maxEnd : null; +} + +/** + * Detects a TDZ-sensitive forward dependency: the latest decl an sCall references that is declared + * _after_ `threshold` (e.g. `q_useStyle.s(STYLES)` where `const STYLES` follows the marker export). + * `placeSCalls` uses it to position the sCall after that decl instead of before it. Null when + * none. + */ +function findForwardReferencedDeclEnd( + sCall: string, + decls: readonly ModuleLevelDecl[], + threshold: number +): number | null { + let maxEnd = -1; + for (const decl of decls) { + if (decl.declStart <= threshold) { + continue; + } + if (decl.declEnd <= maxEnd) { + continue; + } + if (wordBoundaryTester(decl.name).test(sCall)) { + maxEnd = decl.declEnd; + } + } + return maxEnd >= 0 ? maxEnd : null; +} + +function partitionSCallsBySelfRef( + sCalls: readonly string[], + exportedNames: ReadonlySet +): { beforeExport: string[]; afterExport: string[] } { + const beforeExport: string[] = []; + const afterExport: string[] = []; + for (const sCall of sCalls) { + const refsExport = [...exportedNames].some((n) => wordBoundaryTester(n).test(sCall)); + (refsExport ? afterExport : beforeExport).push(sCall); + } + return { beforeExport, afterExport }; +} + +function orderedNeededImports(ctx: RewriteContext): Array<[string, string]> { + const entries = Array.from(ctx.neededImports.entries()); + const ordered: Array<[string, string]> = []; + const used = new Set(); + const add = (importedName: string): void => { + const entry = entries.find( + ([key]) => !used.has(key) && (key === importedName || key.startsWith(`${importedName} as `)) + ); + if (entry) { + used.add(entry[0]); + ordered.push(entry); + } + }; + + for (const ext of ctx.topLevel) { + if (ext.isSync) { + add('_qrlSync'); + continue; + } + if (!ext.isBare && ext.qrlCallee) { + add(ext.qrlCallee); + } + if (ctx.isInline) { + add(ctx.isDevMode ? '_noopQrlDEV' : '_noopQrl'); + } else if (ctx.inlineOptions && !ctx.inlineOptions.inline) { + add( + isStrippedExtraction( + ext, + ctx.inlineOptions.stripCtxName, + ctx.inlineOptions.stripEventHandlers + ) + ? ctx.isDevMode + ? '_noopQrlDEV' + : '_noopQrl' + : ctx.isDevMode + ? 'qrlDEV' + : 'qrl' + ); + } else { + add( + isWorkerExtraction(ext) + ? ctx.isDevMode + ? '_qrlWithChunkDEV' + : '_qrlWithChunk' + : ctx.isDevMode + ? 'qrlDEV' + : 'qrl' + ); + } + } + ordered.push(...entries.filter(([key]) => !used.has(key))); + return ordered; +} + +/** + * Places each sCall at one `MagicString` offset. A per-sCall forward dependency (references a decl + * declared after the marker anchor) is spliced right after that decl to avoid TDZ; the rest group + * at the marker anchor — self-referencing sCalls after the export, others before — else after the + * last referenced decl, else appended at end of file. + */ +function placeSCalls( + s: MagicString, + program: AstProgram, + sCalls: readonly string[], + moduleLevelDecls: readonly ModuleLevelDecl[] | undefined +): void { + if (sCalls.length === 0) { + return; + } + + const markerAnchor = findMarkerExportAnchor(program); + const decls = moduleLevelDecls ?? []; + + const groupedSCalls: string[] = []; + for (const sCall of sCalls) { + const forwardDeclEnd = + markerAnchor && decls.length > 0 + ? findForwardReferencedDeclEnd(sCall, decls, markerAnchor.end) + : null; + if (forwardDeclEnd !== null) { + s.appendRight(forwardDeclEnd, '\n' + sCall); + } else { + groupedSCalls.push(sCall); + } + } + if (groupedSCalls.length === 0) { + return; + } + + if (markerAnchor) { + const exportedNames = findExportedMarkerNames(program); + const { beforeExport, afterExport } = partitionSCallsBySelfRef(groupedSCalls, exportedNames); + if (beforeExport.length > 0) { + s.appendLeft(markerAnchor.start, beforeExport.join('\n') + '\n'); + } + if (afterExport.length > 0) { + s.appendRight(markerAnchor.end, '\n' + afterExport.join('\n')); + } + return; + } + + const lastDeclEnd = decls.length > 0 ? findLastReferencedDeclEnd(groupedSCalls, decls) : null; + if (lastDeclEnd !== null) { + s.appendRight(lastDeclEnd, '\n' + groupedSCalls.join('\n')); + return; + } + + s.append('\n' + groupedSCalls.join('\n')); +} + +function placeLibReferencedDeclarations(ctx: RewriteContext): void { + const anchor = findMarkerExportAnchor(ctx.program); + if (!anchor || !ctx.moduleLevelDecls) { + return; + } + for (const decl of [...ctx.moduleLevelDecls].reverse()) { + if (decl.hasSideEffects || decl.declStart < anchor.end) { + continue; + } + const tester = wordBoundaryTester(decl.name); + if (!ctx.extractions.some((ext) => tester.test(ext.bodyText))) { + continue; + } + let end = decl.declEnd; + if (ctx.source[end] === '\n') { + end++; + } + ctx.s.move(decl.declStart, end, anchor.start); + } +} + +export function assembleOutput(ctx: RewriteContext): string { + const { + s, + source, + program, + survivingUserImports, + survivingImportInfos, + jsxResult, + inlineHoistedDeclarations, + qrlDecls, + sCalls, + migrationDecisions, + moduleLevelDecls, + jsxOptions, + transpileTs, + } = ctx; + + const importStatements = orderedNeededImports(ctx).map( + ([symbol, src]) => `import { ${symbol} } from "${src}";` + ); + const sideEffectImports = survivingUserImports.filter( + (_, index) => survivingImportInfos[index]?.isSideEffect + ); + const valueImports = survivingUserImports.filter( + (_, index) => !survivingImportInfos[index]?.isSideEffect + ); + + const preamble: string[] = [...sideEffectImports]; + if (importStatements.length > 0) { + preamble.push(...importStatements); + } + if (valueImports.length > 0) { + preamble.push(...valueImports); + } + + const allHoistedDecls: string[] = []; + if (jsxResult && jsxResult.hoistedDeclarations.length > 0) { + allHoistedDecls.push(...jsxResult.hoistedDeclarations); + } + if (inlineHoistedDeclarations.length > 0) { + allHoistedDecls.push(...inlineHoistedDeclarations); + } + if (allHoistedDecls.length > 0) { + preamble.push('//'); + preamble.push(...allHoistedDecls); + } + if (qrlDecls.length > 0) { + preamble.push('//'); + preamble.push(...qrlDecls); + } + if (sCalls.length === 0) { + preamble.push('//'); + } + + s.prepend(preamble.join('\n') + '\n'); + + if (migrationDecisions && moduleLevelDecls) { + const removedRanges = new Set(); + for (const decision of migrationDecisions) { + const decl = moduleLevelDecls.find((d) => d.name === decision.varName); + if (!decl) { + continue; + } + const rangeKey = `${decl.declStart}:${decl.declEnd}`; + if (removedRanges.has(rangeKey)) { + continue; + } + if ( + decision.action === 'keep' && + decision.reason === MIG_REASON.KEEP_UNUSED && + decl.hasSideEffects + ) { + const statement = program.body.find( + (node) => node.start === decl.declStart && node.end === decl.declEnd + ); + const declarator = + statement?.type === 'VariableDeclaration' && statement.declarations.length === 1 + ? statement.declarations[0] + : undefined; + if (declarator?.id.type === 'Identifier' && declarator.init) { + s.overwrite( + decl.declStart, + decl.declEnd, + `${s.slice(declarator.init.start, declarator.init.end)};` + ); + removedRanges.add(rangeKey); + } + continue; + } + if (decision.action !== 'move' && decision.action !== 'drop') { + continue; + } + removedRanges.add(rangeKey); + let end = decl.declEnd; + if (end < source.length && source[end] === '\n') { + end++; + } + s.remove(decl.declStart, end); + } + } + + if (ctx.isLibMode) { + placeLibReferencedDeclarations(ctx); + } + placeSCalls(s, program, sCalls, moduleLevelDecls); + + // `_auto_X` re-exports make module-level decls importable by segment files, and are how the + // router finds un-exported `routeLoader$`/`routeAction$` results (rust parity) — without them + // their middleware never runs. Lib mode emits a single module (no segment files) and needs none. + if (!ctx.isLibMode) { + for (const name of autoExportedNames(migrationDecisions, moduleLevelDecls)) { + s.append(`\nexport { ${name} as _auto_${name} };`); + } + } + + let finalCode = s.toString(); + + if (ctx.isLibMode) { + finalCode = collapseToLibInlinedQrl(finalCode); + } + + if (transpileTs) { + // Only strip explicit type imports: liveness decisions (unused value + // imports, side-effect downgrades) belong to the pipeline's AST prune. + const tsStripOptions: TransformOptions = { typescript: { onlyRemoveTypeImports: true } }; + if (!jsxOptions?.enableJsx) { + tsStripOptions.jsx = 'preserve'; + } + finalCode = stripTypeScript( + ctx.relPath, + finalCode, + tsStripOptions, + `rewritten module "${ctx.relPath}"`, + { filename: ctx.relPath, text: ctx.source } + ); + } + + return finalCode; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/predicates.ts b/packages/ts-optimizer/src/optimizer/rewrite/predicates.ts new file mode 100644 index 00000000000..55ff97456c2 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/predicates.ts @@ -0,0 +1,119 @@ +/** Shared predicates over `ExtractionResult` and related extraction shapes. */ + +import type { ExtractionResult } from '../extraction/extract.js'; + +/** Whether `ext.calleeName` matches a registered context name (e.g., `'server'` matches `server$`). */ +export function matchesRegCtxName(ext: ExtractionResult, regCtxName?: readonly string[]): boolean { + if (!regCtxName || regCtxName.length === 0) { + return false; + } + for (const name of regCtxName) { + if (ext.calleeName === name + '$') { + return true; + } + } + return false; +} + +/** + * Whether the extraction's context is a JSX event handler (`onClick$`) or a JSX-prop binding — both + * share the same QRL-as-attribute output shape. + */ +export function isEventHandlerOrJsxProp(ctxKind: ExtractionResult['ctxKind'] | undefined): boolean { + return ctxKind === 'eventHandler' || ctxKind === 'jSXProp'; +} + +/** Whether the parameter list carries loop-handler positional padding. */ +export function hasUnderscorePlaceholderParams( + paramNames: readonly string[], + movedCaptures?: boolean +): boolean { + // Promotion appends captures after the (event, element) positions — with + // preserved author params the padded prefix may be absent, so the + // `movedCaptures` flag is the authoritative signal. + if (movedCaptures && paramNames.length >= 3) { + return true; + } + return paramNames.length >= 2 && paramNames[1] === '_1'; +} + +/** + * Whether `ctxName` is a pre-extraction component segment — the source-level `component$(...)` + * marker or its `componentQrl(...)` sibling. For the post-extraction `'component'` form, see + * {@link isAnyComponentCtx}. + */ +export function isComponentCtx(ctxName: string): boolean { + return ctxName === 'component$' || ctxName === 'componentQrl'; +} + +/** + * Whether `ctxName` is a component segment in any phase: the pre-extraction + * `component$`/`componentQrl` markers or the post-extraction `'component'` form. Three-arm version + * of {@link isComponentCtx}. + */ +export function isAnyComponentCtx(ctxName: string): boolean { + return isComponentCtx(ctxName) || ctxName === 'component'; +} + +/** + * Whether a segment should be stripped to a `null` body: its ctxName starts with a `stripCtxName` + * prefix, or `stripEventHandlers` is set and ctxKind is `"eventHandler"`. Stripped segments emit + * `export const {symbolName} = null;`. + */ +export function isStrippedSegment( + ctxName: string, + ctxKind: string, + stripCtxName?: readonly string[], + stripEventHandlers?: boolean +): boolean { + if (stripCtxName && stripCtxName.length > 0) { + for (const prefix of stripCtxName) { + if (ctxName.startsWith(prefix)) { + return true; + } + } + } + + if (stripEventHandlers && ctxKind === 'eventHandler') { + return true; + } + + return false; +} + +/** + * Strip decision for a whole extraction. + * + * `inlinedQrl` segments are pre-baked QRLs from an upstream tool and are **never** stripped, + * regardless of `stripCtxName`/`stripEventHandlers`: stripping one whose ctxName happens to match a + * strip prefix (e.g. a router lib's `serverQrl` dispatcher vs `stripCtxName: ['server']`) would + * collapse its QRL to a chunkless `_noopQrl` and break `server$` RPC at runtime. + */ +export function isStrippedExtraction( + ext: { readonly ctxName: string; readonly ctxKind: string; readonly isInlinedQrl?: boolean }, + stripCtxName?: readonly string[], + stripEventHandlers?: boolean +): boolean { + if (ext.isInlinedQrl) { + return false; + } + return isStrippedSegment(ext.ctxName, ext.ctxKind, stripCtxName, stripEventHandlers); +} + +export function advancesSentinelCounter( + ext: { readonly parent: string | null; readonly ctxKind: string }, + isStripped: boolean +): boolean { + return isStripped || (ext.parent !== null && ext.ctxKind !== 'jSXProp'); +} + +export function inlineSentinelStep( + ext: ExtractionResult, + isStripped: boolean, + regCtxName?: readonly string[] +): number { + if (!advancesSentinelCounter(ext, isStripped)) { + return 0; + } + return matchesRegCtxName(ext, regCtxName) ? 3 : 2; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/props-field-rewrite.ts b/packages/ts-optimizer/src/optimizer/rewrite/props-field-rewrite.ts new file mode 100644 index 00000000000..143e3559b5d --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/props-field-rewrite.ts @@ -0,0 +1,117 @@ +import { buildPropertyAccessor } from '../ast/identifier-name.js'; +import { + applyReplacements, + collectRangeReplacements, + expressionNeedsParens, + isReplaceableIdentifierPosition, + type RangeReplacementCollector, +} from '../edit/range-replace.js'; +import { createTransformSession } from '../edit/transform-session.js'; + +interface RewritePropsFieldReferencesOptions { + memberPropertyMode?: 'all' | 'nonComputed'; + /** + * LocalName → default expression source. When set, matching fields emit `(_rawProps. ?? + * )` instead of bare `_rawProps.`. + */ + defaultValues?: ReadonlyMap; +} + +/** + * Collector that rewrites identifier references matching `fieldMap` keys to `_rawProps.`. + * Shorthand `Property` values expand to `key: `. + */ +function propsFieldIdentifierCollector( + fieldMap: ReadonlyMap, + defaultValues: ReadonlyMap | undefined, + memberPropertyMode: 'all' | 'nonComputed' | undefined +): RangeReplacementCollector { + return (node, ctx) => { + if (node.type !== 'Identifier') { + return null; + } + const localName = node.name; + const key = fieldMap.get(localName); + if (key === undefined) { + return null; + } + + const isShorthandValue = + ctx.parentKey === 'value' && + ctx.parentNode?.type === 'Property' && + ctx.parentNode?.shorthand === true; + + const isReferencePosition = + isShorthandValue || + isReplaceableIdentifierPosition(ctx.parentKey, ctx.parentNode, { memberPropertyMode }); + if (!isReferencePosition) { + return null; + } + + const baseAccessor = buildPropertyAccessor('_rawProps', key); + const defaultExpr = defaultValues?.get(localName); + let accessor: string; + if (defaultExpr === undefined) { + accessor = baseAccessor; + } else { + // Shorthand expands to Property-value position, which is precedence-safe. + const needsParens = !isShorthandValue && expressionNeedsParens(ctx.parentKey, ctx.parentNode); + accessor = needsParens + ? `(${baseAccessor} ?? ${defaultExpr})` + : `${baseAccessor} ?? ${defaultExpr}`; + } + const replacement = isShorthandValue ? `${key}: ${accessor}` : accessor; + + return { + replacements: [ + { + start: node.start - ctx.exprStart, + end: node.end - ctx.exprStart, + replacement, + }, + ], + }; + }; +} + +/** + * Replace bare references to destructured prop field names with `_rawProps` accessors, using AST + * positions to avoid touching property keys or decl sites. + */ +export function rewritePropsFieldReferences( + bodyText: string, + fieldMap: Map, + options: RewritePropsFieldReferencesOptions +): string { + if (fieldMap.size === 0) { + return bodyText; + } + + let session; + try { + session = createTransformSession(bodyText); + } catch { + return bodyText; + } + + if (!session) { + return bodyText; + } + + const { offset, program, wrappedSource } = session; + const collector = propsFieldIdentifierCollector( + fieldMap, + options.defaultValues, + options.memberPropertyMode + ); + + // Ranges are relative to `wrappedSource`; slicing off the wrapper prefix + // yields the original body's edited form. + const replacements = collectRangeReplacements(program, 0, wrappedSource, [collector]); + if (replacements.length === 0) { + return bodyText; + } + + const edited = applyReplacements(wrappedSource, replacements); + return edited.slice(offset, edited.length - session.wrapperSuffix.length); +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/raw-props.ts b/packages/ts-optimizer/src/optimizer/rewrite/raw-props.ts new file mode 100644 index 00000000000..87b5f5df8bb --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/raw-props.ts @@ -0,0 +1,855 @@ +/** + * Raw props transformation for component$ extractions: rewrites destructured parameters to + * `(_rawProps) => ... _rawProps. ...`. + */ + +import type { + AstCompatNode, + ArrayExpression, + CallExpression, + AstMaybeNode, + AstNode, +} from '../../ast-types.js'; +import { buildPropertyAccessor } from '../ast/identifier-name.js'; +import { rewritePropsFieldReferences } from './props-field-rewrite.js'; +import { + forEachAstChild, + getAssignedIdentifierName, + type AstIdentifierNode, + type AstRangedNode, + getObjectPropertyKeyName, + getPatternProperties, + hasRange, + isAssignmentPatternNode, + isAstNode, + isIdentifierNode, + isRangedIdentifierNode, + isRestElementNode, + isPropertyNode, + isVariableDeclaratorNode, +} from '../ast/guards.js'; +import { + createFunctionTransformSession, + createTransformSession, + insertFunctionBodyPrologue, + type FunctionTransformSession, + type TransformSession, +} from '../edit/transform-session.js'; +import { + collectRangeReplacements, + expressionNeedsParens, + isReplaceableIdentifierPosition, + type RangeReplacementCollector, +} from '../edit/range-replace.js'; +import type { DevSuffixOptions } from '../jsx/jsx.js'; + +function isRawPropsMemberExpression( + node: unknown +): node is AstCompatNode & { object: AstIdentifierNode } { + return ( + isAstNode(node) && + (node.type === 'MemberExpression' || node.type === 'StaticMemberExpression') && + isIdentifierNode(node.object) && + node.object.name === '_rawProps' + ); +} + +function isWCallWithArrayArg( + node: unknown +): node is CallExpression & { arguments: [ArrayExpression & AstRangedNode] } { + if (!isAstNode(node) || node.type !== 'CallExpression') { + return false; + } + if (!isAstNode(node.callee) || node.callee.type !== 'MemberExpression') { + return false; + } + if (!isIdentifierNode(node.callee.property) || node.callee.property.name !== 'w') { + return false; + } + if (!Array.isArray(node.arguments) || node.arguments.length !== 1) { + return false; + } + const arg = node.arguments[0]; + return isAstNode(arg) && arg.type === 'ArrayExpression' && hasRange(arg); +} + +/** + * Options for JSX transpilation within inline .s() body text. `source` is required only when + * `devOptions` is set — used to compute source-relative dev-info positions. + */ +export interface InlineSegmentJsxOptions { + enableJsx: boolean; + importedNames: Set; + devOptions?: DevSuffixOptions; + source?: string; + keyCounterStart?: number; + relPath?: string; +} + +interface IdentifierReplacement { + start: number; + end: number; + key: string; + local: string; + isShorthand?: boolean; + needsParens?: boolean; +} + +interface RawPropsField { + key: string; + local: string; + defaultValue?: string; +} + +/** + * `unsafe` is true when the destructure has a shape consolidation cannot express (nested pattern, + * non-const default, or non-identifier rest); the caller must then abort and preserve the source + * destructure verbatim. + */ +interface RawPropsBindingInfo { + fields: RawPropsField[]; + restElementName: string | null; + unsafe: boolean; +} + +interface SourceRange { + start: number; + end: number; +} + +interface RawPropsTransformPlan { + replacementParamRange?: SourceRange; + removeRange?: SourceRange; + replacementBaseName: string; + restLine?: string; + restLineInPlace?: boolean; + fieldLocalToKey: Map; + fieldLocalToDefault: Map; + excludedRanges: SourceRange[]; +} + +/** Both destructured-first-param projections, computed from one parse. */ +export interface DestructuredFieldInfo { + /** Local binding name → property key name (`bindValue` → `"bind:value"`). */ + readonly fieldMap: Map; + /** Local binding name → destructure-default source text (`some` → `1+2`). */ + readonly fieldDefaults: Map; +} + +/** + * Extract the field-key map and the defaults map from a destructured first parameter in a single + * parse. + */ +export function extractDestructuredFieldInfo(body: string): DestructuredFieldInfo { + const fieldMap = new Map(); + const fieldDefaults = new Map(); + const result: DestructuredFieldInfo = { fieldMap, fieldDefaults }; + + const session = createFunctionTransformSession(body); + if (!session) { + return result; + } + + const params = session.fn.params; + if (params.length === 0) { + return result; + } + + const firstParam = params[0]; + if (firstParam.type !== 'ObjectPattern') { + return result; + } + + const bindings = collectPatternBindings(firstParam, body, session.offset); + // Aborted destructure — return empty maps so downstream consolidation skips + // this parent entirely rather than partially consolidating safe fields only. + if (bindings.unsafe) { + return result; + } + + for (const field of bindings.fields) { + fieldMap.set(field.local, field.key); + if (field.defaultValue !== undefined) { + fieldDefaults.set(field.local, field.defaultValue); + } + } + return result; +} + +/** + * Extract a map from local binding name to property key name from a destructured first parameter. + * Given `({foo, "bind:value": bindValue}) => ...`, returns Map { "foo" -> "foo", "bindValue" -> + * "bind:value" }. + */ +export function extractDestructuredFieldMap(body: string): Map { + return extractDestructuredFieldInfo(body).fieldMap; +} + +/** + * Extract destructure-time default expressions from a function whose first parameter is an + * ObjectPattern (e.g. `some` → `1+2`). Empty map when the destructure aborts under the + * safe-consolidation gate. + */ +export function extractDestructuredFieldDefaultsMap(body: string): Map { + return extractDestructuredFieldInfo(body).fieldDefaults; +} + +function collectPatternBindings( + pattern: unknown, + sourceText?: string, + offset = 0 +): RawPropsBindingInfo { + const fields: RawPropsField[] = []; + const defaultNodes: (unknown | undefined)[] = []; + let restElementName: string | null = null; + let unsafe = false; + + for (const prop of getPatternProperties(pattern)) { + if (isRestElementNode(prop)) { + const restId = isIdentifierNode(prop.argument) ? prop.argument.name : null; + if (restId) { + restElementName = restId; + } else { + unsafe = true; + } + continue; + } + if (!isPropertyNode(prop)) { + continue; + } + + const keyName = getObjectPropertyKeyName(prop.key); + if (!keyName) { + continue; + } + + // A nested pattern value (`{ stuff: { hey } }`) has no `_rawProps.` + // accessor to emit, so consolidation cannot express it — abort. + if (isAstNode(prop.value)) { + const vt = prop.value.type; + if (vt === 'ObjectPattern' || vt === 'ArrayPattern') { + unsafe = true; + continue; + } + } + + const local = getAssignedIdentifierName(prop.value); + if (!local) { + continue; + } + + let defaultValue: string | undefined; + let defaultNode: unknown | undefined; + if (isAssignmentPatternNode(prop.value)) { + defaultNode = prop.value.right; + if (sourceText && hasRange(prop.value.right)) { + const defStart = prop.value.right.start - offset; + const defEnd = prop.value.right.end - offset; + if (defStart >= 0 && defEnd <= sourceText.length) { + defaultValue = sourceText.slice(defStart, defEnd); + } + } + } + + fields.push({ key: String(keyName), local, defaultValue }); + defaultNodes.push(defaultNode); + } + + // After the loop: needs the full binding set; any non-const default aborts. + if (!unsafe) { + const bindingLocals = new Set(fields.map((f) => f.local)); + if (restElementName) { + bindingLocals.add(restElementName); + } + for (const node of defaultNodes) { + if (node !== undefined && defaultExprIsNonConst(node, bindingLocals)) { + unsafe = true; + break; + } + } + } + + return { fields, restElementName, unsafe }; +} + +/** + * A destructure default is non-const — not inline-safe — when its expression contains a call, a + * member access, or a sibling-binding reference (which would dangle once the destructure is + * eliminated). Arrow/function bodies aren't descended; object-literal keys aren't references. + */ +function defaultExprIsNonConst(node: unknown, bindingLocals: ReadonlySet): boolean { + if (!isAstNode(node)) { + return false; + } + const t = node.type; + if (t === 'CallExpression') { + return true; + } + if (t === 'MemberExpression' || t === 'StaticMemberExpression') { + return true; + } + if (t === 'ArrowFunctionExpression' || t === 'FunctionExpression') { + return false; + } + if (isIdentifierNode(node)) { + return bindingLocals.has(node.name); + } + + let found = false; + forEachAstChild(node, (child, key, parent) => { + if (found) { + return; + } + const parentComputed = (parent as { computed?: boolean }).computed; + const isPropertyKey = key === 'key' && parent.type === 'Property' && parentComputed !== true; + if (isPropertyKey) { + return; + } + if (defaultExprIsNonConst(child, bindingLocals)) { + found = true; + } + }); + return found; +} + +function toFieldLocalToKey(fields: RawPropsField[]): Map { + const fieldLocalToKey = new Map(); + for (const field of fields) { + fieldLocalToKey.set(field.local, field.key); + } + return fieldLocalToKey; +} + +function toFieldLocalToDefault(fields: RawPropsField[]): Map { + const fieldLocalToDefault = new Map(); + for (const field of fields) { + if (field.defaultValue !== undefined) { + fieldLocalToDefault.set(field.local, field.defaultValue); + } + } + return fieldLocalToDefault; +} + +function buildRestPropsLine( + baseName: string, + restElementName: string, + fields: RawPropsField[] +): string { + if (fields.length === 0) { + return `const ${restElementName} = _restProps(${baseName});`; + } + + const excludedKeys = fields.map((field) => `"${field.key}"`).join(',\n '); + return `const ${restElementName} = _restProps(${baseName}, [\n ${excludedKeys}\n]);`; +} + +function getStatementRemovalRange( + body: string, + stmt: { start: number; end: number }, + offset: number +): SourceRange { + const stmtStart = stmt.start - offset; + const stmtEnd = stmt.end - offset; + let lineStart = stmtStart; + while (lineStart > 0 && (body[lineStart - 1] === ' ' || body[lineStart - 1] === '\t')) { + lineStart--; + } + + let removeEnd = stmt.end; + if (body.slice(stmtEnd).startsWith('\r\n')) { + removeEnd += 2; + } else if (body.slice(stmtEnd).startsWith('\n')) { + removeEnd += 1; + } + + return { start: offset + lineStart, end: removeEnd }; +} + +function isExpressionBodyArrow(body: AstMaybeNode): boolean { + return body != null && body.type !== 'BlockStatement'; +} + +function blockHasTopLevelReturn(body: AstMaybeNode): boolean { + if (body == null || body.type !== 'BlockStatement') { + return false; + } + return (body.body ?? []).some((stmt) => stmt?.type === 'ReturnStatement'); +} + +function arrowBodyLooksLikeComponent(body: AstMaybeNode): boolean { + return isExpressionBodyArrow(body) || blockHasTopLevelReturn(body); +} + +function analyzeRawPropsTransform( + session: FunctionTransformSession, + body: string +): RawPropsTransformPlan | null { + const { fn, offset } = session; + const firstParam = fn.params[0]; + if (!firstParam) { + return null; + } + + if (firstParam.type === 'Identifier') { + return analyzeBodyDestructurePlan(firstParam.name, fn.body, body, offset); + } + + if (firstParam.type !== 'ObjectPattern') { + return null; + } + + if (!arrowBodyLooksLikeComponent(fn.body)) { + return null; + } + + const bindings = collectPatternBindings(firstParam, body, offset); + // Abort the whole pattern rewrite when any field has an unsupported shape + // (nested pattern, call-expression default, non-ident rest) — preserve the + // source destructure verbatim. + if (bindings.unsafe) { + return null; + } + if (bindings.fields.length === 0 && !bindings.restElementName) { + return null; + } + + return { + replacementParamRange: { start: firstParam.start, end: firstParam.end }, + replacementBaseName: '_rawProps', + restLine: bindings.restElementName + ? buildRestPropsLine('_rawProps', bindings.restElementName, bindings.fields) + : undefined, + fieldLocalToKey: toFieldLocalToKey(bindings.fields), + fieldLocalToDefault: toFieldLocalToDefault(bindings.fields), + excludedRanges: [{ start: firstParam.start, end: firstParam.end }], + }; +} + +function analyzeBodyDestructurePlan( + baseName: string, + fnBody: FunctionTransformSession['fn']['body'], + body: string, + offset: number +): RawPropsTransformPlan | null { + if (!fnBody || fnBody.type !== 'BlockStatement') { + return null; + } + + // The destructured props object may be a local derived from the param + // (`const props = usePlayground(rawProps)`); `{ ...rest }` on it would + // enumerate the props proxy and over-subscribe. + const propsDerived = collectPropsDerivedLocals(baseName, fnBody.body ?? []); + + for (const stmt of fnBody.body ?? []) { + if (stmt.type !== 'VariableDeclaration' || !hasRange(stmt)) { + continue; + } + + for (const declarator of stmt.declarations ?? []) { + const plan = analyzeBodyDestructureDeclarator( + propsDerived, + baseName, + declarator, + stmt, + body, + offset + ); + if (plan) { + return plan; + } + } + } + + return null; +} + +function collectPropsDerivedLocals(baseName: string, statements: readonly unknown[]): Set { + const derived = new Set([baseName]); + for (const stmt of statements) { + if (!isAstNode(stmt) || stmt.type !== 'VariableDeclaration' || stmt.kind !== 'const') { + continue; + } + for (const declarator of (stmt.declarations as unknown[] | undefined) ?? []) { + if (!isVariableDeclaratorNode(declarator)) { + continue; + } + if (!isIdentifierNode(declarator.id)) { + continue; + } + const init = declarator.init; + if (!isAstNode(init) || init.type !== 'CallExpression') { + continue; + } + const arg0 = (init.arguments as unknown[] | undefined)?.[0]; + if (isIdentifierNode(arg0) && derived.has(arg0.name)) { + derived.add(declarator.id.name); + } + } + } + return derived; +} + +function analyzeBodyDestructureDeclarator( + propsDerived: ReadonlySet, + firstParamName: string, + declarator: unknown, + stmt: { start: number; end: number }, + body: string, + offset: number +): RawPropsTransformPlan | null { + if (!isVariableDeclaratorNode(declarator)) { + return null; + } + if (!isAstNode(declarator.id) || declarator.id.type !== 'ObjectPattern') { + return null; + } + if (!isIdentifierNode(declarator.init) || !propsDerived.has(declarator.init.name)) { + return null; + } + const baseName = declarator.init.name; + + const bindings = collectPatternBindings(declarator.id, body, offset); + // Same gate as the param-level path — preserve the source destructure when + // any field has an unsupported shape. + if (bindings.unsafe) { + return null; + } + if (bindings.fields.length === 0 && !bindings.restElementName) { + return null; + } + + return { + removeRange: getStatementRemovalRange(body, stmt, offset), + replacementBaseName: baseName, + restLine: bindings.restElementName + ? buildRestPropsLine(baseName, bindings.restElementName, bindings.fields) + : undefined, + restLineInPlace: baseName !== firstParamName, + fieldLocalToKey: toFieldLocalToKey(bindings.fields), + fieldLocalToDefault: toFieldLocalToDefault(bindings.fields), + excludedRanges: [{ start: stmt.start, end: stmt.end }], + }; +} + +function isExcludedRange( + node: { start: number; end: number }, + excludedRanges?: Array<{ start: number; end: number }> +): boolean { + if (!excludedRanges || excludedRanges.length === 0) { + return false; + } + return excludedRanges.some((range) => node.start >= range.start && node.end <= range.end); +} + +/** + * Collector matching Identifier references to destructured prop locals, emitting + * `IdentifierReplacement` records for the `_rawProps.` rewrite. Uses the local shape (not + * generic `RangeReplacement`) because the apply step needs the original `local`/`key` to compose + * the accessor + default at emit time. + */ +function buildIdentifierReplacementsCollector( + fieldLocalToKey: ReadonlyMap, + offset: number, + out: IdentifierReplacement[], + excludedRanges?: ReadonlyArray<{ start: number; end: number }> +): RangeReplacementCollector { + return (node, ctx) => { + if ( + hasRange(node) && + isExcludedRange(node, excludedRanges as Array<{ start: number; end: number }> | undefined) + ) { + return { replacements: [], skipSubtree: true }; + } + // A JSXIdentifier in tag-name position is a reference to the same binding + // (`` resolves `Model` in scope), so rewrite to ``. + // Only opening/closing tag names are references — attribute names, + // member-expression properties, and namespace parts must NOT be rewritten. + if ( + isAstNode(node) && + node.type === 'JSXIdentifier' && + hasRange(node) && + typeof (node as { name?: unknown }).name === 'string' && + ctx.parentKey === 'name' && + (ctx.parentNode?.type === 'JSXOpeningElement' || ctx.parentNode?.type === 'JSXClosingElement') + ) { + const jsxName = (node as { name: string }).name; + const jsxKey = fieldLocalToKey.get(jsxName); + if (jsxKey !== undefined) { + out.push({ + start: node.start - offset, + end: node.end - offset, + key: jsxKey, + local: jsxName, + // JSX tag position does not need parens around a MemberExpression. + needsParens: false, + }); + } + return { replacements: [] }; + } + if (!isRangedIdentifierNode(node)) { + return null; + } + const key = fieldLocalToKey.get(node.name); + if (key === undefined) { + return null; + } + + const isShorthandValue = + ctx.parentKey === 'value' && + ctx.parentNode?.type === 'Property' && + ctx.parentNode.shorthand === true; + + if (isShorthandValue) { + // Shorthand expands to `key: ` — Property-value position. + out.push({ + start: node.start - offset, + end: node.end - offset, + key, + local: node.name, + isShorthand: true, + needsParens: false, + }); + return { replacements: [] }; + } + if (isReplaceableIdentifierPosition(ctx.parentKey, ctx.parentNode)) { + out.push({ + start: node.start - offset, + end: node.end - offset, + key, + local: node.name, + needsParens: expressionNeedsParens(ctx.parentKey, ctx.parentNode), + }); + } + return { replacements: [] }; + }; +} + +function collectIdentifierReplacements( + root: unknown, + fieldLocalToKey: Map, + offset: number, + excludedRanges?: Array<{ start: number; end: number }> +): IdentifierReplacement[] { + if (!isAstNode(root)) { + return []; + } + const out: IdentifierReplacement[] = []; + // `root` is an `AstCompatNode` narrowed via `isAstNode`; it shares the + // structural fields the orchestrator reads, so the cast to `AstNode` is safe. + collectRangeReplacements(root as AstNode, 0, '', [ + buildIdentifierReplacementsCollector(fieldLocalToKey, offset, out, excludedRanges), + ]); + return out; +} + +function applyIdentifierReplacements( + session: TransformSession, + replacements: IdentifierReplacement[], + baseName: string, + defaultValues?: Map +): void { + for (const replacement of replacements) { + const baseAccessor = buildPropertyAccessor(baseName, replacement.key); + const defaultValue = defaultValues?.get(replacement.local); + // Parens only when parent precedence requires them (via `replacement.needsParens`). + let accessor: string; + if (defaultValue === undefined) { + accessor = baseAccessor; + } else { + accessor = replacement.needsParens + ? `(${baseAccessor} ?? ${defaultValue})` + : `${baseAccessor} ?? ${defaultValue}`; + } + + const start = session.offset + replacement.start; + const end = session.offset + replacement.end; + if (replacement.isShorthand) { + session.edits.overwrite(start, end, `${replacement.key}: ${accessor}`); + } else { + session.edits.overwrite(start, end, accessor); + } + } +} + +function formatConsolidatedWItems(items: string[]): string { + if (items.length === 1) { + return `\n ${items[0]}\n `; + } + return `\n ${items.join(',\n ')}\n `; +} + +function collectRawPropsWCallReplacements(session: TransformSession): Array<{ + start: number; + end: number; + text: string; +}> { + const replacements: Array<{ start: number; end: number; text: string }> = []; + + function walk(node: unknown): void { + if (!isAstNode(node)) { + return; + } + + if (isWCallWithArrayArg(node)) { + const arg = node.arguments[0]; + const consolidated: string[] = []; + let hasRawPropsField = false; + let hasRawProps = false; + + for (const element of arg.elements ?? []) { + if (!element || !hasRange(element)) { + continue; + } + + const elementText = session.wrappedSource.slice(element.start, element.end).trim(); + const isRawPropsField = isRawPropsMemberExpression(element); + const isRawPropsIdent = isIdentifierNode(element) && element.name === '_rawProps'; + + if (isRawPropsField || isRawPropsIdent) { + if (isRawPropsField) { + hasRawPropsField = true; + } + if (!hasRawProps) { + consolidated.push('_rawProps'); + hasRawProps = true; + } + } else { + consolidated.push(elementText); + } + } + + if (hasRawPropsField) { + replacements.push({ + start: arg.start + 1, + end: arg.end - 1, + text: formatConsolidatedWItems(consolidated), + }); + } + } + + forEachAstChild(node, (child) => walk(child)); + } + + walk(session.program); + return replacements; +} + +/** + * Consolidate `.w([...])` arrays after the _rawProps transform: any `_rawProps.x` entries collapse + * to a single deduped `_rawProps`. e.g. `.w([arg0, _rawProps.foo, _rawProps.bar])` → `.w([arg0, + * _rawProps])` + */ +export function consolidateRawPropsInWCalls(body: string): string { + // Textual prefilter: any body this pass could change contains both `_rawProps` + // and `.w(`; most have neither, so skip the parse. + if (!body.includes('_rawProps') || !body.includes('.w(')) { + return body; + } + const session = createTransformSession(body); + if (!session) { + return body; + } + + const replacements = collectRawPropsWCallReplacements(session); + if (replacements.length === 0) { + return body; + } + + replacements.sort((a, b) => b.start - a.start); + for (const replacement of replacements) { + session.edits.overwrite(replacement.start, replacement.end, replacement.text); + } + return session.toSource(); +} + +export function applyRawPropsTransform(body: string): string { + const session = createFunctionTransformSession(body); + if (!session) { + return body; + } + + const plan = analyzeRawPropsTransform(session, body); + if (!plan) { + return body; + } + + if (plan.replacementParamRange) { + session.edits.overwrite( + plan.replacementParamRange.start, + plan.replacementParamRange.end, + '_rawProps' + ); + } + if (plan.removeRange) { + // A props-derived local declared mid-body would hit a TDZ if the rest line + // were inserted at the prologue, so emit it in place of the destructure. + if (plan.restLine && plan.restLineInPlace) { + session.edits.overwrite(plan.removeRange.start, plan.removeRange.end, plan.restLine); + } else { + session.edits.remove(plan.removeRange.start, plan.removeRange.end); + if (plan.restLine) { + insertFunctionBodyPrologue(session, session.fn, plan.restLine); + } + } + } else if (plan.restLine) { + insertFunctionBodyPrologue(session, session.fn, plan.restLine); + } + if (plan.fieldLocalToKey.size > 0) { + const replacements = collectIdentifierReplacements( + session.program, + plan.fieldLocalToKey, + session.offset, + plan.excludedRanges + ); + applyIdentifierReplacements( + session, + replacements, + plan.replacementBaseName, + plan.fieldLocalToDefault + ); + } + + return session.toSource(); +} + +export function bodyConsolidatesToRawProps(body: string): boolean { + const session = createFunctionTransformSession(body); + if (!session) { + return false; + } + return analyzeRawPropsTransform(session, body)?.replacementParamRange !== undefined; +} + +export function consolidateQpCaptureValues( + params: readonly string[], + fieldMap: ReadonlyMap +): string[] { + // The handler's params consolidate too, so the slot carries the whole + // props proxy — a field read would serialize the naked value and lose the + // proxy identity on resume (rust parity). Dedup to one proxy slot. + const out: string[] = []; + for (const p of params) { + const value = fieldMap.has(p) ? '_rawProps' : p; + if (value === '_rawProps' && out.includes('_rawProps')) { + continue; + } + out.push(value); + } + return out; +} + +/** + * Replace field-name references with `_rawProps.` in a body string, for child segments whose + * captures were consolidated into a single `_rawProps`. With `defaultValues`, defaulted fields emit + * `(_rawProps. ?? )`. + */ +export function replacePropsFieldReferencesInBody( + body: string, + fieldMap: Map, + defaultValues?: ReadonlyMap +): string { + return rewritePropsFieldReferences(body, fieldMap, { + memberPropertyMode: 'nonComputed', + defaultValues, + }); +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/rewrite-calls.ts b/packages/ts-optimizer/src/optimizer/rewrite/rewrite-calls.ts new file mode 100644 index 00000000000..70b7b81ded8 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/rewrite-calls.ts @@ -0,0 +1,191 @@ +import { transformSync as oxcTransformSync } from 'oxc-transform'; +import { rewriteImportSource } from './rewrite-imports.js'; +import { isQwikPackageSource } from '../qwik/qwik-packages.js'; +import { quoteAsStringLiteral } from '../edit/string-literal.js'; +import { scanMatchingParenForward, skipStringLiteralForward } from '../edit/text-scanning.js'; + +const collapsedWhitespace = /\s+/g; +const spacesAroundOperators = /\s*([{}(),:;=<>+\-*/%&|!?.])\s*/g; + +// All single-ident arrow params drop their parens (rust minifier parity). +const singleArrowParam = /\((\w+)\)=>/g; + +interface MovedCapturesSource { + readonly ctxKind: 'function' | 'eventHandler' | 'jSXProp'; + readonly ctxName: string; + readonly calleeName: string; + readonly movedCaptures?: boolean; + readonly isInlinedQrl?: boolean; +} + +/** + * Event handlers whose captures were lifted into params need a runtime `.m()` marker. Only direct + * `on*$={fn}` handlers qualify — nested markers (`server$`, `sync$`, …) keep their captures. + */ +export function hasMovedCaptures(ext: MovedCapturesSource): boolean { + return ( + ext.ctxKind === 'eventHandler' && + !ext.isInlinedQrl && + ext.calleeName === ext.ctxName && + ext.movedCaptures === true + ); +} + +/** Appends the `.m()` moved-captures marker to a `;`-terminated QRL declaration. */ +export function markMovedCaptures(decl: string, ext: MovedCapturesSource): string { + if (!hasMovedCaptures(ext)) { + return decl; + } + return decl.slice(0, -1) + '.m();'; +} + +/** Worker QRLs load through a dedicated chunk; the prefix tells the bundler to emit one. */ +export const WORKER_CHUNK_PREFIX = '__QWIK_WORKER_QRL__:'; + +export function isWorkerExtraction(ext: { readonly ctxName: string }): boolean { + return ext.ctxName === 'worker$'; +} + +export function buildWorkerQrlDeclaration( + varName: string, + symbolName: string, + canonicalFilename: string, + explicitExtensions?: boolean, + outputExtension?: string, + devMeta?: string +): string { + const ext = explicitExtensions ? (outputExtension ?? '.js') : ''; + const chunk = `${WORKER_CHUNK_PREFIX}./${canonicalFilename}${ext}`; + const helper = devMeta ? '_qrlWithChunkDEV' : '_qrlWithChunk'; + const devArg = devMeta ? `, ${devMeta}` : ''; + return `const ${varName} = /*#__PURE__*/ ${helper}("${chunk}", ()=>import("./${canonicalFilename}${ext}"), "${symbolName}"${devArg});`; +} + +export function buildQrlDeclaration( + symbolName: string, + canonicalFilename: string, + explicitExtensions?: boolean, + _segmentExtension?: string, + outputExtension?: string +): string { + const ext = explicitExtensions ? (outputExtension ?? '.js') : ''; + return `const q_${symbolName} = /*#__PURE__*/ qrl(()=>import("./${canonicalFilename}${ext}"), "${symbolName}");`; +} + +function minifyCodeSegment(code: string): string { + let result = code; + result = result.replace(collapsedWhitespace, ' '); + result = result.replace(spacesAroundOperators, '$1'); + result = result.replace(singleArrowParam, '$1=>'); + // `new Set()` → `new Set` (rust parity) — unless a member/call chain follows. + result = result.replace(/new ([A-Za-z_$][\w$]*)\(\)(?![.(])/g, 'new $1'); + return result; +} + +/** + * Minify the serialized sync$ source: comments drop, string/template literals pass through + * verbatim, and only the code between them is whitespace/operator-minified. + */ +function minifyFunctionText(text: string): string { + let out = ''; + let codeStart = 0; + const OPERATOR = /[{}(),:;=<>+\-*/%&|!?.]/; + const flushCode = (end: number): void => { + if (end <= codeStart) { + return; + } + let chunk = minifyCodeSegment(text.slice(codeStart, end)); + // Boundary spaces around a dropped comment must not survive the join. + if (chunk.startsWith(' ') && (out === '' || OPERATOR.test(out[out.length - 1]))) { + chunk = chunk.slice(1); + } + if (out.endsWith(' ') && (chunk === '' || OPERATOR.test(chunk[0]))) { + out = out.slice(0, -1); + } + out += chunk; + }; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"' || ch === "'" || ch === '`') { + flushCode(i); + const close = skipStringLiteralForward(text, i); + out += text.slice(i, Math.min(close + 1, text.length)); + i = close; + codeStart = i + 1; + } else if (ch === '/' && text[i + 1] === '*') { + flushCode(i); + const end = text.indexOf('*/', i + 2); + i = end < 0 ? text.length : end + 1; + codeStart = i + 1; + } else if (ch === '/' && text[i + 1] === '/') { + flushCode(i); + const nl = text.indexOf('\n', i); + // The newline survives as code whitespace so tokens stay separated. + codeStart = nl < 0 ? text.length : nl; + i = codeStart - 1; + } + } + flushCode(text.length); + let result = out.trim(); + // `"key" in obj` → `"key"in obj` — a quote already separates the tokens. + result = result.replace(/(["'`]) in\b/g, '$1in'); + return result; +} + +function stripTypesForSerialization(fnText: string): string { + try { + const wrapped = `const __qs = (${fnText});`; + const out = oxcTransformSync('__sync__.tsx', wrapped, { + typescript: { onlyRemoveTypeImports: false }, + jsx: 'preserve', + }); + if (!out.code) { + return fnText; + } + const openIdx = out.code.indexOf('(', out.code.indexOf('=')); + if (openIdx < 0) { + return fnText; + } + const closeIdx = scanMatchingParenForward(out.code, openIdx + 1); + return out.code.slice(openIdx + 1, closeIdx - 1); + } catch { + return fnText; + } +} + +export function buildSyncTransform(originalFnText: string): string { + const minified = minifyFunctionText(stripTypesForSerialization(originalFnText)); + return `_qrlSync(${originalFnText}, ${quoteAsStringLiteral(minified)})`; +} + +const PURE_CALLEES = new Set(['componentQrl', 'qrl', 'qrlDEV']); + +export function needsPureAnnotation(qrlCalleeName: string): boolean { + return PURE_CALLEES.has(qrlCalleeName); +} + +export function getQrlImportSource(qrlCalleeName: string, originalSource?: string): string { + if (originalSource && !isQwikPackageSource(originalSource)) { + return originalSource; + } + + if ( + originalSource && + originalSource !== '@qwik.dev/core' && + originalSource !== '@builder.io/qwik' && + isQwikPackageSource(originalSource) + ) { + return rewriteImportSource(originalSource); + } + + if (qrlCalleeName === 'qwikifyQrl') { + return '@qwik.dev/react'; + } + + const ROUTER_QRLS = new Set(['globalActionQrl', 'routeActionQrl', 'routeLoaderQrl', 'zodQrl']); + if (ROUTER_QRLS.has(qrlCalleeName)) { + return '@qwik.dev/router'; + } + + return '@qwik.dev/core'; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/rewrite-context.ts b/packages/ts-optimizer/src/optimizer/rewrite/rewrite-context.ts new file mode 100644 index 00000000000..708f84967de --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/rewrite-context.ts @@ -0,0 +1,71 @@ +import type MagicString from 'magic-string'; +import type { ExtractionResult } from '../extraction/extract.js'; +import type { ImportInfo } from '../extraction/marker-detection.js'; +import type { MigrationDecision, ModuleLevelDecl } from '../analysis/variable-migration.js'; +import type { JsxTransformOutput } from '../jsx/jsx.js'; +import type { EmitMode } from '../types/types.js'; +import type { AstFunction, AstProgram } from '../../ast-types.js'; +import type { RelativePath } from '../types/brands.js'; +import type { InlineStrategyOptions, JsxRewriteOptions } from './index.js'; + +interface SurvivingImportInfo { + defaultPart: string; + nsPart: string; + namedParts: { local: string; imported: string }[]; + quote: string; + source: string; + isSideEffect: boolean; + preservedAll: boolean; +} + +export interface RewriteContext { + source: string; + relPath: RelativePath; + s: MagicString; + program: AstProgram; + closureNodes?: Map; + extractions: ExtractionResult[]; + originalImports: Map; + migrationDecisions?: MigrationDecision[]; + moduleLevelDecls?: ModuleLevelDecl[]; + jsxOptions?: JsxRewriteOptions; + mode?: EmitMode; + devFilePath?: string; + userDevPath?: string; + inlineOptions?: InlineStrategyOptions; + stripExports?: readonly string[]; + isServer?: boolean; + explicitExtensions?: boolean; + transpileTs?: boolean; + minify?: string; + outputExtension?: string; + + // Accumulated state across phases + extractedCalleeNames: Set; + alreadyImported: Set; + survivingUserImports: string[]; + survivingImportInfos: SurvivingImportInfo[]; + topLevel: ExtractionResult[]; + earlyQrlVarNames: Map; + neededImports: Map; + injectedImportAliases: Map; + qrlVarNames: Map; + qrlDecls: string[]; + sCalls: string[]; + inlineHoistedDeclarations: string[]; + inlinedQrlSymbols: Set; + eventHandlerExtraImports: Array<{ sym: string; src: string }>; + noArgQrlCallees: Array<{ callee: string; source: string }>; + jsxResult: JsxTransformOutput | null; + jsxKeyCounterValue: number; + /** Per-extraction JSX key bases reserved by the parent walks (keyed by argStart). */ + jsxCallSkipKeyBases?: Map; + /** Inline-strategy reservation of extracted-body key ranges. */ + jsxKeyReservations?: import('../jsx/jsx.js').JsxKeyReservations; + /** Unified per-element q:ps arrays keyed by handler symbol (slot order). */ + elementQpParamsMap?: ReadonlyMap; + isDevMode: boolean; + isInline: boolean; + isLibMode: boolean; + hasForeignJsxRuntime: boolean; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/rewrite-imports.ts b/packages/ts-optimizer/src/optimizer/rewrite/rewrite-imports.ts new file mode 100644 index 00000000000..d6e7e80b8a7 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/rewrite-imports.ts @@ -0,0 +1,16 @@ +// Longest prefix first: `@builder.io/qwik-city` must be tried before +// `@builder.io/qwik`, or the shorter rule would match it first. +const IMPORT_REWRITES: readonly [from: string, to: string][] = [ + ['@builder.io/qwik-react', '@qwik.dev/react'], + ['@builder.io/qwik-city', '@qwik.dev/router'], + ['@builder.io/qwik', '@qwik.dev/core'], +]; + +export function rewriteImportSource(source: string): string { + for (const [from, to] of IMPORT_REWRITES) { + if (source === from || source.startsWith(from + '/')) { + return to + source.slice(from.length); + } + } + return source; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/strip-exports.ts b/packages/ts-optimizer/src/optimizer/rewrite/strip-exports.ts new file mode 100644 index 00000000000..71d4b3ac8d3 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/strip-exports.ts @@ -0,0 +1,71 @@ +/** + * Only the export _bodies_ are rewritten — never imports. Import cleanup happens later in + * `filterUnusedImports` against the post-strip body; pruning here would re-add imports onto ranges + * already removed, emitting each surviving import twice. + */ + +import type MagicString from 'magic-string'; +import type { AstProgram } from '../../ast-types.js'; + +const STRIP_THROW_MSG = + 'Symbol removed by Qwik Optimizer, it can not be called from current platform'; + +interface StripExportsResult { + strippedNames: string[]; +} + +export function stripExportDeclarations( + s: MagicString, + program: AstProgram, + stripExports: readonly string[] +): StripExportsResult { + if (stripExports.length === 0) { + return { strippedNames: [] }; + } + + const stripSet = new Set(stripExports); + const strippedNames: string[] = []; + + for (const node of program.body) { + if (node.type !== 'ExportNamedDeclaration') { + continue; + } + + const decl = node.declaration; + if (!decl) { + continue; + } + + if (decl.type === 'VariableDeclaration') { + for (const declarator of decl.declarations) { + if (declarator.id?.type !== 'Identifier') { + continue; + } + const name = declarator.id.name; + if (!stripSet.has(name)) { + continue; + } + + if (declarator.init) { + const throwBody = `()=>{\n throw "${STRIP_THROW_MSG}";\n}`; + s.overwrite(declarator.init.start, declarator.init.end, throwBody); + strippedNames.push(name); + } + } + } else if (decl.type === 'FunctionDeclaration') { + if (decl.id?.type !== 'Identifier') { + continue; + } + const name = decl.id.name; + if (!stripSet.has(name)) { + continue; + } + + const throwBody = `const ${name} = ()=>{\n throw "${STRIP_THROW_MSG}";\n}`; + s.overwrite(decl.start, decl.end, throwBody); + strippedNames.push(name); + } + } + + return { strippedNames }; +} diff --git a/packages/ts-optimizer/src/optimizer/rewrite/symbol-collision.ts b/packages/ts-optimizer/src/optimizer/rewrite/symbol-collision.ts new file mode 100644 index 00000000000..5afd335e96f --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/rewrite/symbol-collision.ts @@ -0,0 +1,556 @@ +/** + * When injected runtime imports (`qrl`, `componentQrl`, `_jsxSorted`, …) collide with user-declared + * top-level symbols, the two bindings would shadow each other and break runtime semantics. This + * pipeline has no hygiene pass, so colliding user-side bindings are renamed with a fresh numeric + * suffix (`qrl` → `qrl1`) and every reference rewritten. + */ + +import type { AstNode, AstProgram } from '../../ast-types.js'; +import { forEachAstChild } from '../ast/guards.js'; +import { + addBindingNamesFromPatternToSet, + type BindingPatternLike, +} from '../ast/binding-pattern.js'; +import type { RewriteContext } from './rewrite-context.js'; +import type { ImportInfo } from '../extraction/marker-detection.js'; +import { isStrippedExtraction } from './predicates.js'; +import { getQrlImportSource } from './rewrite-calls.js'; +import { + formatImportParts, + formatImportStatement, + formatNamedImportPart, +} from '../edit/import-format.js'; + +/** + * A `neededImports` key is either `'qrl'` or `'Fragment as _Fragment'`; the local binding is the + * part after `as`. + */ +function localNameOfNeeded(key: string): string { + const asIdx = key.indexOf(' as '); + if (asIdx === -1) { + return key; + } + return key.slice(asIdx + ' as '.length).trim(); +} + +/** + * The full set of `{ localName → expectedSource }` the optimizer would inject, computed WITHOUT the + * `alreadyImported` mask — collision detection must see names the mask hides (a same-named user + * import from a different source is still a real collision). Keep in sync with + * `collectNeededImports`. + */ +export function computeWouldInjectNames(ctx: RewriteContext): Map { + const result = new Map(); + const { + topLevel, + extractions, + inlineOptions, + isDevMode, + isInline, + inlinedQrlSymbols, + noArgQrlCallees, + eventHandlerExtraImports, + jsxResult, + originalImports, + } = ctx; + const hasTopLevelNonSync = topLevel.some((e) => !e.isSync); + const hasAnyNonSync = extractions.some((e) => !e.isSync); + + const add = (key: string, source: string): void => { + if (!result.has(key)) { + result.set(key, source); + } + }; + + if (isInline) { + if (hasAnyNonSync) { + add(isDevMode ? '_noopQrlDEV' : '_noopQrl', '@qwik.dev/core'); + } + const needsCapturesImport = extractions.some( + (e) => + !e.isSync && + e.captureNames.length > 0 && + !( + inlineOptions && + isStrippedExtraction(e, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers) + ) + ); + if (needsCapturesImport) { + add('_captures', '@qwik.dev/core'); + } + } else if (inlineOptions && !inlineOptions.inline) { + if (hasTopLevelNonSync) { + const hasNonStripped = topLevel.some( + (e) => + !e.isSync && + !isStrippedExtraction(e, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers) + ); + const hasStripped = topLevel.some( + (e) => + !e.isSync && + isStrippedExtraction(e, inlineOptions.stripCtxName, inlineOptions.stripEventHandlers) + ); + if (hasNonStripped) { + add(isDevMode ? 'qrlDEV' : 'qrl', '@qwik.dev/core'); + } + if (hasStripped) { + add(isDevMode ? '_noopQrlDEV' : '_noopQrl', '@qwik.dev/core'); + } + } + } else { + if (hasTopLevelNonSync) { + add(isDevMode ? 'qrlDEV' : 'qrl', '@qwik.dev/core'); + const hasInlinedQrlLocal = topLevel.some( + (e) => e.isInlinedQrl && !ctx.relPath.includes('node_modules') + ); + if (hasInlinedQrlLocal && !isDevMode) { + add('qrlDEV', '@qwik.dev/core'); + } + } + } + + for (const ext of topLevel) { + if (ext.isSync) { + add('_qrlSync', '@qwik.dev/core'); + continue; + } + if (ext.isBare) { + if (inlinedQrlSymbols.has(ext.symbolName)) { + add('qrl', '@qwik.dev/core'); + } + continue; + } + const qrlCallee = ext.qrlCallee; + if (qrlCallee) { + let isCustom = true; + for (const [, info] of originalImports) { + if (info.importedName === ext.calleeName) { + isCustom = false; + break; + } + } + if (!isCustom) { + add(qrlCallee, getQrlImportSource(qrlCallee, ext.importSource)); + } + } + } + + for (const { callee, source } of noArgQrlCallees) { + add(callee, getQrlImportSource(callee, source)); + } + for (const { sym, src } of eventHandlerExtraImports) { + add(sym, src); + } + + if (jsxResult) { + for (const sym of jsxResult.neededImports) { + add(sym, '@qwik.dev/core'); + } + if (jsxResult.needsFragment) { + add('Fragment as _Fragment', '@qwik.dev/core/jsx-runtime'); + } + } + if (ctx.isLibMode && jsxResult) { + add('jsx as _jsx', '@qwik.dev/core/jsx-runtime'); + } + + return result; +} + +interface UserSymbolImport { + readonly kind: 'import'; + readonly importIdx: number; + readonly partIdx: number; +} + +interface UserSymbolDecl { + readonly kind: 'decl'; + readonly idStart: number; + readonly idEnd: number; +} + +type UserSymbol = UserSymbolImport | UserSymbolDecl; + +function pickFreshName(base: string, taken: ReadonlySet): string { + for (let i = 1; i < 10_000; i++) { + const candidate = `${base}${i}`; + if (!taken.has(candidate)) { + return candidate; + } + } + throw new Error(`pickFreshName exhausted suffixes for ${base}`); +} + +export function reserveGeneratedImportAliases(ctx: RewriteContext): void { + const taken = new Set([ + ...ctx.originalImports.keys(), + ...(ctx.moduleLevelDecls?.map((declaration) => declaration.name) ?? []), + ]); + for (const extraction of ctx.topLevel) { + if (extraction.isBare || !extraction.qrlCallee) { + continue; + } + const name = extraction.qrlCallee; + if (taken.has(name) || ctx.injectedImportAliases.has(name)) { + continue; + } + const wrapsBareExtraction = ctx.topLevel.some((candidate) => { + if (!candidate.isBare) { + return false; + } + return new RegExp(`\\b${name}\\s*\\(\\s*$`).test( + ctx.source.slice(Math.max(0, candidate.callStart - name.length - 16), candidate.callStart) + ); + }); + if (!wrapsBareExtraction) { + continue; + } + const alias = pickFreshName(name, taken); + ctx.injectedImportAliases.set(name, alias); + taken.add(alias); + } +} + +function findBindingIdentifierPositions( + program: AstProgram, + name: string +): { start: number; end: number } | null { + for (const node of program.body) { + if (node.type === 'VariableDeclaration') { + for (const decl of node.declarations) { + if (decl.id.type === 'Identifier' && decl.id.name === name) { + return { start: decl.id.start, end: decl.id.end }; + } + } + continue; + } + if ( + node.type === 'FunctionDeclaration' && + node.id?.type === 'Identifier' && + node.id.name === name + ) { + return { start: node.id.start, end: node.id.end }; + } + if ( + node.type === 'ClassDeclaration' && + node.id?.type === 'Identifier' && + node.id.name === name + ) { + return { start: node.id.start, end: node.id.end }; + } + if (node.type === 'ExportNamedDeclaration' && node.declaration) { + const decl = node.declaration; + if (decl.type === 'VariableDeclaration') { + for (const d of decl.declarations) { + if (d.id.type === 'Identifier' && d.id.name === name) { + return { start: d.id.start, end: d.id.end }; + } + } + } else if ( + (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') && + decl.id?.type === 'Identifier' && + decl.id.name === name + ) { + return { start: decl.id.start, end: decl.id.end }; + } + } + } + return null; +} + +function collectParamBindings(params: readonly AstNode[], out: Set): void { + for (const param of params) { + addBindingNamesFromPatternToSet(param as BindingPatternLike, out); + } +} + +function collectBlockBindings(statements: readonly AstNode[], out: Set): void { + for (const stmt of statements) { + if (stmt.type === 'VariableDeclaration') { + for (const d of stmt.declarations) { + addBindingNamesFromPatternToSet(d.id as BindingPatternLike, out); + } + } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'ClassDeclaration') { + if (stmt.id?.type === 'Identifier') { + out.add(stmt.id.name); + } + } + } +} + +/** + * Scope stack tracks inner shadows so a renamed top-level binding's references are rewritten but an + * inner same-named binding's are left alone. + */ +function rewriteReferences( + ctx: RewriteContext, + renameMap: ReadonlyMap, + excludedPositions: ReadonlySet +): void { + const { program, s } = ctx; + const scopeStack: Array> = []; + // Extraction call ranges were already replaced by rewriteCallSites; + // overwriting an identifier inside one re-splits an edited chunk. + const replacedRanges = ctx.extractions.map((ext) => [ext.callStart, ext.callEnd] as const); + const isInReplacedRange = (pos: number): boolean => + replacedRanges.some(([start, end]) => pos >= start && pos < end); + + function isShadowed(name: string): boolean { + for (const scope of scopeStack) { + if (scope.has(name)) { + return true; + } + } + return false; + } + + function walk(node: AstNode | null | undefined, parentKey?: string, parentNode?: AstNode): void { + if (!node) { + return; + } + + // Import ranges were already `s.remove`d elsewhere; overwriting inside + // a removed range re-introduces text (`qrl1;` residue), so skip the + // whole subtree — the surviving import is rebuilt separately. + if (node.type === 'ImportDeclaration') { + return; + } + + if (node.type === 'Identifier') { + const newName = renameMap.get(node.name); + if (newName !== undefined && !isShadowed(node.name) && !isInReplacedRange(node.start)) { + const isPropertyKey = + parentKey === 'key' && parentNode?.type === 'Property' && !parentNode.computed; + const isMemberProp = + parentKey === 'property' && + parentNode?.type === 'MemberExpression' && + !parentNode.computed; + // The binding's own decl-id was already overwritten directly; skip + // it here. Inner same-named decl-ids are blocked by `isShadowed`. + const isDeclId = excludedPositions.has(node.start); + // A shorthand Property has key === value (same Identifier); + // rewriting it would silently drop the property name, so leave it. + const isShorthandValue = + parentKey === 'value' && parentNode?.type === 'Property' && parentNode.shorthand === true; + if (!isPropertyKey && !isMemberProp && !isDeclId && !isShorthandValue) { + s.overwrite(node.start, node.end, newName); + } + } + return; + } + + const pushedScope = pushScopeIfFunctionLike(node); + if (pushedScope) { + scopeStack.push(pushedScope); + } + + const pushedBlock = pushScopeIfBlock(node); + if (pushedBlock) { + scopeStack.push(pushedBlock); + } + + forEachAstChild(node, (child, key, parent) => { + walk(child, key, parent); + }); + + if (pushedBlock) { + scopeStack.pop(); + } + if (pushedScope) { + scopeStack.pop(); + } + } + + function pushScopeIfFunctionLike(node: AstNode): Set | null { + if ( + node.type !== 'FunctionDeclaration' && + node.type !== 'FunctionExpression' && + node.type !== 'ArrowFunctionExpression' + ) { + return null; + } + const scope = new Set(); + collectParamBindings(node.params ?? [], scope); + return scope; + } + + function pushScopeIfBlock(node: AstNode): Set | null { + // Function bodies are also BlockStatements: the function-like push + // handles params, this one handles the body's own lexical decls. + if (node.type !== 'BlockStatement') { + return null; + } + const scope = new Set(); + collectBlockBindings(node.body ?? [], scope); + return scope; + } + + walk(program); +} + +/** + * The source import was already `s.remove`d, so a MagicString overwrite wouldn't take — rename by + * editing the rebuilt `survivingUserImports` string (and its structured `namedParts`) instead. + */ +function applyImportSpecifierRename( + ctx: RewriteContext, + importIdx: number, + partIdx: number, + newLocal: string +): void { + const info = ctx.survivingImportInfos[importIdx]; + const part = info.namedParts[partIdx]; + const updatedPart = { imported: part.imported, local: newLocal }; + info.namedParts[partIdx] = updatedPart; + + const namedStrs = info.namedParts.map((np) => formatNamedImportPart(np.imported, np.local)); + const importParts = formatImportParts(info.defaultPart, info.nsPart, namedStrs); + ctx.survivingUserImports[importIdx] = formatImportStatement(importParts, info.quote, info.source); +} + +/** + * Runs AFTER all body rewrites but BEFORE `collectNeededImports`, so the would-inject universe is + * computed independently of `alreadyImported` (a same-named user import from a different source is + * still a collision). + */ +export function detectAndRenameCollisions(ctx: RewriteContext): void { + for (const [name, alias] of ctx.injectedImportAliases) { + const source = ctx.neededImports.get(name); + if (source !== undefined) { + ctx.neededImports.delete(name); + ctx.neededImports.set(`${name} as ${alias}`, source); + } + } + const wouldInject = computeWouldInjectNames(ctx); + if (wouldInject.size === 0) { + return; + } + + const wouldInjectByLocal = new Map(); + for (const [key, source] of wouldInject) { + wouldInjectByLocal.set(localNameOfNeeded(key), source); + } + + const userSymbols = new Map(); + const userImportSources = new Map(); + for (let importIdx = 0; importIdx < ctx.survivingImportInfos.length; importIdx++) { + const info = ctx.survivingImportInfos[importIdx]; + if (info.preservedAll) { + continue; + } + for (let partIdx = 0; partIdx < info.namedParts.length; partIdx++) { + const local = info.namedParts[partIdx].local; + if (!userSymbols.has(local)) { + userSymbols.set(local, { kind: 'import', importIdx, partIdx }); + userImportSources.set(local, info.source); + } + } + } + + if (ctx.moduleLevelDecls) { + for (const decl of ctx.moduleLevelDecls) { + if (userSymbols.has(decl.name)) { + continue; + } + const pos = findBindingIdentifierPositions(ctx.program, decl.name); + if (!pos) { + continue; + } + userSymbols.set(decl.name, { kind: 'decl', idStart: pos.start, idEnd: pos.end }); + } + } + + // A user import from the *same* source as the injected one is a deliberate + // dedup, not a collision — skip it; only a different source (or a shadowing + // decl) needs renaming. + const renameMap = new Map(); + const taken = new Set([...userSymbols.keys(), ...wouldInjectByLocal.keys()]); + for (const [name, sym] of userSymbols) { + const expectedSource = wouldInjectByLocal.get(name); + if (expectedSource === undefined) { + continue; + } + if (sym.kind === 'import') { + const userSource = userImportSources.get(name); + if (userSource === expectedSource) { + continue; + } + } + const newName = pickFreshName(name, taken); + renameMap.set(name, newName); + taken.add(newName); + } + if (renameMap.size === 0) { + return; + } + + const declExclusions = new Set(); + for (const [oldName, newName] of renameMap) { + const sym = userSymbols.get(oldName); + if (sym?.kind !== 'decl') { + continue; + } + ctx.s.overwrite(sym.idStart, sym.idEnd, newName); + declExclusions.add(sym.idStart); + } + + for (const [oldName, newName] of renameMap) { + const sym = userSymbols.get(oldName); + if (sym?.kind !== 'import') { + continue; + } + applyImportSpecifierRename(ctx, sym.importIdx, sym.partIdx, newName); + } + + rewriteReferences(ctx, renameMap, declExclusions); + + for (const [oldName, newName] of renameMap) { + if (ctx.alreadyImported.has(oldName)) { + ctx.alreadyImported.delete(oldName); + ctx.alreadyImported.add(newName); + } + const info = ctx.originalImports.get(oldName); + if (info) { + ctx.originalImports.delete(oldName); + const updated: ImportInfo = { ...info, localName: newName }; + ctx.originalImports.set(newName, updated); + } + if (ctx.moduleLevelDecls) { + for (const decl of ctx.moduleLevelDecls) { + // `name` is readonly; cast through to mutate it for the rename. + if (decl.name === oldName) { + (decl as { name: string }).name = newName; + } + } + } + } + + // This runs after `collectNeededImports`, whose `alreadyImported` mask + // is now stale post-rename; re-add each renamed name's import from the + // authoritative `wouldInject` map so it isn't dropped. + for (const [renamedUserName] of renameMap) { + for (const [key, source] of wouldInject) { + if (localNameOfNeeded(key) !== renamedUserName) { + continue; + } + if (!ctx.neededImports.has(key)) { + ctx.neededImports.set(key, source); + } + } + } + + // `segmentImports` was built pre-rename; any entry keyed on `oldName` is + // now stale (the import is `newName`, or the ref became a local shadow / + // dead). Drop them — `recollectPostTransformImports` re-derives whatever + // the post-transform body genuinely needs. + for (const ext of ctx.extractions) { + if (ext.segmentImports.length === 0) { + continue; + } + const filtered = ext.segmentImports.filter((imp) => !renameMap.has(imp.localName)); + if (filtered.length !== ext.segmentImports.length) { + // `segmentImports` is readonly; cast through to reassign the filtered list. + (ext as unknown as { segmentImports: ImportInfo[] }).segmentImports = filtered; + } + } +} diff --git a/packages/ts-optimizer/src/optimizer/segment/body-transforms.ts b/packages/ts-optimizer/src/optimizer/segment/body-transforms.ts new file mode 100644 index 00000000000..fcf8ef41bed --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/body-transforms.ts @@ -0,0 +1,804 @@ +import { walk } from 'oxc-walker'; +import type { AstNode } from '../../ast-types.js'; +import { + createFunctionTransformSession, + insertFunctionBodyPrologue, + replaceFunctionParams, + type FunctionTransformSession, +} from '../edit/transform-session.js'; +import { buildSyncTransform, needsPureAnnotation } from '../rewrite/rewrite-calls.js'; +import { formatWCall } from '../qwik/w-call.js'; +import { applyRawPropsTransform, consolidateRawPropsInWCalls } from '../rewrite/index.js'; +import type { NestedCallSiteInfo } from './segment-codegen.js'; +import { + blankNonCode, + isInsideString, + pureAwareOverwriteStart, + scanMatchingParenBackward, + scanMatchingParenForward, +} from '../edit/text-scanning.js'; +import { replaceOutsideStrings, wholeIdentifierPattern } from '../edit/identifier-boundary.js'; + +const qwikDisableDirective = /\/\*\s*@qwik-disable-next-line\s+\w+\s*\*\/\s*\n?/g; + +function getNestedCallSiteStart(site: NestedCallSiteInfo): number { + if (!site.isJsxAttr) { + return site.callStart; + } + return site.attrStart ?? site.callStart; +} + +/** + * Text-based because it runs after nested call site rewriting has invalidated the original AST + * positions. Scans the blanked text so strings and comments can't fake an arrow or a declaration. + */ +function findEnclosingArrowBodyForCapture( + text: string, + pos: number, + capturedVarName: string +): number { + text = blankNonCode(text); + // Brackets closed between `i` and `pos` belong to siblings, not to an enclosing + // scope — without this, an earlier arrow that merely *precedes* the position + // matches and the declaration lands outside any statement. + const closedBrackets: string[] = []; + for (let i = pos - 1; i >= 1; i--) { + const ch = text[i]; + if (ch === ')' || ch === '}' || ch === ']') { + closedBrackets.push(ch); + continue; + } + if (ch !== '(' && ch !== '{' && ch !== '[') { + continue; + } + const closer = ch === '(' ? ')' : ch === '{' ? '}' : ']'; + if (closedBrackets[closedBrackets.length - 1] === closer) { + closedBrackets.pop(); + continue; + } + if (ch === '[') { + continue; + } + + let j = i - 1; + while (j >= 0 && /\s/.test(text[j])) { + j--; + } + if (!(j >= 1 && text[j] === '>' && text[j - 1] === '=')) { + continue; + } + + let paramEnd = j - 2; + while (paramEnd >= 0 && /\s/.test(text[paramEnd])) { + paramEnd--; + } + + let paramText = ''; + if (text[paramEnd] === ')') { + const pStart = scanMatchingParenBackward(text, paramEnd - 1); + paramText = text.slice(pStart + 1, paramEnd); + } else if (/\w/.test(text[paramEnd])) { + let pStart = paramEnd; + while (pStart > 0 && /\w/.test(text[pStart - 1])) { + pStart--; + } + paramText = text.slice(pStart, paramEnd + 1); + } + + const params = paramText.split(',').map((p) => p.trim()); + if (params.includes(capturedVarName)) { + return i + 1; + } + + const bodyStart = i + 1; + const bodySlice = text.slice(bodyStart, pos); + const localDeclPattern = new RegExp( + `\\b(?:const|let|var)\\s+${wholeIdentifierPattern(capturedVarName).source}` + ); + if (localDeclPattern.test(bodySlice)) { + return bodyStart; + } + } + return -1; +} + +function findVarDeclarationEnd(text: string, startPos: number, varName: string): number { + const code = blankNonCode(text); + const pattern = new RegExp( + `\\b(?:const|let|var)\\s+${wholeIdentifierPattern(varName).source}\\s*=` + ); + const match = pattern.exec(code.slice(startPos)); + if (!match) { + return -1; + } + + const declStart = startPos + match.index; + // The statement's own `;`: same nesting depth as the declaration, outside + // strings — a nested arrow body's `;` must not end it early. + let semiIdx = -1; + let depth = 0; + for (let i = declStart + match[0].length; i < code.length; i++) { + const ch = code[i]; + if (ch === '(' || ch === '[' || ch === '{') { + depth++; + } else if (ch === ')' || ch === ']' || ch === '}') { + if (depth === 0) { + break; + } + depth--; + } else if (ch === ';' && depth === 0) { + semiIdx = i; + break; + } + } + if (semiIdx < 0) { + return -1; + } + + let endPos = semiIdx + 1; + if (text[endPos] === '\n') { + endPos++; + } + return endPos; +} + +/** Fallback for malformed bodies that cannot provide an outer scope. */ +const OUTERMOST_BODY_THRESHOLD = 20; + +/** MUST run before any other text modifications because it uses original source positions. */ +export function rewriteNestedCallSitesInline( + bodyText: string, + nestedCallSites: NestedCallSiteInfo[], + bodyOffset: number, + preserveOffsets = false +): string { + const outerSession = createFunctionTransformSession(bodyText, { tolerateErrors: true }); + const outerBodyStart = + outerSession?.fn.body?.type === 'BlockStatement' + ? outerSession.fn.body.start - outerSession.offset + 1 + : -1; + const sorted = [...nestedCallSites].sort((a, b) => { + return getNestedCallSiteStart(b) - getNestedCallSiteStart(a); + }); + + let componentScopeWDecls: + | Array<{ declaration: string; captureNames: readonly string[] }> + | undefined; + const hoistDeclarations: Array<{ position: number; declaration: string }> = []; + const replaceBodyRange = (start: number, end: number, replacement: string): void => { + if (start < 0 || end > bodyText.length) { + return; + } + if (preserveOffsets && replacement.length < end - start) { + replacement += ' '.repeat(end - start - replacement.length); + } + const offsetChange = replacement.length - (end - start); + for (const hoist of hoistDeclarations) { + if (hoist.position >= end) { + hoist.position += offsetChange; + } + } + bodyText = bodyText.slice(0, start) + replacement + bodyText.slice(end); + }; + + for (const site of sorted) { + if ( + site.isJsxAttr && + site.attrStart !== undefined && + site.attrEnd !== undefined && + site.transformedPropName + ) { + // A JSX-attr child that captures but isn't on the loop-cross hoist path + // (`hoistedSymbolName` unset) still needs `.w([captures])` wrapping at + // the parent's prop call site. + let propValueRef: string; + if (site.hoistedSymbolName) { + propValueRef = site.hoistedSymbolName; + } else if (site.captureNames && site.captureNames.length > 0) { + propValueRef = formatWCall(site.qrlVarName, site.captureNames, ' ', ' '); + } else { + propValueRef = site.qrlVarName; + } + const relStart = site.attrStart - bodyOffset; + const relEnd = site.attrEnd - bodyOffset; + replaceBodyRange(relStart, relEnd, `${site.transformedPropName}={${propValueRef}}`); + + if ( + site.hoistedSymbolName && + site.hoistedCaptureNames && + site.hoistedCaptureNames.length > 0 + ) { + const capturedVar = site.hoistedCaptureNames[0]; + const enclosingPos = findEnclosingArrowBodyForCapture(bodyText, relStart, capturedVar); + const isLoopCallback = + enclosingPos >= 0 && + (outerBodyStart >= 0 + ? enclosingPos !== outerBodyStart + : enclosingPos > OUTERMOST_BODY_THRESHOLD); + if (isLoopCallback) { + // Asymmetric indentation (12-space first item, 8-space rest+close) + // is intentional here; kept inline rather than routed through formatWCall. + const captureList = site.hoistedCaptureNames.join(',\n '); + const decl = `const ${site.hoistedSymbolName} = ${site.qrlVarName}.w([\n ${captureList}\n ]);`; + let latestDeclPos = -1; + for (const capVar of site.hoistedCaptureNames) { + const varDeclPos = findVarDeclarationEnd(bodyText, enclosingPos, capVar); + if (varDeclPos > latestDeclPos) { + latestDeclPos = varDeclPos; + } + } + hoistDeclarations.push({ + position: latestDeclPos >= 0 ? latestDeclPos : enclosingPos, + declaration: decl, + }); + } else { + if (!componentScopeWDecls) { + componentScopeWDecls = []; + } + const wCall = formatWCall(site.qrlVarName, site.hoistedCaptureNames, ' ', ' '); + componentScopeWDecls.unshift({ + declaration: `const ${site.hoistedSymbolName} = ${wCall};`, + captureNames: site.hoistedCaptureNames, + }); + } + } + } else { + let relStart = site.callStart - bodyOffset; + const relEnd = site.callEnd - bodyOffset; + let qrlRef = site.qrlVarName; + // Full inlinedQrl captures win over identifier-only captureNames here: + // dropping a non-identifier capture leaves `_captures[i]` undefined. + const wrapItems = + site.explicitCaptureItems && site.explicitCaptureItems.length > 0 + ? site.explicitCaptureItems + : site.captureNames; + if (wrapItems && wrapItems.length > 0) { + qrlRef = formatWCall(site.qrlVarName, wrapItems, ' ', ' '); + } + let replacement: string; + if (site.qrlCallee) { + // Wrapped in a `*Qrl(…)` call — a preceding PURE annotation still + // applies to the call, so leave it be (and re-emit our own if needed). + // Preserve arguments after the extracted closure (e.g. task options). + const relArgEnd = site.argEnd !== undefined ? site.argEnd - bodyOffset : -1; + const trailingArgs = + relArgEnd > relStart && relArgEnd < relEnd ? bodyText.slice(relArgEnd, relEnd - 1) : ''; + replacement = `${needsPureAnnotation(site.qrlCallee) ? '/*#__PURE__*/ ' : ''}${site.qrlCallee}(${qrlRef}${trailingArgs})`; + } else { + // Replaced by a bare `q_` identifier: consume a leading PURE + // annotation so it isn't stranded before the identifier (a fatal + // Rolldown INVALID_ANNOTATION once reflowed onto its own line). + relStart = pureAwareOverwriteStart(bodyText, relStart); + replacement = qrlRef; + } + replaceBodyRange(relStart, relEnd, replacement); + } + } + + bodyText = injectHoistDeclarations(bodyText, hoistDeclarations); + bodyText = injectComponentScopeWDecls(bodyText, componentScopeWDecls); + return bodyText; +} + +function injectHoistDeclarations( + bodyText: string, + hoistDeclarations: Array<{ position: number; declaration: string }> +): string { + if (hoistDeclarations.length === 0) { + return bodyText; + } + + // Group .w() declarations in the same scope together at the max position. + if (hoistDeclarations.length > 1) { + const maxPos = Math.max(...hoistDeclarations.map((h) => h.position)); + const minPos = Math.min(...hoistDeclarations.map((h) => h.position)); + if (maxPos - minPos < 500) { + for (const h of hoistDeclarations) { + h.position = maxPos; + } + } + } + + hoistDeclarations.sort((a, b) => b.position - a.position); + for (const hoist of hoistDeclarations) { + const pos = hoist.position; + const charBefore = bodyText[pos - 1]; + if (charBefore === '(') { + const closeIdx = scanMatchingParenForward(bodyText, pos) - 1; + const exprContent = bodyText.slice(pos, closeIdx).replace(/^\s+/, ''); + const blockBody = `{\n ${hoist.declaration}\n return ${exprContent};\n }`; + bodyText = bodyText.slice(0, pos - 1) + blockBody + bodyText.slice(closeIdx + 1); + } else if (charBefore === '{') { + bodyText = bodyText.slice(0, pos) + '\n ' + hoist.declaration + bodyText.slice(pos); + } else { + let indent = '\t'; + const nextNewline = bodyText.indexOf('\n', pos); + if (nextNewline >= 0) { + const nextLine = bodyText.slice(nextNewline + 1); + const indentMatch = nextLine.match(/^(\s+)/); + if (indentMatch) { + indent = indentMatch[1]; + } + } + bodyText = bodyText.slice(0, pos) + indent + hoist.declaration + '\n' + bodyText.slice(pos); + } + } + return bodyText; +} + +/** + * A component body may contain nested function declarations (each with their own `return`), and + * `componentScopeWDecls` must be injected before the COMPONENT's return, not the first nested + * function's — so this returns the LAST depth-1 `return`, or -1 when none is found. + */ +function findComponentReturnPosition(bodyText: string): number { + // Blanked text: braces or `return` inside strings and comments can't affect the scan. + const code = blankNonCode(bodyText); + let i = 0; + // Skip ahead to the first `{` — the body open. + while (i < code.length && code[i] !== '{') { + i++; + } + if (i >= code.length) { + return -1; + } + let depth = 1; + i++; + let lastDepth1Return = -1; + while (i < code.length) { + const ch = code[i]; + if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + } else if (depth === 1 && code.startsWith('return ', i)) { + // Confirm `return ` is a keyword, not an identifier tail like `noreturn `. + const prev = i > 0 ? code[i - 1] : '\n'; + if (!/[A-Za-z0-9_$]/.test(prev)) { + lastDepth1Return = i; + } + i += 7; + continue; + } + i++; + } + return lastDepth1Return; +} + +function findComponentDeclarationPrologueEnd(bodyText: string): number { + const session = createFunctionTransformSession(bodyText, { tolerateErrors: true }); + const block = session?.fn.body; + if (!session || !block || block.type !== 'BlockStatement') { + return -1; + } + + let end = -1; + for (const statement of block.body ?? []) { + if (statement.type !== 'VariableDeclaration') { + break; + } + end = statement.end - session.offset; + } + if (bodyText[end] === '\n') { + end++; + } + return end; +} + +function injectComponentScopeWDecls( + bodyText: string, + decls: Array<{ declaration: string; captureNames: readonly string[] }> | undefined +): string { + if (!decls || decls.length === 0) { + return bodyText; + } + + let insertionEnd = findComponentDeclarationPrologueEnd(bodyText); + for (const decl of decls) { + for (const captureName of decl.captureNames) { + insertionEnd = Math.max(insertionEnd, findVarDeclarationEnd(bodyText, 0, captureName)); + } + } + if (insertionEnd >= 0) { + const indent = bodyText.slice(insertionEnd).match(/^[\t ]*/)?.[0] ?? ''; + const declBlock = indent + decls.map((decl) => decl.declaration).join('\n' + indent) + '\n'; + return bodyText.slice(0, insertionEnd) + declBlock + bodyText.slice(insertionEnd); + } + + const returnIdx = findComponentReturnPosition(bodyText); + if (returnIdx < 0) { + return bodyText; + } + + let lineStart = returnIdx - 1; + while (lineStart >= 0 && bodyText[lineStart] !== '\n') { + lineStart--; + } + const indent = bodyText.slice(lineStart + 1, returnIdx); + const declBlock = decls.map((decl) => decl.declaration).join('\n' + indent) + '\n' + indent; + return bodyText.slice(0, returnIdx) + declBlock + bodyText.slice(returnIdx); +} + +export function inlineEnumReferences( + bodyText: string, + enumValueMap: Map> +): string { + for (const [enumName, members] of enumValueMap) { + for (const [memberName, value] of members) { + // No preceding `.`/ident (a member path like `x.Status.Active` is not + // the enum) and never inside strings or comments. + const pattern = new RegExp(`(? p.includes('_restProps'))) { + insertImportBeforeSeparator(parts, `import { _restProps } from "@qwik.dev/core";`); + } + return bodyText; +} + +/** Must run AFTER nested call site rewriting, which uses original positions. */ +export function stripDiagnosticsAndDirectives(bodyText: string): string { + bodyText = bodyText.replace(qwikDisableDirective, (match, ...args) => { + const offset = args[args.length - 2] as number; + // Directive-shaped STRING data is not a directive. + return isInsideString(bodyText, offset) ? match : ''; + }); + + if (!bodyText.includes('passive:')) { + return bodyText; + } + + // Match tags and directives on the blanked copy (attribute string values + // spaced out, positions preserved), then delete the found ranges from the + // original — a `passive:` or `>` inside a string can't confuse the scan. + const code = blankNonCode(bodyText); + const deletions: Array<{ start: number; end: number }> = []; + for (const tag of code.matchAll(/<(\w+)([^>]*?)>/g)) { + const attrs = tag[2]; + const attrsStart = tag.index + 1 + tag[1].length; + const elementPassive = new Set(); + for (const m of attrs.matchAll(/passive:(\w+)/g)) { + elementPassive.add(m[1]); + } + if (elementPassive.size === 0) { + continue; + } + for (const m of attrs.matchAll(/\s*passive:\w+/g)) { + deletions.push({ start: attrsStart + m.index, end: attrsStart + m.index + m[0].length }); + } + for (const m of attrs.matchAll(/\s*preventdefault:(\w+)/g)) { + if (elementPassive.has(m[1])) { + deletions.push({ start: attrsStart + m.index, end: attrsStart + m.index + m[0].length }); + } + } + } + if (deletions.length === 0) { + return bodyText; + } + + deletions.sort((a, b) => a.start - b.start); + let out = ''; + let last = 0; + for (const d of deletions) { + out += bodyText.slice(last, d.start); + last = d.end; + } + return out + bodyText.slice(last); +} + +export function transformSyncCalls(bodyText: string, parts: string[]): string { + if (!bodyText.includes('sync$(')) { + return bodyText; + } + + // Call sites are located on the blanked copy so `sync$(` inside a string or + // comment is data, not a call. + const blanked = blankNonCode(bodyText); + let didTransform = false; + let result = ''; + let i = 0; + while (i < bodyText.length) { + const syncIdx = blanked.indexOf('sync$(', i); + if (syncIdx === -1) { + result += bodyText.slice(i); + break; + } + + if (syncIdx > 0 && /[\w$]/.test(bodyText[syncIdx - 1])) { + result += bodyText.slice(i, syncIdx + 6); + i = syncIdx + 6; + continue; + } + + result += bodyText.slice(i, syncIdx); + const openParen = syncIdx + 5; + const closePos = scanMatchingParenForward(bodyText, openParen + 1); + result += buildSyncTransform(bodyText.slice(openParen + 1, closePos - 1)); + i = closePos; + didTransform = true; + } + if (!didTransform) { + return bodyText; + } + + bodyText = result; + const syncSepIdx = parts.indexOf('//'); + if (syncSepIdx >= 0 && !parts.some((p) => p.includes('_qrlSync'))) { + parts.splice(syncSepIdx, 0, `import { _qrlSync } from "@qwik.dev/core";`); + } + return bodyText; +} + +export function ensureCoreImports(bodyText: string, parts: string[]): void { + const coreSymbols = [ + '_jsxSorted', + '_jsxSplit', + '_fnSignal', + '_wrapProp', + '_restProps', + '_getVarProps', + '_getConstProps', + ]; + + for (const sym of coreSymbols) { + if (bodyText.includes(sym) && !partsHaveImport(parts, sym)) { + insertImportBeforeSeparator(parts, `import { ${sym} } from "@qwik.dev/core";`); + } + } + // `_qrlWithChunk` is a prefix of `_qrlWithChunkDEV`, so match the call exactly. + for (const sym of ['_qrlWithChunkDEV', '_qrlWithChunk']) { + if (new RegExp(`\\b${sym}\\(`).test(bodyText) && !partsHaveImport(parts, sym)) { + insertImportBeforeSeparator(parts, `import { ${sym} } from "@qwik.dev/core";`); + } + } + if (bodyText.includes('_Fragment') && !partsHaveImport(parts, '_Fragment')) { + insertImportBeforeSeparator( + parts, + `import { Fragment as _Fragment } from "@qwik.dev/core/jsx-runtime";` + ); + } +} + +export function removeDeadConstLiterals( + bodyText: string, + inlinedIdentifiers?: ReadonlySet +): string { + let session: FunctionTransformSession | null; + try { + session = createFunctionTransformSession(bodyText, { tolerateErrors: true }); + } catch { + return bodyText; + } + if (!session) { + return bodyText; + } + + const fnBody = session.fn.body; + if (!fnBody || fnBody.type !== 'BlockStatement') { + return bodyText; + } + + const offset = session.offset; + const stmts = fnBody.body; + if (!stmts || stmts.length === 0) { + return bodyText; + } + + interface DeadCandidate { + name: string; + stmtStart: number; + stmtEnd: number; + } + const candidates: DeadCandidate[] = []; + + for (const stmt of stmts) { + if (stmt.type !== 'VariableDeclaration' || stmt.kind !== 'const') { + continue; + } + if (stmt.declarations.length !== 1) { + continue; + } + const d = stmt.declarations[0]; + if (d.id?.type !== 'Identifier') { + continue; + } + const initNode = d.init; + if (!initNode) { + continue; + } + const isLiteral = + initNode.type === 'Literal' && + (initNode.value === null || typeof initNode.value !== 'object'); + if (!isLiteral && !inlinedIdentifiers?.has(d.id.name)) { + continue; + } + + candidates.push({ + name: d.id.name, + stmtStart: stmt.start - offset, + stmtEnd: stmt.end - offset, + }); + } + + if (candidates.length === 0) { + return bodyText; + } + + const toRemove: DeadCandidate[] = []; + for (const c of candidates) { + const rest = bodyText.slice(0, c.stmtStart) + bodyText.slice(c.stmtEnd); + if (!wholeIdentifierPattern(c.name).test(rest)) { + toRemove.push(c); + } + } + + if (toRemove.length === 0) { + return bodyText; + } + + toRemove.sort((a, b) => b.stmtStart - a.stmtStart); + let result = bodyText; + for (const c of toRemove) { + let end = c.stmtEnd; + while ( + end < result.length && + (result[end] === '\n' || result[end] === '\r' || result[end] === ';') + ) { + end++; + } + let start = c.stmtStart; + while (start > 0 && (result[start - 1] === '\t' || result[start - 1] === ' ')) { + start--; + } + result = result.slice(0, start) + result.slice(end); + } + + return result; +} + +export function rewriteFunctionSignature(bodyText: string, paramNames: string[]): string { + const session = createFunctionTransformSession(bodyText); + if (!session) { + return bodyText; + } + if (!replaceFunctionParams(session, session.fn, paramNames)) { + return bodyText; + } + return session.toSource(); +} + +export function injectCapturesUnpacking(bodyText: string, captureNames: string[]): string { + if (captureNames.length === 0) { + return bodyText; + } + + const unpackParts = captureNames.map((name, i) => `${name} = _captures[${i}]`); + const unpackLine = `const ${unpackParts.join(', ')};`; + + const session = createFunctionTransformSession(bodyText); + if (!session) { + return bodyText; + } + + insertFunctionBodyPrologue(session, session.fn, unpackLine); + return session.toSource(); +} + +export function insertImportBeforeSeparator(parts: string[], importStmt: string): void { + const sepIdx = parts.indexOf('//'); + if (sepIdx < 0) { + parts.unshift(importStmt); + return; + } + parts.splice(sepIdx, 0, importStmt); +} + +export function partsHaveImport(parts: string[], symbol: string): boolean { + return parts.some( + (p) => + p.includes(`{ ${symbol} }`) || + p.includes(`{ ${symbol},`) || + p.includes(`, ${symbol} }`) || + p.includes(`, ${symbol},`) || + p.includes(`as ${symbol}`) || + p.includes(`* as ${symbol}`) || + // Default import — binds the name just as much as a named one. + p.startsWith(`import ${symbol} from`) || + p.startsWith(`import ${symbol},`) + ); +} diff --git a/packages/ts-optimizer/src/optimizer/segment/dev-mode.ts b/packages/ts-optimizer/src/optimizer/segment/dev-mode.ts new file mode 100644 index 00000000000..33da6041e46 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/dev-mode.ts @@ -0,0 +1,43 @@ +export interface NoopQrlDevMeta { + file: string; + lo: number; + hi: number; + displayName: string; +} + +export function formatDevMeta(meta: NoopQrlDevMeta): string { + return ( + `{\n` + + ` file: "${meta.file}",\n` + + ` lo: ${meta.lo},\n` + + ` hi: ${meta.hi},\n` + + ` displayName: "${meta.displayName}"\n` + + `}` + ); +} + +export function buildQrlDevDeclaration( + symbolName: string, + canonicalFilename: string, + devFile: string, + lo: number, + hi: number, + displayName: string, + explicitExtension?: string +): string { + const ext = explicitExtension ?? ''; + const devMeta = formatDevMeta({ file: devFile, lo, hi, displayName }); + return `const q_${symbolName} = /*#__PURE__*/ qrlDEV(()=>import("./${canonicalFilename}${ext}"), "${symbolName}", ${devMeta});`; +} + +export function buildDevFilePath(inputPath: string, srcDir: string, devPath?: string): string { + if (devPath) { + return devPath; + } + const normalizedInput = inputPath.startsWith('./') ? inputPath.slice(2) : inputPath; + if (normalizedInput.startsWith('/')) { + return normalizedInput; + } + const normalizedSrcDir = srcDir.endsWith('/') ? srcDir.slice(0, -1) : srcDir; + return `${normalizedSrcDir}/${normalizedInput}`; +} diff --git a/packages/ts-optimizer/src/optimizer/segment/entry-strategy.ts b/packages/ts-optimizer/src/optimizer/segment/entry-strategy.ts new file mode 100644 index 00000000000..1d974a5a1c2 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/entry-strategy.ts @@ -0,0 +1,59 @@ +import { getFileStem } from '../../paths.js'; + +type EntryStrategyType = 'smart' | 'segment' | 'hook' | 'component' | 'single' | 'inline' | 'hoist'; + +interface EntrySegment { + symbolName: string; + origin: string; + ctxKind: 'eventHandler' | 'function' | 'jSXProp'; + ctxName: string; + captures: boolean; +} + +function getRootComponent(segment: EntrySegment): string | null { + const marker = '_component'; + const markerIndex = segment.symbolName.indexOf(marker); + if (markerIndex < 0) { + return null; + } + + const root = segment.symbolName.slice(0, markerIndex); + const fileStem = getFileStem(segment.origin); + const routeName = fileStem + .match(/^\[\[\.\.\.(.+)\]\]$|^\[(.+)\]$/) + ?.slice(1) + .find(Boolean); + return routeName === root ? fileStem : root; +} + +function getComponentEntry(segment: EntrySegment): string | null { + const root = getRootComponent(segment); + return root ? `${segment.origin}_entry_${root}` : null; +} + +export function resolveEntryField( + strategyType: EntryStrategyType, + segment: EntrySegment, + manual: Record | undefined +): string | null { + if (manual && segment.symbolName in manual) { + return manual[segment.symbolName]!; + } + + switch (strategyType) { + case 'single': + return 'entry_segments'; + case 'inline': + case 'hoist': + case 'segment': + case 'hook': + return null; + case 'component': + return getComponentEntry(segment) ?? 'entry_segments'; + case 'smart': + if (!segment.captures && (segment.ctxKind !== 'function' || segment.ctxName === 'event$')) { + return null; + } + return getComponentEntry(segment); + } +} diff --git a/packages/ts-optimizer/src/optimizer/segment/hoist-inlined-qrl.ts b/packages/ts-optimizer/src/optimizer/segment/hoist-inlined-qrl.ts new file mode 100644 index 00000000000..18a023bb641 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/hoist-inlined-qrl.ts @@ -0,0 +1,117 @@ +/** + * A capture-position `inlinedQrl` (a QRL used inside another QRL's captures array) is not a lazy + * boundary, so its body is kept inline but still needs a stable top-level binding: `inlinedQrl(() + * => {…}, "name", […])` becomes `const _inlined_name = () => {…}` + `inlinedQrl(_inlined_name, + * "name", […])`. + */ + +import { walk } from 'oxc-walker'; +import MagicString from 'magic-string'; +import type { AstNode, AstProgram } from '../../ast-types.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; + +interface HoistCandidate { + // `arg*` is the function-body range (lifted to `bindingName`); `call*` is the + // whole `inlinedQrl(...)` call range (classifies leaf vs nested candidates). + readonly argStart: number; + readonly argEnd: number; + readonly callStart: number; + readonly callEnd: number; + readonly bindingName: string; +} + +export function hoistInlinedQrlBodies(code: string): string { + if (!code.includes('inlinedQrl(')) { + return code; + } + + let current = code; + // Each pass hoists only candidates whose body holds no other candidate + // (leaf-first), so every pass edits disjoint ranges and nested chains still + // resolve; the cap is a runaway guard, real inputs converge in one or two. + for (let pass = 0; pass < 16; pass++) { + const next = hoistOnePass(current); + if (next === current) { + break; + } + current = next; + } + return current; +} + +function hoistOnePass(code: string): string { + let program: AstProgram; + try { + program = parseWithRawTransfer('segment.tsx', code).program; + } catch { + return code; + } + + const candidates: HoistCandidate[] = []; + walk(program, { + enter(node: AstNode) { + const candidate = asHoistCandidate(node); + if (candidate) { + candidates.push(candidate); + } + }, + }); + if (candidates.length === 0) { + return code; + } + + // Defer non-leaves: once a leaf's body is replaced by its identifier, the + // enclosing candidate becomes a leaf on the next pass. + const leaves = candidates.filter( + (c) => !candidates.some((o) => o !== c && o.callStart >= c.argStart && o.callEnd <= c.argEnd) + ); + if (leaves.length === 0) { + return code; + } + leaves.sort((a, b) => a.argStart - b.argStart); + + const s = new MagicString(code); + const decls: string[] = []; + for (const leaf of leaves) { + decls.push(`const ${leaf.bindingName} = ${code.slice(leaf.argStart, leaf.argEnd)};`); + s.overwrite(leaf.argStart, leaf.argEnd, leaf.bindingName); + } + s.appendLeft(lastImportEnd(program), '\n' + decls.join('\n')); + return s.toString(); +} + +function asHoistCandidate(node: AstNode): HoistCandidate | null { + if (node.type !== 'CallExpression') { + return null; + } + if (node.callee.type !== 'Identifier' || node.callee.name !== 'inlinedQrl') { + return null; + } + // A function-expression first arg is an un-hoisted body; an identifier is + // already lifted. + const fnArg = node.arguments[0]; + if (!fnArg || (fnArg.type !== 'ArrowFunctionExpression' && fnArg.type !== 'FunctionExpression')) { + return null; + } + const nameArg = node.arguments[1]; + if (!nameArg || nameArg.type !== 'Literal' || typeof nameArg.value !== 'string') { + return null; + } + return { + argStart: fnArg.start, + argEnd: fnArg.end, + callStart: node.start, + callEnd: node.end, + bindingName: `_inlined_${nameArg.value}`, + }; +} + +function lastImportEnd(program: AstProgram): number { + let end = 0; + for (const stmt of program.body) { + if (stmt.type === 'ImportDeclaration') { + end = stmt.end; + } + } + return end; +} diff --git a/packages/ts-optimizer/src/optimizer/segment/import-collection.ts b/packages/ts-optimizer/src/optimizer/segment/import-collection.ts new file mode 100644 index 00000000000..b8e653acaaa --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/import-collection.ts @@ -0,0 +1,238 @@ +/** + * Earlier body transforms (JSX, nested calls, sync$) can introduce identifier references absent + * from the original segmentImports; this scans the final body text and adds the missing imports. + */ + +import { walk, getUndeclaredIdentifiersInFunction } from 'oxc-walker'; +import type { AstFunction, AstNode } from '../../ast-types.js'; +import { createTransformSession } from '../edit/transform-session.js'; +import { rewriteImportSource } from '../rewrite/rewrite-imports.js'; +import { getQrlCalleeName } from '../qwik/qrl-naming.js'; +import { getQrlImportSource } from '../rewrite/rewrite-calls.js'; +import type { NestedCallSiteInfo, SegmentImportData } from './segment-codegen.js'; +import { insertImportBeforeSeparator, partsHaveImport } from './body-transforms.js'; + +interface SegmentImportSpec { + localName: string; + importedName: string; +} + +const qrlSuffixPattern = /\b(\w+Qrl)\b/g; + +export function collectBodyIdentifiers(bodyText: string): Set { + const ids = new Set(); + try { + const session = createTransformSession(bodyText, { tolerateErrors: true }); + if (!session) { + throw new Error('unparseable body'); + } + + let funcNode: AstFunction | null = null; + const bareIds = new Set(); + walk(session.program, { + enter(node: AstNode) { + if ( + !funcNode && + (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') + ) { + funcNode = node; + } + if ( + node.type === 'JSXIdentifier' && + node.name && + node.name[0] >= 'A' && + node.name[0] <= 'Z' + ) { + ids.add(node.name); + return; + } + if (node.type === 'Identifier' && node.name) { + bareIds.add(node.name); + } + }, + }); + + if (funcNode) { + const undeclared = getUndeclaredIdentifiersInFunction(funcNode); + for (const name of undeclared) { + ids.add(name); + } + } else { + for (const name of bareIds) { + ids.add(name); + } + } + } catch { + const identRegex = /\b([A-Z_$][a-zA-Z0-9_$]*)\b/g; + let match; + while ((match = identRegex.exec(bodyText)) !== null) { + ids.add(match[1]); + } + } + return ids; +} + +export function recollectPostTransformImports( + bodyText: string, + parts: string[], + importContext: SegmentImportData, + importsBySource: Map, + capturedNames: Set, + nestedCallSites: NestedCallSiteInfo[] | undefined +): void { + const bodyIdentifiers = collectBodyIdentifiers(bodyText); + + for (const id of bodyIdentifiers) { + if (capturedNames.has(id)) { + continue; + } + + let alreadyImported = false; + for (const specs of importsBySource.values()) { + if (specs.some((s) => s.localName === id)) { + alreadyImported = true; + break; + } + } + if (alreadyImported) { + continue; + } + if (partsHaveImport(parts, id)) { + continue; + } + + const moduleImp = importContext.moduleImports.find((m) => m.localName === id); + if (moduleImp) { + let importStmt = buildModuleImportStatement(moduleImp); + if (moduleImp.importAttributes) { + const attrs = Object.entries(moduleImp.importAttributes) + .map(([k, v]) => `${k}: "${v}"`) + .join(', '); + importStmt = importStmt.replace('";', `" with { ${attrs} };`); + } + insertImportBeforeSeparator(parts, importStmt); + continue; + } + + if (importContext.sameFileSymbols.has(id)) { + addSameFileImport(parts, id, importContext); + } + } + + addQrlCalleeImports(parts, bodyText, nestedCallSites, importContext); +} + +function buildModuleImportStatement(imp: { + localName: string; + importedName: string; + source: string; +}): string { + const rewrittenSource = rewriteImportSource(imp.source); + if (imp.importedName === '*') { + return `import * as ${imp.localName} from "${rewrittenSource}";`; + } + if (imp.importedName === 'default') { + return `import ${imp.localName} from "${rewrittenSource}";`; + } + if (imp.importedName !== imp.localName) { + return `import { ${imp.importedName} as ${imp.localName} } from "${rewrittenSource}";`; + } + return `import { ${imp.localName} } from "${rewrittenSource}";`; +} + +/** Reexports take precedence because segment files consume the optimizer's stable private alias. */ +export function resolveSameFileImportName( + id: string, + isReexported: boolean, + defaultExportedNames: ReadonlySet | undefined, + renamedExports: ReadonlyMap | undefined +): string { + if (isReexported) { + return `_auto_${id}`; + } + if (defaultExportedNames?.has(id)) { + return 'default'; + } + return renamedExports?.get(id) ?? id; +} + +export function formatSameFileImport(id: string, importedName: string, source: string): string { + if (importedName === id) { + return `import { ${id} } from "${source}";`; + } + return `import { ${importedName} as ${id} } from "${source}";`; +} + +function addSameFileImport(parts: string[], id: string, importContext: SegmentImportData): void { + const migrationDecision = importContext.migrationDecisions.find((d) => d.varName === id); + if (migrationDecision && migrationDecision.action === 'move') { + return; + } + + const isReexported = + migrationDecision?.action === 'reexport' && + (!migrationDecision.isExported || + (importContext.useAutoExportForExported === true && !migrationDecision.isDirectlyExported)); + const importedName = resolveSameFileImportName( + id, + isReexported, + importContext.defaultExportedNames, + importContext.renamedExports + ); + insertImportBeforeSeparator( + parts, + formatSameFileImport(id, importedName, importContext.parentModulePath) + ); +} + +function addQrlCalleeImports( + parts: string[], + bodyText: string, + nestedCallSites: NestedCallSiteInfo[] | undefined, + importContext: SegmentImportData +): void { + if (!nestedCallSites) { + qrlSuffixPattern.lastIndex = 0; + const qrlSuffixRegex = qrlSuffixPattern; + let qrlMatch; + while ((qrlMatch = qrlSuffixRegex.exec(bodyText)) !== null) { + const qrlName = qrlMatch[1]; + if (parts.some((p) => p.includes(qrlName))) { + continue; + } + const markerName = `${qrlName.slice(0, -3)}$`; + if (getQrlCalleeName(markerName) !== qrlName) { + continue; + } + // Gate on the name being a legitimate `*Qrl` import — either from the + // source file's imports or the optimizer's runtime-imports injection + // list. Without this gate, ANY user-named identifier ending in `Qrl` + // (e.g. a function parameter named `reactCmpQrl` in qwik-react's + // `qwikifyQrl`) gets a bogus `import { reactCmpQrl } from + // "@qwik.dev/core";` emitted — getQrlCalleeName's check above is + // trivially true since it literally re-suffixes `Qrl` onto its input. + const moduleImport = importContext.moduleImports.find((m) => m.localName === qrlName); + if (!moduleImport) { + continue; + } + // Import from wherever the module itself got it — a `*Qrl` re-exported by + // a subpath (e.g. `@qwik.dev/core/internal`) is not on the core entry. + const importSource = getQrlImportSource(qrlName, moduleImport.source); + insertImportBeforeSeparator(parts, `import { ${qrlName} } from "${importSource}";`); + } + return; + } + + const addedQrlCallees = new Set(); + for (const site of nestedCallSites) { + if (!site.qrlCallee || addedQrlCallees.has(site.qrlCallee)) { + continue; + } + addedQrlCallees.add(site.qrlCallee); + if (parts.some((p) => p.includes(site.qrlCallee!))) { + continue; + } + const importSource = getQrlImportSource(site.qrlCallee!, site.importSource); + insertImportBeforeSeparator(parts, `import { ${site.qrlCallee} } from "${importSource}";`); + } +} diff --git a/packages/ts-optimizer/src/optimizer/segment/inline-strategy.ts b/packages/ts-optimizer/src/optimizer/segment/inline-strategy.ts new file mode 100644 index 00000000000..badbe25bd0e --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/inline-strategy.ts @@ -0,0 +1,56 @@ +/** + * Inline/hoist entry strategies keep segment bodies in the parent module — QRL declarations use + * `_noopQrl()` and bodies attach via `.s()` calls rather than landing in separate files. Stripped + * segments use sentinel counter naming to avoid colliding with real symbol names. + */ + +import { formatDevMeta, type NoopQrlDevMeta } from './dev-mode.js'; + +/** The `0xFFFF0000 + index * 2` range cannot conflict with real QRL names. */ +export function getSentinelCounter(index: number): number { + return 0xffff0000 + index * 2; +} + +export function buildNoopQrlForVar(varName: string, symbolName: string): string { + return `const ${varName} = /*#__PURE__*/ _noopQrl("${symbolName}");`; +} + +export function buildNoopQrlDevForVar( + varName: string, + symbolName: string, + devMeta: NoopQrlDevMeta +): string { + return `const ${varName} = /*#__PURE__*/ _noopQrlDEV("${symbolName}", ${formatDevMeta(devMeta)});`; +} + +export function buildNoopQrlDeclaration(symbolName: string): string { + return buildNoopQrlForVar(`q_${symbolName}`, symbolName); +} + +export function buildNoopQrlDevDeclaration(symbolName: string, devMeta: NoopQrlDevMeta): string { + return buildNoopQrlDevForVar(`q_${symbolName}`, symbolName, devMeta); +} + +export function buildStrippedNoopQrl(symbolName: string, strippedIndex: number): string { + return buildNoopQrlForVar(`q_qrl_${getSentinelCounter(strippedIndex)}`, symbolName); +} + +export function buildStrippedNoopQrlDev( + symbolName: string, + strippedIndex: number, + devMeta: NoopQrlDevMeta +): string { + return buildNoopQrlDevForVar(`q_qrl_${getSentinelCounter(strippedIndex)}`, symbolName, devMeta); +} + +export function buildSCall(varName: string, bodyText: string): string { + return `${varName}.s(${bodyText});`; +} + +export function buildHoistConstDecl(symbolName: string, bodyText: string): string { + return `const ${symbolName} = ${bodyText};`; +} + +export function buildHoistSCall(qrlVarName: string, symbolName: string): string { + return `${qrlVarName}.s(${symbolName});`; +} diff --git a/packages/ts-optimizer/src/optimizer/segment/module-symbols.ts b/packages/ts-optimizer/src/optimizer/segment/module-symbols.ts new file mode 100644 index 00000000000..056dc7975b7 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/module-symbols.ts @@ -0,0 +1,77 @@ +import type { AstProgram, ModuleExportName } from '../../ast-types.js'; +import { addBindingNamesFromPatternToSet } from '../ast/binding-pattern.js'; + +interface SameFileSymbolInfo { + sameFileSymbols: Set; + defaultExportedNames: Set; + renamedExports: Map; +} + +function getSpecifierName(specifier: ModuleExportName | null | undefined): string | undefined { + if (specifier?.type === 'Identifier') { + return specifier.name; + } + return specifier?.value; +} + +export function collectSameFileSymbolInfo(program: AstProgram): SameFileSymbolInfo { + const sameFileSymbols = new Set(); + const defaultExportedNames = new Set(); + const renamedExports = new Map(); + + for (const node of program.body) { + if (node.type === 'ExportNamedDeclaration') { + if (node.declaration) { + if (node.declaration.type === 'FunctionDeclaration' && node.declaration.id?.name) { + sameFileSymbols.add(node.declaration.id.name); + } else if (node.declaration.type === 'VariableDeclaration') { + for (const decl of node.declaration.declarations ?? []) { + addBindingNamesFromPatternToSet(decl.id, sameFileSymbols); + } + } else if (node.declaration.type === 'ClassDeclaration' && node.declaration.id?.name) { + sameFileSymbols.add(node.declaration.id.name); + } else if (node.declaration.type === 'TSEnumDeclaration' && node.declaration.id?.name) { + sameFileSymbols.add(node.declaration.id.name); + } + } + + for (const spec of node.specifiers ?? []) { + const exportedName = getSpecifierName(spec.exported); + if (exportedName) { + sameFileSymbols.add(exportedName); + } + + const localName = getSpecifierName(spec.local); + if (localName && exportedName && localName !== exportedName) { + sameFileSymbols.add(localName); + renamedExports.set(localName, exportedName); + } + } + } else if (node.type === 'ExportDefaultDeclaration') { + const decl = node.declaration; + if ( + (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') && + decl.id?.name + ) { + sameFileSymbols.add(decl.id.name); + defaultExportedNames.add(decl.id.name); + } + } else if (node.type === 'FunctionDeclaration' && node.id?.name) { + sameFileSymbols.add(node.id.name); + } else if (node.type === 'ClassDeclaration' && node.id?.name) { + sameFileSymbols.add(node.id.name); + } else if (node.type === 'VariableDeclaration') { + for (const decl of node.declarations ?? []) { + addBindingNamesFromPatternToSet(decl.id, sameFileSymbols); + } + } else if (node.type === 'TSEnumDeclaration' && node.id?.name) { + sameFileSymbols.add(node.id.name); + } + } + + return { + sameFileSymbols, + defaultExportedNames, + renamedExports, + }; +} diff --git a/packages/ts-optimizer/src/optimizer/segment/post-process.ts b/packages/ts-optimizer/src/optimizer/segment/post-process.ts new file mode 100644 index 00000000000..dc2cff10899 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/post-process.ts @@ -0,0 +1,202 @@ +import { type TransformOptions } from 'oxc-transform'; +import MagicString from 'magic-string'; +import type { SegmentCaptureInfo } from './segment-codegen.js'; +import { runDcePipeline } from '../transform/module-cleanup.js'; +import { deriveIsDev } from '../rewrite/const-replacement.js'; +import type { EmitMode } from '../types/types.js'; +import { isAnyComponentCtx } from '../rewrite/predicates.js'; +import { wholeIdentifierPattern } from '../edit/identifier-boundary.js'; +import { stripTypeScript, type StripOrigin } from '../edit/strip-types.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; + +/** + * `parentSourceExt` is the parent input file's extension (`.tsx`/`.ts`/`.jsx`/ `.js`), distinct + * from the segment's _output_ `extension`/`sourceExtensions` (often downgraded to `.js`): it drives + * oxc-transform's parser-input filename so a TS- or JSX-bearing segment body isn't rejected as a + * syntax error. + */ +export interface SegmentPostProcessOptions { + symbolName: string; + canonicalFilename: string; + extension: string; + ctxName: string; + isBare?: boolean; + sourceExtensions: Map; + parentModulePath: string; + parentSourceExt: string; + origin: StripOrigin; + shouldTranspileTs: boolean; + shouldTranspileJsx: boolean; + isServer?: boolean; + emitMode: string; + devFile?: string; + prioritizeGeneratedCaptures?: boolean; + preserveImportNames?: ReadonlySet; +} + +const pureAnnotationComment = /\/\* @__PURE__ \*\//g; + +export const leadingSquareBracket = /^\[/; + +export const trailingSquareBracket = /\]$/; + +export const leadingDot = /^\./; + +export const paddingParam = /^_\d*$/; + +export function getWholeWordPattern(name: string): RegExp { + return wholeIdentifierPattern(name); +} + +function hasCapturePayload( + captureInfo: SegmentCaptureInfo, + includeConstLiterals: boolean +): boolean { + if (captureInfo.captureNames.length > 0) { + return true; + } + if (captureInfo.autoImports.length > 0) { + return true; + } + if (captureInfo.movedDeclarations.length > 0) { + return true; + } + // Promoted-param consolidation carries only the field-rewrite map. + if (captureInfo.propsFieldCaptures && captureInfo.propsFieldCaptures.size > 0) { + return true; + } + return includeConstLiterals && captureInfo.constLiterals !== undefined; +} + +export function resolveCaptureInfo( + captureInfo: SegmentCaptureInfo, + isInlinedQrl: boolean +): SegmentCaptureInfo | undefined { + const includeConstLiterals = !isInlinedQrl; + if (!hasCapturePayload(captureInfo, includeConstLiterals)) { + return undefined; + } + + if (isInlinedQrl) { + return { ...captureInfo, skipCaptureInjection: true }; + } + + return captureInfo; +} + +export function postProcessSegmentCode(code: string, opts: SegmentPostProcessOptions): string { + let result = code; + const filename = opts.canonicalFilename + opts.extension; + + // Always transpile rather than probing for TS/JSX syntax first: a probe list + // that misses a shape silently emits unstripped TS, and oxc has to parse the + // body here anyway. + if (opts.shouldTranspileTs) { + const tsStripOptions: TransformOptions = { + typescript: { onlyRemoveTypeImports: (opts.preserveImportNames?.size ?? 0) > 0 }, + }; + if (!opts.shouldTranspileJsx) { + tsStripOptions.jsx = 'preserve'; + } + // Parser-input filename must reflect the *source* dialect so oxc-transform + // parses the segment body correctly (see parentSourceExt). + result = stripTypeScript( + opts.canonicalFilename + opts.parentSourceExt, + result, + tsStripOptions, + `generated segment "${opts.symbolName}"`, + opts.origin + ); + result = result.replace(pureAnnotationComment, '/*#__PURE__*/'); + } + + result = runDcePipeline(result, filename, { + isServer: opts.isServer, + isDev: deriveIsDev(opts.emitMode as EmitMode), + isLibMode: opts.emitMode === 'lib', + hmrDevFile: + opts.emitMode === 'hmr' && opts.devFile && !opts.isBare && isAnyComponentCtx(opts.ctxName) + ? opts.devFile + : undefined, + preserveImportNames: opts.preserveImportNames, + }); + + return sortSegmentImports( + result, + filename, + opts.parentModulePath, + opts.prioritizeGeneratedCaptures === true + ); +} + +function sortSegmentImports( + code: string, + filename: string, + parentModulePath: string, + prioritizeGeneratedCaptures: boolean +): string { + const imports = parseWithRawTransfer(filename, code).program.body.filter( + (node) => node.type === 'ImportDeclaration' + ); + if (imports.length < 2) { + return code; + } + for (let index = 1; index < imports.length; index++) { + if (code.slice(imports[index - 1].end, imports[index].start).trim()) { + return code; + } + } + const importKey = (node: (typeof imports)[number]): string => + node.specifiers + .map((specifier) => specifier.local.name) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))[0] ?? ''; + const hasExistingCaptureImport = + !prioritizeGeneratedCaptures && + imports.some((node) => + node.specifiers.some((specifier) => specifier.local.name === '_captures') + ); + const sorted = [...imports].sort((a, b) => { + const rank = (node: (typeof imports)[number]): number => { + if (node.specifiers.length === 0) { + return -1; + } + if ( + node.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + (specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value + ).startsWith('_auto_') + ) + ) { + return 0; + } + if (hasExistingCaptureImport) { + return 1; + } + if (node.specifiers.some((specifier) => specifier.local.name === '_captures')) { + return 1; + } + return node.source.value === parentModulePath ? 2 : 3; + }; + const rankA = rank(a); + const rankB = rank(b); + if (rankA !== rankB) { + return rankA - rankB; + } + const keyA = importKey(a); + const keyB = importKey(b); + return keyA < keyB ? -1 : keyA > keyB ? 1 : 0; + }); + if (sorted.every((node, index) => node === imports[index])) { + return code; + } + const edits = new MagicString(code); + edits.overwrite( + imports[0].start, + imports.at(-1)!.end, + sorted.map((node) => code.slice(node.start, node.end)).join('\n') + ); + return edits.toString(); +} diff --git a/packages/ts-optimizer/src/optimizer/segment/segment-codegen.ts b/packages/ts-optimizer/src/optimizer/segment/segment-codegen.ts new file mode 100644 index 00000000000..efec7f6db39 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/segment-codegen.ts @@ -0,0 +1,854 @@ +import { + createFunctionTransformSession, + createTransformSession, +} from '../edit/transform-session.js'; +import { formatBindingPattern } from '../ast/binding-pattern.js'; +import { rewriteImportSource } from '../rewrite/rewrite-imports.js'; +import { inlineConstCaptures } from '../rewrite/index.js'; +import { hasUnderscorePlaceholderParams } from '../rewrite/predicates.js'; +import type { ConsolidatedSegment } from '../extraction/extract.js'; +import { + transformAllJsx, + collectScopeAwareBindings, + JsxKeyCounter, + type DevSuffixOptions, +} from '../jsx/jsx.js'; +import { transformJsxCalls, collectJsxFunctionNames } from '../jsx/jsx-call-transform.js'; +import { SignalHoister } from '../jsx/signal-analysis.js'; +import { computeKeyPrefix } from '../jsx/key-prefix.js'; +import { rewritePropsFieldReferences } from '../rewrite/props-field-rewrite.js'; +import { walkAstForQp } from '../jsx/qp-walk.js'; +import { eventHandlerQpParams } from '../jsx/loop-hoisting.js'; +import { foldBodySimplifiableExpressions } from '../jsx/simplify.js'; +import type { AstProgram } from '../../ast-types.js'; + +export { + rewriteFunctionSignature, + removeDeadConstLiterals, + injectCapturesUnpacking, +} from './body-transforms.js'; + +import { + rewriteNestedCallSitesInline, + applySelfRefIndirection, + inlineEnumReferences, + applyRawPropsToSegmentBody, + stripDiagnosticsAndDirectives, + transformSyncCalls, + ensureCoreImports, + removeDeadConstLiterals, + rewriteFunctionSignature, + injectCapturesUnpacking, + insertImportBeforeSeparator, + partsHaveImport, +} from './body-transforms.js'; +import { recollectPostTransformImports } from './import-collection.js'; + +const qrlConstName = /const\s+(q_\S+)/; + +/** + * Capture/migration payloads for one segment. `skipCaptureInjection` means the body already + * contains `_captures[i]` references (e.g. an `inlinedQrl`), so the unpacking prologue is not + * re-injected. `propsFieldCaptures` are names consolidated into `_rawProps` (field local → prop + * key); `propsFieldDefaults` carries their destructure-time defaults so defaulted fields emit + * `(_rawProps. ?? )`. `constLiterals` (captured name → literal source) are inlined + * into the body. + */ +export interface SegmentCaptureInfo { + captureNames: string[]; + autoImports: Array<{ varName: string; parentModulePath: string }>; + movedDeclarations: Array<{ + text: string; + importDeps: Array<{ localName: string; importedName: string; source: string }>; + }>; + skipCaptureInjection?: boolean; + propsFieldCaptures?: Map; + propsFieldDefaults?: Map; + constLiterals?: Map; +} + +export interface SegmentImportData { + moduleImports: Array<{ + localName: string; + importedName: string; + source: string; + importAttributes?: Record; + }>; + sameFileSymbols: Set; + defaultExportedNames?: Set; + renamedExports?: Map; + parentModulePath: string; + migrationDecisions: Array<{ + varName: string; + action: string; + isExported?: boolean; + isDirectlyExported?: boolean; + }>; + useAutoExportForExported?: boolean; +} + +/** + * `source` (the original module string) and `bodyOriginOffset` (the body's byte offset, + * `ext.loc[0]`) together yield source-relative dev-info positions: default-strategy segments wrap + * the body as `(${bodyText})` before parsing, so without them dev-info `lineNumber:` lands + * body-relative. Both are honored only when `devOptions` is set. + */ +export interface SegmentJsxOptions { + enableJsx: boolean; + importedNames: Set; + paramNames?: Set; + relPath?: string; + keyCounterStart?: number; + devOptions?: DevSuffixOptions; + source?: string; + bodyOriginOffset?: number; +} + +export interface NestedCallSiteInfo { + qrlVarName: string; + callStart: number; + callEnd: number; + /** End of the extracted closure argument; text up to `callEnd - 1` is trailing args. */ + argEnd?: number; + isJsxAttr: boolean; + attrStart?: number; + attrEnd?: number; + transformedPropName?: string; + hoistedSymbolName?: string; + hoistedCaptureNames?: string[]; + loopLocalParamNames?: string[]; + elementQpParams?: string[]; + /** The child's lifted captures include a non-const binding — its entry is var. */ + liftedNonConst?: boolean; + qrlCallee?: string; + captureNames?: string[]; + explicitCaptureItems?: string[]; + importSource?: string; +} + +interface SegmentImportSpec { + localName: string; + importedName: string; +} + +function replacePropsFieldReferences( + bodyText: string, + fieldMap: Map, + defaultValues?: ReadonlyMap +): string { + return rewritePropsFieldReferences(bodyText, fieldMap, { + memberPropertyMode: 'all', + defaultValues, + }); +} + +function buildSegmentImports( + extraction: ConsolidatedSegment, + capturedNames: Set, + importContext: SegmentImportData | undefined +): { parts: string[]; importsBySource: Map } { + const parts: string[] = []; + const importsBySource = new Map(); + + for (const imp of extraction.segmentImports) { + if (capturedNames.has(imp.localName)) { + continue; + } + + const rewrittenSource = rewriteImportSource(imp.source); + const existing = importsBySource.get(rewrittenSource); + const spec: SegmentImportSpec = { localName: imp.localName, importedName: imp.importedName }; + if (existing) { + if (!existing.some((s) => s.localName === imp.localName)) { + existing.push(spec); + } + } else { + importsBySource.set(rewrittenSource, [spec]); + } + } + + for (const [source, specs] of importsBySource) { + const defaultSpec = specs.find((s) => s.importedName === 'default'); + const nsSpec = specs.find((s) => s.importedName === '*'); + const namedSpecs = specs.filter((s) => s.importedName !== 'default' && s.importedName !== '*'); + + let importAttrsSuffix = ''; + if (importContext) { + const anySpec = specs[0]; + const moduleImp = importContext.moduleImports.find((m) => m.localName === anySpec.localName); + if (moduleImp?.importAttributes) { + const attrs = Object.entries(moduleImp.importAttributes) + .map(([k, v]) => `${k}: "${v}"`) + .join(', '); + importAttrsSuffix = ` with {\n ${attrs}\n}`; + } + } + + let importStmt = ''; + if (nsSpec) { + importStmt = `import * as ${nsSpec.localName} from "${source}"${importAttrsSuffix};`; + } else if (namedSpecs.length > 0) { + const namedStr = namedSpecs + .map((s) => + s.importedName !== s.localName ? `${s.importedName} as ${s.localName}` : s.localName + ) + .join(', '); + if (defaultSpec) { + importStmt = `import ${defaultSpec.localName}, { ${namedStr} } from "${source}"${importAttrsSuffix};`; + } else { + importStmt = `import { ${namedStr} } from "${source}"${importAttrsSuffix};`; + } + } else if (defaultSpec) { + importStmt = `import ${defaultSpec.localName} from "${source}"${importAttrsSuffix};`; + } + if (importStmt) { + parts.push(importStmt); + } + } + + return { parts, importsBySource }; +} + +function addCaptureAndMigrationImports( + parts: string[], + captureInfo: SegmentCaptureInfo | undefined +): void { + if (captureInfo && captureInfo.captureNames.length > 0 && !captureInfo.skipCaptureInjection) { + const qwikCoreImportIdx = parts.findIndex((p) => p.includes('"@qwik.dev/core"')); + if (qwikCoreImportIdx >= 0) { + const existing = parts[qwikCoreImportIdx]; + const braceStart = existing.indexOf('{'); + if (braceStart >= 0) { + parts[qwikCoreImportIdx] = + existing.slice(0, braceStart + 2) + '_captures, ' + existing.slice(braceStart + 2); + } else if (existing.includes('* as')) { + parts.push(`import { _captures } from "@qwik.dev/core";`); + } else { + parts[qwikCoreImportIdx] = existing.replace(' from', ', { _captures } from'); + } + } else { + parts.push(`import { _captures } from "@qwik.dev/core";`); + } + } + + if (captureInfo && captureInfo.autoImports.length > 0) { + for (const autoImp of captureInfo.autoImports) { + parts.push( + `import { _auto_${autoImp.varName} as ${autoImp.varName} } from "${autoImp.parentModulePath}";` + ); + } + } + + if (parts.length > 0) { + parts.push('//'); + } + + if (captureInfo && captureInfo.movedDeclarations.length > 0) { + for (const moved of captureInfo.movedDeclarations) { + for (const dep of moved.importDeps) { + const rewrittenSource = rewriteImportSource(dep.source); + let importLine: string; + if (dep.importedName === '*') { + importLine = `import * as ${dep.localName} from "${rewrittenSource}";`; + } else if (dep.importedName === 'default') { + importLine = `import ${dep.localName} from "${rewrittenSource}";`; + } else if (dep.importedName === dep.localName) { + importLine = `import { ${dep.localName} } from "${rewrittenSource}";`; + } else { + importLine = `import { ${dep.importedName} as ${dep.localName} } from "${rewrittenSource}";`; + } + // Dedupe by local name: two imports of one name is a redeclaration error + // no matter which modules they come from. + if (!partsHaveImport(parts, dep.localName)) { + insertImportBeforeSeparator(parts, importLine); + } + } + } + for (const moved of captureInfo.movedDeclarations) { + parts.push(moved.text); + } + } +} + +function addNestedQrlDeclarations(parts: string[], nestedQrlDecls: string[] | undefined): void { + if (!nestedQrlDecls || nestedQrlDecls.length === 0) { + return; + } + + const neededSymbols: string[] = []; + if (nestedQrlDecls.some((d) => d.includes('qrlDEV(') && !d.includes('_noopQrlDEV('))) { + neededSymbols.push('qrlDEV'); + } + if ( + nestedQrlDecls.some( + (d) => d.includes('qrl(') && !d.includes('_noopQrl(') && !d.includes('qrlDEV(') + ) + ) { + neededSymbols.push('qrl'); + } + if (nestedQrlDecls.some((d) => d.includes('_noopQrlDEV('))) { + neededSymbols.push('_noopQrlDEV'); + } + if (nestedQrlDecls.some((d) => d.includes('_noopQrl(') && !d.includes('_noopQrlDEV('))) { + neededSymbols.push('_noopQrl'); + } + + for (const sym of neededSymbols) { + if (!partsHaveImport(parts, sym)) { + const sepIdx = parts.indexOf('//'); + if (sepIdx >= 0) { + parts.splice(sepIdx, 0, `import { ${sym} } from "@qwik.dev/core";`); + } else { + parts.push(`import { ${sym} } from "@qwik.dev/core";`); + parts.push('//'); + } + } + } + + const sortedDecls = [...nestedQrlDecls].sort((a, b) => { + const nameA = a.match(qrlConstName)?.[1] ?? a; + const nameB = b.match(qrlConstName)?.[1] ?? b; + return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; + }); + for (const decl of sortedDecls) { + parts.push(decl); + } + parts.push('//'); +} + +/** + * Rewrites peer-tool `jsx(Tag, propsObj, ...)` calls (e.g. from `qwik-react` codegen) into + * `_jsxSorted(...)` form — complementary to `transformSegmentJsx` (which handles `` syntax), + * because peer tools pre-process JSX to `jsx()` calls that the syntax-based pass skips. + */ +function transformSegmentJsxCalls( + bodyText: string, + parts: string[], + relPath: string, + importContext: SegmentImportData, + keyCounterStartOverride: number | undefined, + qpByQrl?: ReadonlyMap, + paramNames?: readonly string[] +): { bodyText: string; keyCounterValue?: number } { + // Cheap fast-path: only parse if the body even mentions a candidate + // jsx-function name. Most segments don't. + const jsxFunctions = collectJsxFunctionNames(importContext); + if (jsxFunctions.size === 0) { + return { bodyText }; + } + + let anyMatch = false; + for (const name of jsxFunctions) { + if (bodyText.includes(name + '(')) { + anyMatch = true; + break; + } + } + if (!anyMatch) { + return { bodyText }; + } + + try { + const session = createTransformSession(bodyText, { tolerateErrors: true }); + if (!session) { + return { bodyText }; + } + + const prefix = relPath ? computeKeyPrefix(relPath) : 'u6'; + const startAt = keyCounterStartOverride ?? 0; + const keyCounter = new JsxKeyCounter(startAt, prefix); + + const neededImports = new Set(); + const importedNames = new Set(importContext.moduleImports.map((m) => m.localName)); + const signalHoister = new SignalHoister(); + transformJsxCalls(session.wrappedSource, session.edits, session.program, { + jsxFunctions, + keyCounter, + neededImports, + qpByQrl, + importedNames, + signalHoister, + paramNames, + }); + + const newBodyText = session.toSource(); + + if (newBodyText === bodyText) { + return { bodyText }; + } + + for (const sym of neededImports) { + if (!parts.some((p) => p.includes(`{ ${sym} }`) || p.includes(`, ${sym}`))) { + const sepIdx = parts.indexOf('//'); + if (sepIdx >= 0) { + parts.splice(sepIdx, 0, `import { ${sym} } from "@qwik.dev/core";`); + } else { + parts.push(`import { ${sym} } from "@qwik.dev/core";`); + } + } + } + + for (const decl of signalHoister.getDeclarations()) { + parts.push(decl); + } + + return { bodyText: newBodyText, keyCounterValue: keyCounter.current() }; + } catch { + return { bodyText }; + } +} + +function transformSegmentJsx( + bodyText: string, + parts: string[], + jsxOptions: SegmentJsxOptions, + nestedCallSites: NestedCallSiteInfo[] | undefined, + captureInfo: SegmentCaptureInfo | undefined +): { bodyText: string; keyCounterValue?: number } { + if (!/(?:<[A-Z_a-z/]|JSX)/.test(bodyText)) { + return { bodyText }; + } + + try { + const session = createTransformSession(bodyText, { tolerateErrors: true }); + if (!session) { + return { bodyText }; + } + + const qrlsWithCaptures = buildQrlsWithCapturesSet(nestedCallSites); + const qrlsNonConst = buildQrlsNonConstSet(nestedCallSites); + const qpOverrides = buildQpOverrides(nestedCallSites, session.program); + + const segScopeBindings = collectScopeAwareBindings(session.program); + if (captureInfo?.captureNames) { + // Capture names are injected by `_captures[i]` unpacking at segment + // body entry; they're runtime-const but have no AST declaration in + // this body. Inject as program-scope consts so any reference in the + // segment classifies as const (unless shadowed by an inner binding). + for (const name of captureInfo.captureNames) { + segScopeBindings.bindings.addProgramScopeConst(name); + } + } + + // The session wrapper adds a prefix; without `sourcePosition` + // dev-info `lineNumber:` would be body-relative. Source-relative + // requires the original module source + body's byte offset. + let devOptionsForCall = jsxOptions.devOptions; + if (devOptionsForCall && jsxOptions.source != null && jsxOptions.bodyOriginOffset != null) { + devOptionsForCall = { + ...devOptionsForCall, + sourcePosition: { + source: jsxOptions.source, + bodyOriginOffset: jsxOptions.bodyOriginOffset, + wrapperPrefixLen: session.offset, + }, + }; + } + + const jsxResult = transformAllJsx( + { + source: session.wrappedSource, + s: session.edits, + program: session.program, + importedNames: jsxOptions.importedNames, + }, + { + devOptions: devOptionsForCall, + keyCounterStart: jsxOptions.keyCounterStart, + qpOverrides, + qrlsWithCaptures, + qrlsNonConst, + paramNames: jsxOptions.paramNames, + relPath: jsxOptions.relPath, + precomputedScopeBindings: segScopeBindings, + } + ); + + bodyText = session.toSource(); + + if (jsxResult.needsFragment && !parts.some((p) => p.includes('_Fragment'))) { + parts.splice( + parts.indexOf('//'), + 0, + `import { Fragment as _Fragment } from "@qwik.dev/core/jsx-runtime";` + ); + } + for (const sym of jsxResult.neededImports) { + if (!parts.some((p) => p.includes(`{ ${sym} }`) || p.includes(`, ${sym}`))) { + parts.splice(parts.indexOf('//'), 0, `import { ${sym} } from "@qwik.dev/core";`); + } + } + if (jsxResult.hoistedDeclarations) { + for (const decl of jsxResult.hoistedDeclarations) { + parts.push(decl); + } + } + + return { bodyText, keyCounterValue: jsxResult.keyCounterValue }; + } catch { + return { bodyText }; + } +} + +function buildQrlsNonConstSet( + nestedCallSites: NestedCallSiteInfo[] | undefined +): Set | undefined { + if (!nestedCallSites) { + return undefined; + } + const result = new Set(); + for (const site of nestedCallSites) { + if (site.liftedNonConst) { + result.add(site.qrlVarName); + } + } + return result.size > 0 ? result : undefined; +} + +function buildQrlsWithCapturesSet( + nestedCallSites: NestedCallSiteInfo[] | undefined +): Set | undefined { + if (!nestedCallSites) { + return undefined; + } + const result = new Set(); + for (const site of nestedCallSites) { + // Either form of cross-loop wiring contributes a "captures" qrl: the + // explicit loop-local param case (`_, _1, iterVar`) and the + // cross-scope-capture case (`hoistedSymbolName` set, no loop-local + // padding). Both flow data per iteration; classifying the event + // handler entry as var rather than const is correct for both. + const hasLoopLocal = site.loopLocalParamNames && site.loopLocalParamNames.length > 0; + const hasHoistedCaptures = !!site.hoistedSymbolName; + if (hasLoopLocal || hasHoistedCaptures) { + result.add(site.qrlVarName); + if (site.hoistedSymbolName) { + result.add(site.hoistedSymbolName); + } + } + } + return result.size > 0 ? result : undefined; +} + +function buildQpOverrides( + nestedCallSites: NestedCallSiteInfo[] | undefined, + program: AstProgram +): Map | undefined { + // Also fire when any nestedCallSite carries `elementQpParams` from + // `buildElementCaptureMap` (covers the stripped-event-with-captures + // case where there's no `loopLocalParamNames`, since stripped handlers + // aren't required to be in a loop). + if ( + !nestedCallSites || + !nestedCallSites.some( + (s) => + (s.loopLocalParamNames && s.loopLocalParamNames.length > 0) || + (s.elementQpParams && s.elementQpParams.length > 0) + ) + ) { + return undefined; + } + + const qpOverrides = new Map(); + const qrlParamMap = new Map(); + for (const site of nestedCallSites) { + if (!site.loopLocalParamNames || site.loopLocalParamNames.length === 0) { + continue; + } + qrlParamMap.set(site.qrlVarName, site.loopLocalParamNames); + if (site.hoistedSymbolName) { + qrlParamMap.set(site.hoistedSymbolName, site.loopLocalParamNames); + } + } + + // Per-attr site lookup (not a pre-merged map) so a site that matches by + // name but carries no `elementQpParams` still falls back to the + // loop-local params keyed under the same name. + const resolveParams = (qrlName: string): readonly string[] | undefined => { + const site = nestedCallSites.find( + (s) => s.qrlVarName === qrlName || s.hoistedSymbolName === qrlName + ); + return site?.elementQpParams ?? qrlParamMap.get(qrlName); + }; + + walkAstForQp(program, resolveParams, qpOverrides); + return qpOverrides.size > 0 ? qpOverrides : undefined; +} + +function normalizeSeparators(parts: string[]): void { + const allParts = parts.filter((p) => p !== '//'); + const imports: string[] = []; + const hoisted: string[] = []; + const qrlDecls: string[] = []; + const other: string[] = []; + + for (const p of allParts) { + if (p.startsWith('import ')) { + imports.push(p); + } else if (p.trimStart().startsWith('const _hf')) { + hoisted.push(p); + } else if (p.trimStart().startsWith('const q_')) { + qrlDecls.push(p); + } else { + other.push(p); + } + } + imports.sort((a, b) => { + const rank = (value: string) => + value.includes('_captures') + ? -1 + : value.includes('@qwik.dev/core/jsx-') + ? 1 + : value.includes('@qwik.dev/core') + ? 2 + : 0; + return rank(a) - rank(b); + }); + + parts.length = 0; + if (imports.length > 0) { + parts.push(...imports, '//'); + } + if (hoisted.length > 0) { + parts.push(...hoisted); + if (other.length > 0 || qrlDecls.length > 0) { + parts.push('//'); + } + } + const qrlNames = qrlDecls.map((declaration) => declaration.match(qrlConstName)?.[1]); + const usesNestedQrl = (statement: string) => + qrlNames.some((name) => name && new RegExp(`\\b${name}\\b`).test(statement)); + const beforeQrl = other.filter((statement) => !usesNestedQrl(statement)); + const afterQrl = other.filter(usesNestedQrl); + if (beforeQrl.length > 0) { + parts.push(...beforeQrl); + if (qrlDecls.length > 0) { + parts.push('//'); + } + } + if (qrlDecls.length > 0) { + parts.push(...qrlDecls, '//'); + } + parts.push(...afterQrl); +} + +function collectInitialImports( + extraction: ConsolidatedSegment, + capturedNames: Set, + captureInfo: SegmentCaptureInfo | undefined, + nestedQrlDecls: string[] | undefined, + importContext: SegmentImportData | undefined +): { parts: string[]; importsBySource: Map } { + const { parts, importsBySource } = buildSegmentImports(extraction, capturedNames, importContext); + addCaptureAndMigrationImports(parts, captureInfo); + addNestedQrlDeclarations(parts, nestedQrlDecls); + return { parts, importsBySource }; +} + +/** + * Returns the updated `captureInfo` because const-literal inlining filters inlined names out of + * `captureNames`; downstream phases (JSX, captures unpacking) need that filtered view. + */ +function applyBodyTransforms( + extraction: ConsolidatedSegment, + parts: string[], + captureInfo: SegmentCaptureInfo | undefined, + nestedCallSites: NestedCallSiteInfo[] | undefined, + enumValueMap: Map> | undefined, + preserveOffsets: boolean +): { bodyText: string; captureInfo: SegmentCaptureInfo | undefined } { + // Internal helpers work on plain string; the BodyText brand applies only at + // the ExtractionResult boundary. + let bodyText: string = extraction.bodyText; + + if (nestedCallSites && nestedCallSites.length > 0) { + bodyText = rewriteNestedCallSitesInline( + bodyText, + nestedCallSites, + extraction.argStart, + preserveOffsets + ); + bodyText = applySelfRefIndirection(bodyText); + } + + if (enumValueMap && enumValueMap.size > 0) { + bodyText = inlineEnumReferences(bodyText, enumValueMap); + } + + // `inlinedQrl` bodies are pre-compiled library code: their first arg is a + // finished closure whose destructured first param (e.g. a `useTask$`'s + // `({ track })` context) is NOT component props and must not be normalised to + // `_rawProps`. Doing so renamed the param while the body still referenced the + // original binding and dropped the closing brace, producing an unbalanced, + // unparseable segment. + if (!extraction.isInlinedQrl) { + bodyText = applyRawPropsToSegmentBody(bodyText, parts); + } + bodyText = stripDiagnosticsAndDirectives(bodyText); + + const propsFieldCaptures = captureInfo?.propsFieldCaptures; + if (propsFieldCaptures && propsFieldCaptures.size > 0) { + // Pass `propsFieldDefaults` so defaulted fields emit + // `(_rawProps. ?? )`. + bodyText = replacePropsFieldReferences( + bodyText, + propsFieldCaptures, + captureInfo?.propsFieldDefaults + ); + } + + let liveCaptureInfo = captureInfo; + const constLiterals = captureInfo?.constLiterals; + if (constLiterals && constLiterals.size > 0 && captureInfo) { + bodyText = inlineConstCaptures(bodyText, constLiterals); + liveCaptureInfo = { + ...captureInfo, + captureNames: captureInfo.captureNames.filter((n) => !constLiterals.has(n)), + }; + } + + if ( + liveCaptureInfo && + liveCaptureInfo.captureNames.length > 0 && + !liveCaptureInfo.skipCaptureInjection + ) { + bodyText = injectCapturesUnpacking(bodyText, liveCaptureInfo.captureNames); + } + + return { bodyText, captureInfo: liveCaptureInfo }; +} + +export function generateSegmentCode( + extraction: ConsolidatedSegment, + nestedQrlDecls?: string[], + captureInfo?: SegmentCaptureInfo, + jsxOptions?: SegmentJsxOptions, + nestedCallSites?: NestedCallSiteInfo[], + importContext?: SegmentImportData, + enumValueMap?: Map>, + inlinedIdentifiers?: ReadonlySet +): { code: string; keyCounterValue?: number; paramNames?: string[] } { + const capturedNames = new Set(captureInfo ? captureInfo.captureNames : []); + + const { parts, importsBySource } = collectInitialImports( + extraction, + capturedNames, + captureInfo, + nestedQrlDecls, + importContext + ); + + const transformed = applyBodyTransforms( + extraction, + parts, + captureInfo, + nestedCallSites, + enumValueMap, + jsxOptions?.devOptions !== undefined + ); + const liveCaptureInfo = transformed.captureInfo; + let bodyText = transformed.bodyText; + + let segmentKeyCounterValue: number | undefined; + if (jsxOptions?.enableJsx) { + const jsxResult = transformSegmentJsx( + bodyText, + parts, + jsxOptions, + nestedCallSites, + liveCaptureInfo + ); + bodyText = jsxResult.bodyText; + segmentKeyCounterValue = jsxResult.keyCounterValue; + } + + // Peer-tool JSX-call rewriting: `qwik-react` and similar peer codegen emit + // `jsx(Tag, propsObj)` calls (pre-processed from JSX syntax) that Phase 5's + // syntax transform skips, and Phase 5 may be skipped entirely when the source + // isn't .tsx/.jsx (qwik-react ships as .mjs). Rewrite them to `_jsxSorted(...)` + // here. Runs unconditionally — the fast-path in `transformSegmentJsxCalls` + // skips when no jsx imports are present. + if (importContext) { + // Map each event-handler QRL var (`q_`) to its `q:p`/`q:ps` capture + // params, so the peer-tool JSX-call rewriter can inject the prop onto the + // owning element. Built from the nested call sites' `elementQpParams`. + const qpByQrl = new Map(); + for (const site of nestedCallSites ?? []) { + if (site.elementQpParams && site.elementQpParams.length > 0) { + qpByQrl.set(site.qrlVarName, site.elementQpParams); + } + } + const jsxCallResult = transformSegmentJsxCalls( + bodyText, + parts, + extraction.origin, + importContext, + segmentKeyCounterValue, + qpByQrl.size > 0 ? qpByQrl : undefined, + extraction.paramNames + ); + bodyText = jsxCallResult.bodyText; + if (jsxCallResult.keyCounterValue !== undefined) { + segmentKeyCounterValue = jsxCallResult.keyCounterValue; + } + } + + // Fold constant-foldable subtrees that survived earlier passes (typically + // `?? ` RHS injected by raw-props in non-JSX positions). Runs AFTER + // Phase 5/5b so `_hf_str` is generated source-preserving and JSX-prop + // positions are now `_fnSignal(...)` calls with no `?? ` left to fold. + bodyText = foldBodySimplifiableExpressions(bodyText); + + // Include moved-decl text (already in `parts`) when scanning for core + // helpers — a moved helper function with rewritten JSX may reference + // `_jsxSplit` / `_getVarProps` / `_getConstProps` etc. that the segment's + // main body doesn't. The `//` separator may not yet be present when + // `ensureCoreImports` runs (added later by `normalizeSeparators`), so include + // a separator before calling so the early-return path doesn't bail out. + const scanText = + bodyText + '\n' + parts.filter((p) => !p.startsWith('import') && p !== '//').join('\n'); + if (parts.indexOf('//') < 0) { + parts.push('//'); + } + ensureCoreImports(scanText, parts); + bodyText = transformSyncCalls(bodyText, parts); + + normalizeSeparators(parts); + + if (extraction.isWorkerEventWrapper) { + // The nested-call rewrite above turned the body into `workerQrl(q_X)`; + // wrap it in the delegating handler that forwards event/element + lifted + // params positionally. + const lifted = eventHandlerQpParams(extraction.paramNames); + const params = ['event', 'element', ...lifted].join(', '); + bodyText = `(${params})=>${bodyText}(${params})`; + } else if (hasUnderscorePlaceholderParams(extraction.paramNames, extraction.movedCaptures)) { + bodyText = rewriteFunctionSignature(bodyText, extraction.paramNames); + } + + if (importContext) { + recollectPostTransformImports( + bodyText, + parts, + importContext, + importsBySource, + capturedNames, + nestedCallSites + ); + } + normalizeSeparators(parts); + + if (nestedCallSites && nestedCallSites.length > 0) { + bodyText = removeDeadConstLiterals(bodyText, inlinedIdentifiers); + } + + const paramNames = createFunctionTransformSession(bodyText, { tolerateErrors: true }) + ?.fn.params.map(formatBindingPattern) + .filter((name) => name !== undefined); + parts.push(`export const ${extraction.symbolName} = ${bodyText};`); + return { + code: parts.join('\n'), + keyCounterValue: segmentKeyCounterValue, + paramNames: paramNames?.length ? paramNames : undefined, + }; +} diff --git a/packages/ts-optimizer/src/optimizer/segment/segment-generation.ts b/packages/ts-optimizer/src/optimizer/segment/segment-generation.ts new file mode 100644 index 00000000000..8b3049406c0 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/segment-generation.ts @@ -0,0 +1,1748 @@ +import type { AstFunction, AstNode, AstProgram, TSEnumDeclaration } from '../../ast-types.js'; +import type { ConsolidatedSegment, ExtractionResult, Mutable } from '../extraction/extract.js'; +import type { ImportInfo } from '../extraction/marker-detection.js'; +import { + collectDeclIdentifiers, + type MigrationDecision, + type ModuleLevelDecl, +} from '../analysis/variable-migration.js'; +import type { + TransformModule, + SegmentMetadataInternal, + TransformModulesOptions, + EntryStrategy, +} from '../types/types.js'; +import { hasManualEntryMap } from '../types/types.js'; +import { + generateSegmentCode, + type SegmentCaptureInfo, + type NestedCallSiteInfo, + type SegmentImportData, +} from './segment-codegen.js'; +import { join } from 'pathe'; +import { getDirectory } from '../../paths.js'; +import { resolveEntryField } from './entry-strategy.js'; +import { + buildQrlDeclaration, + buildWorkerQrlDeclaration, + isWorkerExtraction, + markMovedCaptures, + needsPureAnnotation, +} from '../rewrite/rewrite-calls.js'; +import { getQrlCalleeName } from '../qwik/qrl-naming.js'; +import { getQrlImportSource } from '../rewrite/rewrite-calls.js'; +import { isHtmlElement } from '../jsx/jsx.js'; +import { isJsxCall } from '../jsx/jsx-call-transform.js'; +import { resolveSameFileImportName } from './import-collection.js'; +import { buildQrlDevDeclaration, formatDevMeta } from './dev-mode.js'; +import { generateStrippedSegmentCode } from './strip-ctx.js'; +import { + advancesSentinelCounter, + hasUnderscorePlaceholderParams, + isStrippedExtraction, + matchesRegCtxName, +} from '../rewrite/predicates.js'; +import { mkByteOffset, mkRelativePath } from '../types/brands.js'; +import { escapeSymbol } from '../../hashing/naming.js'; +import { + buildNoopQrlDeclaration, + buildNoopQrlDevDeclaration, + buildStrippedNoopQrl, + buildStrippedNoopQrlDev, + getSentinelCounter, +} from './inline-strategy.js'; +import { eventHandlerPropName } from '../jsx/event-handlers.js'; +import { parseArrayItems } from '../qwik/w-call.js'; +import { hoistInlinedQrlBodies } from './hoist-inlined-qrl.js'; +import { + extractDestructuredFieldInfo, + bodyConsolidatesToRawProps, + consolidateQpCaptureValues, +} from '../rewrite/index.js'; +import { collectSameFileSymbolInfo } from './module-symbols.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { forEachAstChild } from '../ast/guards.js'; +import { leadingDot, resolveCaptureInfo, postProcessSegmentCode } from './post-process.js'; +import { collectBodyIdentifiers } from './import-collection.js'; +import type { LoopContext } from '../jsx/loop-hoisting.js'; +import { eventHandlerQpParams } from '../jsx/loop-hoisting.js'; +import { getJsxAttributeName } from '../jsx/jsx-attr-name.js'; + +/** + * Resolve the on-disk extension for a segment's emitted file (`module.path` and the `extension` + * metadata field). MUST equal the extension used by the QRL import specifiers that target the + * segment: the bundler keys its segment registry on `module.path`, so a sibling importing + * `./foo.js` while the segment registered as `./foo.mjs` misses the lookup and Rolldown reports + * UNRESOLVED_IMPORT. The resolution chain matches the import sites: parent- derived output + * extension wins, then the segment's JSX-flipped source extension, then the raw source extension. + */ +function resolveSegmentFileExtension( + symbolName: string, + segmentExtension: string, + qrlOutputExt: string | undefined, + sourceExtensions: Map +): string { + return qrlOutputExt ?? sourceExtensions.get(symbolName) ?? segmentExtension; +} + +export function collectEnumValueMap( + program: AstProgram, + shouldTranspileTs: boolean +): Map> { + const enumValueMap = new Map>(); + if (!shouldTranspileTs) { + return enumValueMap; + } + + for (const node of program.body) { + let enumDecl: TSEnumDeclaration | null = null; + if (node.type === 'TSEnumDeclaration') { + enumDecl = node; + } else if ( + node.type === 'ExportNamedDeclaration' && + node.declaration?.type === 'TSEnumDeclaration' + ) { + enumDecl = node.declaration; + } + if (enumDecl && enumDecl.id?.name && enumDecl.body?.members) { + const members = new Map(); + let autoValue = 0; + for (const member of enumDecl.body.members) { + const memberName = + member.id.type === 'Identifier' + ? member.id.name + : member.id.type === 'Literal' && typeof member.id.value === 'string' + ? member.id.value + : null; + if (!memberName) { + continue; + } + if (member.initializer) { + if ( + member.initializer.type === 'Literal' && + typeof member.initializer.value === 'number' + ) { + const val = String(member.initializer.value); + members.set(memberName, val); + autoValue = Number(member.initializer.value) + 1; + } else if ( + member.initializer.type === 'Literal' && + typeof member.initializer.value === 'string' + ) { + members.set(memberName, JSON.stringify(member.initializer.value)); + autoValue = NaN; // String enums break auto-increment + } else { + autoValue = NaN; + } + } else { + members.set(memberName, String(autoValue)); + autoValue++; + } + } + if (members.size > 0) { + enumValueMap.set(enumDecl.id.name, members); + } + } + } + return enumValueMap; +} + +export function collectImportAttributes(program: AstProgram): Map> { + const importAttributesMap = new Map>(); + for (const node of program.body) { + if (node.type !== 'ImportDeclaration') { + continue; + } + const attrs = node.attributes; + if (attrs && attrs.length > 0) { + const attrObj: Record = {}; + for (const attr of attrs) { + const key = attr.key?.type === 'Identifier' ? attr.key.name : attr.key?.value; + const value = attr.value?.value; + if (key && value) { + attrObj[key] = value; + } + } + for (const spec of node.specifiers) { + const localName = spec.local?.name; + if (localName) { + importAttributesMap.set(localName, attrObj); + } + } + } + } + return importAttributesMap; +} + +export function buildSegmentImportList( + originalImports: Map, + importAttributesMap: Map> +): SegmentImportData['moduleImports'] { + const segmentImportList: SegmentImportData['moduleImports'] = []; + const addedModuleImports = new Set(); + for (const [, imp] of originalImports) { + segmentImportList.push({ + localName: imp.localName, + importedName: imp.importedName, + source: imp.source, + importAttributes: importAttributesMap.get(imp.localName), + }); + addedModuleImports.add(imp.localName); + } + + // Also include optimizer-injected runtime imports (qrl, componentQrl, etc.) + // that the parent module uses but weren't in the original source imports. + // These are needed so segment post-transform import scanning can find them. + const runtimeImports = [ + 'qrl', + 'qrlDEV', + 'componentQrl', + '_noopQrl', + '_noopQrlDEV', + '_qrlSync', + '_captures', + '_jsxSorted', + '_jsxSplit', + '_fnSignal', + '_wrapProp', + '_restProps', + '_getVarProps', + '_getConstProps', + '_regSymbol', + '_useHmr', + 'serverQrl', + 'serverLoaderQrl', + 'serverStuffQrl', + 'serverActionQrl', + 'useTaskQrl', + 'useStyleQrl', + 'useStylesQrl', + 'useClientMountQrl', + 'formActionQrl', + 'useServerMountQrl', + 'inlinedQrl', + ]; + // A `*Qrl` dispatcher (e.g. `serverQrl`) belongs to whatever package the + // `*$` marker was imported from — only truly-internal helpers default to core. + const dollarFormSource = (name: string): string | undefined => { + if (!name.endsWith('Qrl')) { + return undefined; + } + const dollarName = `${name.slice(0, -3)}$`; + for (const [, imp] of originalImports) { + if (imp.importedName === dollarName) { + return imp.source; + } + } + return undefined; + }; + for (const name of runtimeImports) { + if (!addedModuleImports.has(name)) { + segmentImportList.push({ + localName: name, + importedName: name, + source: dollarFormSource(name) ?? '@qwik.dev/core', + }); + } + } + + return segmentImportList; +} + +export interface SegmentGenerationContext { + extractions: ConsolidatedSegment[]; + updatedExtractions: ConsolidatedSegment[]; + /** + * Closure AST nodes threaded from Phase 1, keyed by (post-rename) symbolName. Lets the JSX-key + * pre-count walk the original AST instead of re-parsing each body text. + */ + closureNodes: ReadonlyMap; + program: AstProgram; + originalImports: Map; + options: TransformModulesOptions; + repairedCode: string; + relPath: string; + /** + * The original `input.path` (whatever the consumer supplied — absolute or relative). Distinct + * from `relPath`, which has been made srcDir-relative. Segment `module.path` derives its + * directory portion from this so output paths live in the same namespace as inputs. + */ + inputPath: string; + emitMode: string; + devFile: string | undefined; + /** + * Raw user-supplied `input.devPath`. Distinguished from `devFile` (which always falls back to a + * composed path); JSX dev-info `fileName:` only honors the explicit override. + */ + userDevPath: string | undefined; + isInlineStrategy: boolean; + entryStrategy: EntryStrategy; + migrationDecisions: MigrationDecision[]; + moduleLevelDecls: ModuleLevelDecl[]; + moduleLevelDeclsByName: Map; + /** + * Per-varName post-JSX-rewrite source text for MOVE-target decls. The parent rewrite captures + * these post-`runJsxTransform` (pre-assembly) so JSX inside a moved helper function (e.g. + * `function Hola(props) { return
; }`) carries the rewritten Qwik form into the + * segment file. Without this, the raw source survives the move and oxc-transform's TS-strip pass + * emits React `_jsx(...)` instead. + */ + movedDeclSnapshots: Map; + segmentUsage: Map>; + parentModulePath: string; + preRenameSymbolName: Map; + qrlOutputExt: string | undefined; + sourceExtensions: Map; + /** + * The parent input file's extension. Threaded to `postProcessSegmentCode` so oxc-transform's + * TS-strip / JSX-strip parses the segment body in the source dialect (the segment's own + * `extension` is often downgraded to `.js` even when the body contains TS or JSX). + */ + parentSourceExt: string; + shouldTranspileJsx: boolean; + shouldTranspileTs: boolean; + isJsx: boolean; + importedNames: Set; + enclosingExtMap: Map; + elementQpParamsMap: Map; + extractionLoopMap: Map; + constLiteralsMap: Map>; + inlinedIdentifiersByParent: ReadonlyMap>; + parentJsxKeyCounterValue: number; + /** Per-extraction key bases from the parent's module-order walk (keyed by argStart). */ + jsxRegionKeyBases?: ReadonlyMap; + /** + * Source carries `/* @jsxImportSource *‌/`. When true, segment-codegen skips + * Qwik's JSX-syntax rewrite and prepends `foreignJsxPragmaText` to each segment file so + * oxc-transform's default JSX transform (run by `postProcessSegmentCode`) honors the pragma. + */ + hasForeignJsxRuntime: boolean; + foreignJsxPragmaText: string | null; +} + +/** + * Immutable per-call setup data produced once before the per-extraction loop in + * {@link generateAllSegmentModules}. Fields are logically read-only across the loop body (their + * types stay mutable only to match downstream signatures like `SegmentImportData` / + * `generateSegmentCode`), so the per-extraction code has no setup-time side effects to reason + * about. `sortedExtractions` is the same reference as `ctx.updatedExtractions`, which + * {@link computeSegmentGenerationPrep} sorts in place (children before parents). `fieldDefaultsMaps` + * parallels `fieldMaps`: per parent symbol, destructure-time default expressions keyed by + * local-binding name (empty inner map ⇒ no defaults, fall through to bare `_rawProps.`). + */ +export interface SegmentGenerationPrep { + extBySymbol: Map; + sortedExtractions: ConsolidatedSegment[]; + sameFileSymbols: Set; + defaultExportedNames: Set; + renamedExports: Map; + segmentImportList: SegmentImportData['moduleImports']; + enumValueMap: Map>; + fieldMaps: ReadonlyMap>; + fieldDefaultsMaps: ReadonlyMap>; +} + +/** + * Result of {@link consolidateRawPropsCaptures}. Returned (vs in-place mutation) so the caller + * decides which surface to write to — the inline-strategy path writes `ext.propsFieldCaptures`, the + * default-strategy path writes `captureInfo.propsFieldCaptures`; both also mutate + * `ext.captureNames` and `ext.captures`. `newCaptureNames` is sorted and includes the literal + * `"_rawProps"` sentinel. `propsFieldDefaults` holds destructure-time defaults for captures that + * resolved to a defaulted parent prop (emitted downstream as `(_rawProps. ?? )`), + * undefined when none apply. + */ +export interface RawPropsConsolidation { + propsFieldCaptures: Map; + newCaptureNames: string[]; + propsFieldDefaults?: Map; +} + +/** + * Returns `null` when no captures resolve to parent props fields (the caller then leaves `ext` + * untouched). Shared by the inline-strategy metadata path and the default-strategy codegen path — + * identical algorithm, only the write surfaces differ. + */ +export function consolidateRawPropsCaptures( + captureNames: readonly string[], + fieldMap: ReadonlyMap, + fieldDefaults?: ReadonlyMap +): RawPropsConsolidation | null { + const propsFieldCaptures = new Map(); + const propsFieldDefaults = new Map(); + const nonPropsCaptures: string[] = []; + for (const name of captureNames) { + const fieldExpr = fieldMap.get(name); + if (fieldExpr !== undefined) { + propsFieldCaptures.set(name, fieldExpr); + const defaultExpr = fieldDefaults?.get(name); + if (defaultExpr !== undefined) { + propsFieldDefaults.set(name, defaultExpr); + } + } else { + nonPropsCaptures.push(name); + } + } + if (propsFieldCaptures.size === 0) { + return null; + } + return { + propsFieldCaptures, + newCaptureNames: [...nonPropsCaptures, '_rawProps'].sort(), + propsFieldDefaults: propsFieldDefaults.size > 0 ? propsFieldDefaults : undefined, + }; +} + +function buildParentFieldMaps( + extractions: readonly ConsolidatedSegment[], + extBySymbol: ReadonlyMap +): { + fieldMaps: ReadonlyMap>; + fieldDefaultsMaps: ReadonlyMap>; +} { + const parentSymbolNames = new Set(); + for (const ext of extractions) { + if (ext.parent !== null) { + parentSymbolNames.add(ext.parent); + } + } + const fieldMaps = new Map>(); + const fieldDefaultsMaps = new Map>(); + for (const symbolName of parentSymbolNames) { + const parentExt = extBySymbol.get(symbolName); + if (parentExt !== undefined) { + const info = extractDestructuredFieldInfo(parentExt.bodyText); + fieldMaps.set(symbolName, info.fieldMap); + fieldDefaultsMaps.set(symbolName, info.fieldDefaults); + } + } + return { fieldMaps, fieldDefaultsMaps }; +} + +/** + * Returns `null` when consolidation doesn't apply (no parent, no captures, or the parent has no + * destructured fields). The caller applies the result to its own surface — inline-strategy writes + * `ext`, default-strategy writes `captureInfo` — so the divergent writes stay explicit at the call + * site. + */ +function tryConsolidateRawProps( + ext: ConsolidatedSegment, + prep: SegmentGenerationPrep +): RawPropsConsolidation | null { + if (ext.parent === null || ext.captureNames.length === 0) { + return null; + } + const fieldMap = prep.fieldMaps.get(ext.parent); + if (fieldMap === undefined || fieldMap.size === 0) { + return null; + } + const fieldDefaults = prep.fieldDefaultsMaps.get(ext.parent); + return consolidateRawPropsCaptures(ext.captureNames, fieldMap, fieldDefaults); +} + +/** + * Both strategy builders produce the identical {@link SegmentMetadataInternal} shape; only + * `entryField` and `outputExtension` are computed differently upstream, so those are the only + * per-call parameters. + */ +function buildSegmentMetadata( + ext: ConsolidatedSegment, + entryField: string | null, + outputExtension: string, + paramNames?: string[] +): SegmentMetadataInternal { + return { + origin: ext.origin, + name: ext.symbolName, + entry: entryField, + displayName: ext.displayName, + hash: ext.hash, + canonicalFilename: ext.canonicalFilename, + path: getDirectory(ext.origin), + extension: outputExtension.replace(leadingDot, ''), + parent: ext.parent, + ctxKind: ext.ctxKind, + ctxName: ext.ctxName, + captures: ext.captures, + loc: ext.loc, + captureNames: ext.captureNames.length > 0 ? ext.captureNames : undefined, + paramNames, + }; +} + +/** Mutates `ctx.updatedExtractions` in place to depth-sort children before parents. */ +export function computeSegmentGenerationPrep(ctx: SegmentGenerationContext): SegmentGenerationPrep { + const extBySymbol = new Map(); + for (const ext of ctx.updatedExtractions) { + extBySymbol.set(ext.symbolName, ext); + } + + const extractionDepth = new Map(); + for (const ext of ctx.updatedExtractions) { + let depth = 0; + let current = ext.parent; + while (current) { + depth++; + const parentExt = extBySymbol.get(current); + current = parentExt?.parent ?? null; + } + extractionDepth.set(ext.symbolName, depth); + } + ctx.updatedExtractions.sort((a, b) => { + const da = extractionDepth.get(a.symbolName) ?? 0; + const db = extractionDepth.get(b.symbolName) ?? 0; + return db - da; + }); + + const { sameFileSymbols, defaultExportedNames, renamedExports } = collectSameFileSymbolInfo( + ctx.program + ); + + const importAttributesMap = collectImportAttributes(ctx.program); + const segmentImportList = buildSegmentImportList(ctx.originalImports, importAttributesMap); + + const enumValueMap = collectEnumValueMap(ctx.program, ctx.shouldTranspileTs); + + const { fieldMaps, fieldDefaultsMaps } = buildParentFieldMaps( + ctx.updatedExtractions, + extBySymbol + ); + + return { + extBySymbol, + sortedExtractions: ctx.updatedExtractions, + sameFileSymbols, + defaultExportedNames, + renamedExports, + segmentImportList, + enumValueMap, + fieldMaps, + fieldDefaultsMaps, + }; +} + +/** + * Returns `null` for non-stripped extractions — their body is inlined into the parent and no + * per-segment file lands on disk. Stripped extractions still get their own file holding the `export + * const X = null` stub the runtime resolver expects when a stripped QRL is referenced. + * + * Mutates `ext.captureNames`, `ext.captures`, and `ext.propsFieldCaptures` when raw-props + * consolidation applies — unconditionally, because the consolidated capture metadata is also + * consumed by the parent's inlined `q_X.s(body)` emission. + */ +export function buildInlineStrategySegment( + ext: ConsolidatedSegment, + ctx: SegmentGenerationContext, + prep: SegmentGenerationPrep, + stripped: boolean +): TransformModule | null { + const rawProps = tryConsolidateRawProps(ext, prep); + if (rawProps !== null) { + ext.propsFieldCaptures = rawProps.propsFieldCaptures; + if (rawProps.propsFieldDefaults !== undefined) { + ext.propsFieldDefaults = rawProps.propsFieldDefaults; + } + ext.captureNames = rawProps.newCaptureNames; + ext.captures = rawProps.newCaptureNames.length > 0; + } + + const entryField = resolveEntryField(ctx.entryStrategy.type, ext, undefined); + + if (!stripped) { + return null; + } + + const outputExtension = resolveSegmentFileExtension( + ext.symbolName, + ext.extension, + ctx.qrlOutputExt, + ctx.sourceExtensions + ); + const segmentAnalysis = buildSegmentMetadata(ext, entryField, outputExtension); + + return { + kind: 'segment', + path: mkRelativePath( + join(getDirectory(ctx.inputPath), ext.canonicalFilename + outputExtension) + ), + isEntry: true, + code: generateStrippedSegmentCode(ext.symbolName), + map: null, + segment: segmentAnalysis, + }; +} + +/** + * Returns the declaration strings paired with `childQrlVarNames` (`child.symbolName → q_`, or + * `q_qrl_` for stripped children). Stripped-segment indexing is per-call — each call owns + * its `strippedIdx` counter via {@link getSentinelCounter}. + */ +export function buildNestedQrlDeclarations( + children: ConsolidatedSegment[], + options: TransformModulesOptions, + isDevMode: boolean, + devFile: string | undefined, + qrlOutputExt: string | undefined, + sentinelIndexes?: ReadonlyMap +): { + nestedQrlDecls: string[]; + childQrlVarNames: Map; +} { + let strippedIdx = 0; + const childQrlVarNames = new Map(); + const nestedQrlDecls = children.map((child) => { + if (isWorkerExtraction(child)) { + const idx = sentinelIndexes?.get(child.symbolName) ?? strippedIdx++; + const varName = `q_qrl_${getSentinelCounter(idx)}`; + childQrlVarNames.set(child.symbolName, varName); + const devMeta = + isDevMode && devFile + ? formatDevMeta({ + file: devFile, + lo: child.loc[0], + hi: child.loc[1], + displayName: child.displayName, + }) + : undefined; + return buildWorkerQrlDeclaration( + varName, + child.symbolName, + child.canonicalFilename, + options.explicitExtensions, + qrlOutputExt, + devMeta + ); + } + const childStripped = isStrippedExtraction( + child, + options.stripCtxName, + options.stripEventHandlers + ); + const idx = sentinelIndexes?.get(child.symbolName) ?? strippedIdx; + if (!sentinelIndexes && advancesSentinelCounter(child, childStripped)) { + strippedIdx++; + } + if (childStripped) { + const counter = getSentinelCounter(idx); + childQrlVarNames.set(child.symbolName, `q_qrl_${counter}`); + if (isDevMode && devFile) { + return markMovedCaptures( + buildStrippedNoopQrlDev(child.symbolName, idx, { + file: devFile, + lo: 0, + hi: 0, + displayName: child.displayName, + }), + child + ); + } + return markMovedCaptures(buildStrippedNoopQrl(child.symbolName, idx), child); + } + childQrlVarNames.set(child.symbolName, `q_${child.symbolName}`); + if (isDevMode && devFile) { + const devExt = options.explicitExtensions ? (qrlOutputExt ?? '.js') : undefined; + return markMovedCaptures( + buildQrlDevDeclaration( + child.symbolName, + child.canonicalFilename, + devFile, + child.loc[0], + child.loc[1], + child.displayName, + devExt + ), + child + ); + } + return markMovedCaptures( + buildQrlDeclaration( + child.symbolName, + child.canonicalFilename, + options.explicitExtensions, + child.extension, + qrlOutputExt + ), + child + ); + }); + return { nestedQrlDecls, childQrlVarNames }; +} + +function buildDescendantSentinelIndexes( + parentSymbol: string, + extractions: readonly ConsolidatedSegment[], + options: TransformModulesOptions +): Map { + const childrenByParent = new Map(); + for (const extraction of extractions) { + if (extraction.isSync || extraction.parent === null) { + continue; + } + const siblings = childrenByParent.get(extraction.parent) ?? []; + siblings.push(extraction); + childrenByParent.set(extraction.parent, siblings); + } + const indexes = new Map(); + let counter = 0; + const visit = (symbol: string): void => { + for (const child of (childrenByParent.get(symbol) ?? []).sort( + (a, b) => a.callStart - b.callStart + )) { + visit(child.symbolName); + const stripped = isStrippedExtraction( + child, + options.stripCtxName, + options.stripEventHandlers + ); + if (advancesSentinelCounter(child, stripped)) { + indexes.set(child.symbolName, counter++); + } + } + }; + visit(parentSymbol); + return indexes; +} + +/** + * A stripped body has no chunk, so it registers a `_noopQrl` rather than a + * `qrl(()=>import(strippedChunk))` that would resolve to the chunk's `null`. + */ +function buildMovedQrlDecl( + ext: ConsolidatedSegment, + options: TransformModulesOptions, + isDevMode: boolean, + devFile: string | undefined, + qrlOutputExt: string | undefined, + sourceExtensions: Map +): { decl: string; qrlHelper: string } { + if (isWorkerExtraction(ext)) { + const devMeta = + isDevMode && devFile + ? formatDevMeta({ + file: devFile, + lo: ext.loc[0], + hi: ext.loc[1], + displayName: ext.displayName, + }) + : undefined; + return { + decl: buildWorkerQrlDeclaration( + `q_${ext.symbolName}`, + ext.symbolName, + ext.canonicalFilename, + options.explicitExtensions, + qrlOutputExt, + devMeta + ), + qrlHelper: devMeta ? '_qrlWithChunkDEV' : '_qrlWithChunk', + }; + } + const stripped = isStrippedExtraction(ext, options.stripCtxName, options.stripEventHandlers); + if (stripped) { + if (isDevMode && devFile) { + return { + decl: buildNoopQrlDevDeclaration(ext.symbolName, { + file: devFile, + lo: 0, + hi: 0, + displayName: ext.displayName, + }), + qrlHelper: '_noopQrlDEV', + }; + } + return { decl: buildNoopQrlDeclaration(ext.symbolName), qrlHelper: '_noopQrl' }; + } + if (isDevMode && devFile) { + const devExt = options.explicitExtensions ? (qrlOutputExt ?? '.js') : undefined; + return { + decl: buildQrlDevDeclaration( + ext.symbolName, + ext.canonicalFilename, + devFile, + ext.loc[0], + ext.loc[1], + ext.displayName, + devExt + ), + qrlHelper: 'qrlDEV', + }; + } + const outputExt = qrlOutputExt ?? sourceExtensions.get(ext.symbolName) ?? ext.extension; + return { + decl: buildQrlDeclaration( + ext.symbolName, + ext.canonicalFilename, + options.explicitExtensions, + ext.extension, + outputExt + ), + qrlHelper: 'qrl', + }; +} + +/** + * When a single-segment marker-call decl (`const X = component$(() => ...)`) is `move`d into a + * sibling segment's file, the raw source text would re-emit the marker call and its closure body — + * duplicating the body that was already extracted into its own segment file. Instead, synthesize + * the parent-rewrite-equivalent pair so the moved decl behaves like every other marker-call binding + * in the codebase: a qrl ref for the extracted body followed by a `componentQrl(q_)`-style + * wrap. + * + * Returns `null` when the decl's initializer isn't a marker call, or when no top-level extraction + * is found inside its source range (which would mean the marker body wasn't extracted — extraction + * failure, not our concern). + */ +function tryBuildMarkerDeclMove( + decl: ModuleLevelDecl, + extractions: readonly ConsolidatedSegment[], + relPath: string, + options: TransformModulesOptions, + qrlOutputExt: string | undefined, + sourceExtensions: Map, + isDevMode: boolean, + devFile: string | undefined, + originalImports: Map +): { + qrlDecl: string; + wrapDecl: string; + importDeps: Array<{ localName: string; importedName: string; source: string }>; +} | null { + const fileStem = relPath.split('/').pop() ?? relPath; + const exactDisplayName = `${fileStem}_${escapeSymbol(decl.name)}`; + const prefixDisplayName = `${exactDisplayName}_`; + let match: ConsolidatedSegment | null = null; + for (const ext of extractions) { + if (ext.parent !== null) { + continue; + } + if (ext.isInlinedQrl || ext.isSync) { + continue; + } + if (ext.displayName === exactDisplayName || ext.displayName.startsWith(prefixDisplayName)) { + match = ext; + break; + } + } + if (!match) { + return null; + } + const qrlCallee = getQrlCalleeName(match.ctxName); + if (!qrlCallee) { + return null; + } + + const { decl: qrlDecl, qrlHelper } = buildMovedQrlDecl( + match, + options, + isDevMode, + devFile, + qrlOutputExt, + sourceExtensions + ); + const pure = needsPureAnnotation(qrlCallee) ? '/*#__PURE__*/ ' : ''; + const wrapDecl = `const ${decl.name} = ${pure}${qrlCallee}(q_${match.symbolName});`; + const calleeSource = originalImports.get(match.ctxName)?.source ?? '@qwik.dev/core'; + const importDeps = [ + { localName: qrlHelper, importedName: qrlHelper, source: '@qwik.dev/core' }, + { localName: qrlCallee, importedName: qrlCallee, source: calleeSource }, + ]; + return { qrlDecl, wrapDecl, importDeps }; +} + +/** + * QRL support code for a moved non-marker declaration: the `const q_ = qrl(...)` + * declarations for every top-level extraction the moved text references, plus the import deps those + * declarations and the text's marker-Qrl wrappers (`useTaskQrl`, …) need. The parent keeps only + * bare `qrl(...)` registration statements for these symbols (see `movedMarkerSymbols` in + * rewrite/output-assembly.ts), so the consuming segment becomes the binding owner. `declared` + * dedupes across multiple moved declarations landing in the same segment. + */ +function buildMovedQrlSupport( + movedText: string, + ctx: SegmentGenerationContext, + declared: Set +): { + qrlDecls: string[]; + importDeps: Array<{ localName: string; importedName: string; source: string }>; +} { + const qrlDecls: string[] = []; + const importDeps: Array<{ localName: string; importedName: string; source: string }> = []; + + const matched: ConsolidatedSegment[] = []; + for (const e of ctx.extractions) { + if (e.parent !== null || e.isSync) { + continue; + } + if (declared.has(e.symbolName)) { + continue; + } + if (!movedText.includes(`q_${e.symbolName}`)) { + continue; + } + matched.push(e); + } + if (matched.length === 0) { + return { qrlDecls, importDeps }; + } + + const isDevMode = ctx.emitMode === 'dev' || ctx.emitMode === 'hmr'; + const qrlHelpers = new Set(); + + matched.sort((a, b) => a.symbolName.localeCompare(b.symbolName)); + for (const e of matched) { + declared.add(e.symbolName); + const { decl, qrlHelper } = buildMovedQrlDecl( + e, + ctx.options, + isDevMode, + ctx.devFile, + ctx.qrlOutputExt, + ctx.sourceExtensions + ); + qrlDecls.push(decl); + qrlHelpers.add(qrlHelper); + const callee = getQrlCalleeName(e.ctxName); + if ( + callee && + movedText.includes(`${callee}(`) && + !importDeps.some((d) => d.localName === callee) + ) { + // The module's own import wins when it already binds the callee (e.g. a + // pre-optimized lib importing `createAsyncQrl` from `@qwik.dev/core/internal`); + // otherwise the extraction remembers which package the marker came from, so a + // moved decl using e.g. `reactify$` keeps @qwik.dev/react instead of core. + const ownImport = ctx.originalImports.get(callee); + importDeps.push( + ownImport ?? { + localName: callee, + importedName: callee, + source: getQrlImportSource(callee, e.importSource), + } + ); + } + } + const helperDeps = [...qrlHelpers].map((h) => ({ + localName: h, + importedName: h, + source: '@qwik.dev/core', + })); + return { qrlDecls, importDeps: [...helperDeps, ...importDeps] }; +} + +type MovedImportDep = { localName: string; importedName: string; source: string }; + +function resolveMovedDeclImportDeps( + decl: ModuleLevelDecl, + varName: string, + ctx: SegmentGenerationContext, + prep: SegmentGenerationPrep, + reexportedNames: Set, + movedIntoThisSegment: Set +): MovedImportDep[] { + const { program, originalImports, parentModulePath } = ctx; + const { sameFileSymbols, defaultExportedNames, renamedExports } = prep; + + const importDeps: MovedImportDep[] = []; + for (const idName of collectDeclIdentifiers(program, decl)) { + const imp = originalImports.get(idName); + if (imp) { + importDeps.push({ + localName: imp.localName, + importedName: imp.importedName, + source: imp.source, + }); + continue; + } + if (!sameFileSymbols.has(idName) || idName === varName) { + continue; + } + if (movedIntoThisSegment.has(idName)) { + continue; + } + const importedName = resolveSameFileImportName( + idName, + reexportedNames.has(idName), + defaultExportedNames, + renamedExports + ); + importDeps.push({ localName: idName, importedName, source: parentModulePath }); + } + return importDeps; +} + +/** + * Prefers the parent's post-JSX-rewrite snapshot over the raw decl text so the moved helper keeps + * the Qwik JSX form — the raw text would fall through to oxc-transform's React `_jsx` default. + */ +function emitMovedDeclaration( + decl: ModuleLevelDecl, + varName: string, + importDeps: MovedImportDep[], + ctx: SegmentGenerationContext, + captureInfo: SegmentCaptureInfo, + movedQrlSymbols: Set +): void { + const isDevMode = ctx.emitMode === 'dev' || ctx.emitMode === 'hmr'; + const markerXform = tryBuildMarkerDeclMove( + decl, + ctx.extractions, + ctx.relPath, + ctx.options, + ctx.qrlOutputExt, + ctx.sourceExtensions, + isDevMode, + ctx.devFile, + ctx.originalImports + ); + if (markerXform) { + captureInfo.movedDeclarations.push({ + text: markerXform.qrlDecl, + importDeps: [...importDeps, ...markerXform.importDeps], + }); + captureInfo.movedDeclarations.push({ text: markerXform.wrapDecl, importDeps: [] }); + return; + } + + const movedText = ctx.movedDeclSnapshots.get(varName) ?? decl.declText; + if (movedText.includes('_qrlSync(') && !importDeps.some((dep) => dep.localName === '_qrlSync')) { + importDeps.push({ + localName: '_qrlSync', + importedName: '_qrlSync', + source: '@qwik.dev/core', + }); + } + const support = buildMovedQrlSupport(movedText, ctx, movedQrlSymbols); + for (const qrlDecl of support.qrlDecls) { + captureInfo.movedDeclarations.push({ text: qrlDecl, importDeps: support.importDeps }); + } + captureInfo.movedDeclarations.push({ + text: movedText, + importDeps: support.qrlDecls.length > 0 ? [...importDeps, ...support.importDeps] : importDeps, + }); +} + +function filterMigratedCaptures( + ext: ConsolidatedSegment, + captureInfo: SegmentCaptureInfo, + migrationDecisions: readonly MigrationDecision[] +): void { + const migratedVarNames = new Set(); + for (const decision of migrationDecisions) { + if (decision.action === 'reexport' || decision.action === 'move') { + migratedVarNames.add(decision.varName); + } + } + ext.captureNames = ext.captureNames.filter((name) => !migratedVarNames.has(name)); + ext.captures = ext.captureNames.length > 0; + + if (ext.captures && ext.paramNames.length > 0) { + const paramSet = new Set(ext.paramNames); + if (ext.captureNames.every((name) => paramSet.has(name))) { + ext.captures = false; + } + } + captureInfo.captureNames = ext.captureNames; +} + +/** + * Per-segment matching is keyed by `migrationKey` (`preRenameSymbolName.get(ext.symbolName) ?? + * ext.symbolName`), so a `move` decision targeting a nested segment lands its declaration in that + * nested segment's file rather than its parent. Precondition: only invoked when `!ext.isInlinedQrl` + * — `inlinedQrl` extractions skip migration wiring because their capture list is pre-baked by the + * upstream tool. + */ +export function wireMigration( + ext: ConsolidatedSegment, + captureInfo: SegmentCaptureInfo, + ctx: SegmentGenerationContext, + prep: SegmentGenerationPrep +): void { + const { + migrationDecisions, + moduleLevelDeclsByName, + segmentUsage, + parentModulePath, + preRenameSymbolName, + } = ctx; + + const migrationKey = preRenameSymbolName.get(ext.symbolName) ?? ext.symbolName; + + const segUsage = segmentUsage.get(migrationKey); + if (segUsage) { + for (const decision of migrationDecisions) { + if (decision.action !== 'reexport' || !segUsage.has(decision.varName)) { + continue; + } + const decl = moduleLevelDeclsByName.get(decision.varName); + if (decl?.isExported && (!ext.isInlinedQrl || decl.isDirectlyExported)) { + continue; + } + captureInfo.autoImports.push({ varName: decision.varName, parentModulePath }); + } + } + + const movedDeclRanges = new Set(); + const movedQrlSymbols = new Set(); + // Reexported decls are reachable from segments through their `_auto_` alias. + const reexportedNames = new Set(); + const movedIntoThisSegment = new Set(); + for (const d of migrationDecisions) { + if ( + d.action === 'reexport' && + (!moduleLevelDeclsByName.get(d.varName)?.isExported || + (ext.isInlinedQrl && !moduleLevelDeclsByName.get(d.varName)?.isDirectlyExported)) + ) { + reexportedNames.add(d.varName); + } + if (d.action === 'move' && d.targetSegment === migrationKey) { + movedIntoThisSegment.add(d.varName); + } + } + for (const decision of migrationDecisions) { + if (decision.action === 'move' && decision.targetSegment === migrationKey) { + const decl = moduleLevelDeclsByName.get(decision.varName); + if (decl) { + const rangeKey = `${decl.declStart}:${decl.declEnd}`; + if (movedDeclRanges.has(rangeKey)) { + continue; + } + movedDeclRanges.add(rangeKey); + const importDeps = resolveMovedDeclImportDeps( + decl, + decision.varName, + ctx, + prep, + reexportedNames, + movedIntoThisSegment + ); + emitMovedDeclaration(decl, decision.varName, importDeps, ctx, captureInfo, movedQrlSymbols); + } + } + } + + filterMigratedCaptures(ext, captureInfo, migrationDecisions); +} + +/** + * Passive-event detection for a JSX-attr child's prop-name transform: the naming pass encodes the + * passive variant in the displayName path (`_q_ep_`/`_q_wp_`/`_q_dp_`), so recover the normalized + * event name from the callee and mark it passive when any marker is present. + */ +export function passiveEventsFromDisplayName(child: { + readonly displayName?: string; + readonly symbolName: string; + readonly calleeName: string; +}): Set { + const passiveSet = new Set(); + const displayNamePath = child.displayName ?? child.symbolName; + let eventName: string = child.calleeName; + if (eventName.startsWith('document:')) { + eventName = eventName.slice(9); + } else if (eventName.startsWith('window:')) { + eventName = eventName.slice(7); + } + if (eventName.startsWith('on') && eventName.endsWith('$')) { + eventName = eventName.slice(2, -1).toLowerCase(); + } + if ( + displayNamePath.includes('_q_ep_') || + displayNamePath.includes('_q_wp_') || + displayNamePath.includes('_q_dp_') + ) { + passiveSet.add(eventName); + } + return passiveSet; +} + +export function buildNestedCallSites( + children: ConsolidatedSegment[], + childQrlVarNames: Map, + elementQpParamsMap: Map, + extractionLoopMap: Map, + parentRawPropsFieldMap?: ReadonlyMap +): NestedCallSiteInfo[] { + const consolidateParams = (params: string[]): string[] => + parentRawPropsFieldMap === undefined + ? params + : consolidateQpCaptureValues(params, parentRawPropsFieldMap); + const qp = (symbolName: string): string[] | undefined => { + const params = elementQpParamsMap.get(symbolName); + return params ? consolidateParams(params) : params; + }; + const nestedCallSites: NestedCallSiteInfo[] = []; + for (const child of children) { + const qrlVarName = childQrlVarNames.get(child.symbolName) ?? `q_${child.symbolName}`; + // jSXProp ctxKind covers Component-side `$`-suffix attrs (classified + // separately from eventHandler). Both flow as JSX-attr call sites; + // the inner branch's `isComponentEvent` arm keeps the callee raw + // (`onEvent$` stays `onEvent$`, no `q-e:event` transform). + const isJsxAttr = + (child.ctxKind === 'eventHandler' || child.ctxKind === 'jSXProp') && + child.calleeName.endsWith('$') && + child.calleeName !== '$' && + // Handlers extracted from a pre-transformed `_jsxDEV(...)` props bag + // are object properties, not `name={value}` attributes — their call + // site is the bare value. Route them through the plain call-site path + // (replace `[callStart, callEnd]` with the QRL ref) so Phase 5b's + // `_jsxDEV`→`_jsxSorted` rewrite renames the key and slices the ref. + !child.isJsxObjectProp; + if (isJsxAttr) { + const transformedName = eventHandlerPropName( + child.calleeName, + child.isComponentEvent, + passiveEventsFromDisplayName(child) + ); + // A kebab-cased name whose local part starts with '-' (onDOMContentLoaded$ + // → q-d:-d-o-m-...) can't re-parse as a JSX attribute — keep the original + // attr name and let the JSX pass emit the final quoted key. + const propName = /:-/.test(transformedName) ? child.calleeName : transformedName; + + // Two-armed detection: an event-handler QRL needs `.w([captures])` + // wiring whenever it captures something the runtime can't deliver + // positionally. The original arm catches the "handler has loop-iter + // padded params (`_, _1, ...`)" case where some captures became + // positional. The second arm catches the "handler is inside a loop + // AND has cross-scope captures only" case (e.g. an `onClick$` deep + // inside three nested maps that captures an outer-loop-derived + // const) — that handler has no loop-iter padding because it doesn't + // reference the immediate-loop iter vars, but it still needs the + // `.w()` binding emitted in the outer scope where its captures are + // in scope, otherwise the parent body references the captured var + // nowhere and the side-effect simplifier strips the decl. + const childIsInLoop = (extractionLoopMap.get(child.symbolName)?.length ?? 0) > 0; + const hasLoopCrossCaptures = + child.captures && + child.captureNames.length > 0 && + (hasUnderscorePlaceholderParams(child.paramNames, child.movedCaptures) || childIsInLoop); + + const loopLocalParams = consolidateParams(eventHandlerQpParams(child.paramNames)); + + nestedCallSites.push({ + qrlVarName, + callStart: child.callStart, + callEnd: child.callEnd, + isJsxAttr: true, + attrStart: child.callStart, + attrEnd: child.callEnd, + transformedPropName: propName, + hoistedSymbolName: hasLoopCrossCaptures ? child.symbolName : undefined, + hoistedCaptureNames: hasLoopCrossCaptures ? child.captureNames : undefined, + // A JSX-attr child that captures but isn't on the loop-cross hoist path + // still needs `.w(…)` capture wrapping at the parent's prop call site; + // the body-transforms consumer reads this only when `hoistedSymbolName` + // is unset. + captureNames: + !hasLoopCrossCaptures && child.captureNames.length > 0 ? child.captureNames : undefined, + loopLocalParamNames: loopLocalParams.length > 0 ? loopLocalParams : undefined, + elementQpParams: qp(child.symbolName), + liftedNonConst: child.liftedNonConst === true || undefined, + }); + } else { + // An event handler extracted from a pre-transformed `_jsxDEV(...)` props + // bag (isJsxObjectProp) flows through this plain call-site branch, but it + // still needs its lexical captures delivered to the runtime via the + // element's `q:p`/`q:ps` prop. Those captures are the handler's params + // after the `_, _1` (event, element) prefix — the same positional + // delivery the loop-iter path uses. The peer-tool JSX-call rewriter + // (`buildJsxSortedCall`) reads `elementQpParams` to inject the prop. + let qpParams: string[] | undefined = qp(child.symbolName); + if ( + qpParams === undefined && + (child.ctxKind === 'eventHandler' || child.ctxKind === 'jSXProp') + ) { + const params = eventHandlerQpParams(child.paramNames); + if (params.length > 0) { + qpParams = consolidateParams(params); + } + } + const explicitCaptureItems = + child.isInlinedQrl && child.explicitCaptures + ? parseArrayItems(child.explicitCaptures) + : undefined; + nestedCallSites.push({ + qrlVarName, + callStart: child.callStart, + callEnd: child.callEnd, + argEnd: child.argEnd, + isJsxAttr: false, + qrlCallee: child.isBare ? undefined : child.qrlCallee || undefined, + captureNames: child.captureNames.length > 0 ? child.captureNames : undefined, + explicitCaptureItems: + explicitCaptureItems && explicitCaptureItems.length > 0 + ? explicitCaptureItems + : undefined, + importSource: child.importSource || undefined, + elementQpParams: qpParams, + liftedNonConst: child.liftedNonConst === true || undefined, + }); + } + } + return nestedCallSites; +} + +/** + * Default strategy emits each segment as a standalone module with its own code, metadata, and + * import context (vs the inline strategy's metadata-only shape). Returns the module plus + * `keyCounterValue` — the JSX key counter advanced by `generateSegmentCode`, which the orchestrator + * threads into the next iteration. + */ +export function buildDefaultStrategySegment( + ext: ConsolidatedSegment, + ctx: SegmentGenerationContext, + prep: SegmentGenerationPrep, + stripped: boolean, + segmentKeyCounter: number +): { module: TransformModule; keyCounterValue: number | undefined } { + const { + options, + relPath, + emitMode, + devFile, + entryStrategy, + migrationDecisions, + moduleLevelDeclsByName, + parentModulePath, + qrlOutputExt, + sourceExtensions, + parentSourceExt, + shouldTranspileJsx, + shouldTranspileTs, + isJsx, + importedNames, + elementQpParamsMap, + constLiteralsMap, + } = ctx; + const { + extBySymbol, + sortedExtractions, + sameFileSymbols, + defaultExportedNames, + renamedExports, + segmentImportList, + enumValueMap, + } = prep; + + const children = sortedExtractions.filter((c) => c.parent === ext.symbolName && !c.isSync); + const sentinelIndexes = buildDescendantSentinelIndexes( + ext.symbolName, + sortedExtractions, + options + ); + const isDevMode = emitMode === 'dev' || emitMode === 'hmr'; + const { nestedQrlDecls, childQrlVarNames } = buildNestedQrlDeclarations( + children, + options, + isDevMode, + devFile, + qrlOutputExt, + sentinelIndexes + ); + + const captureInfo: SegmentCaptureInfo = { + captureNames: ext.captureNames, + autoImports: [], + movedDeclarations: [], + }; + + // Default strategy writes the consolidated prop-field captures onto + // `captureInfo` (the inline path writes `ext`); both update + // `ext.captureNames`/`ext.captures`. + const rawProps = tryConsolidateRawProps(ext, prep); + if (rawProps !== null) { + captureInfo.captureNames = rawProps.newCaptureNames; + captureInfo.propsFieldCaptures = rawProps.propsFieldCaptures; + if (rawProps.propsFieldDefaults !== undefined) { + captureInfo.propsFieldDefaults = rawProps.propsFieldDefaults; + } + ext.captureNames = rawProps.newCaptureNames; + ext.captures = rawProps.newCaptureNames.length > 0; + } else if (ext.propsFieldCaptures && ext.propsFieldCaptures.size > 0) { + // Promoted-param handlers: the rewrite pre-pass consolidated the params + // to `_rawProps`; the body's field references must follow. + captureInfo.propsFieldCaptures = ext.propsFieldCaptures; + if (ext.propsFieldDefaults) { + captureInfo.propsFieldDefaults = ext.propsFieldDefaults; + } + } + + const preComputedConsts = constLiteralsMap.get(ext.symbolName); + if (preComputedConsts) { + captureInfo.constLiterals = preComputedConsts; + captureInfo.captureNames = ext.captureNames; + } + + wireMigration(ext, captureInfo, ctx, prep); + + const parentRawPropsFieldMap = bodyConsolidatesToRawProps(ext.bodyText) + ? extractDestructuredFieldInfo(ext.bodyText).fieldMap + : undefined; + const nestedCallSites = buildNestedCallSites( + children, + childQrlVarNames, + elementQpParamsMap, + ctx.extractionLoopMap, + parentRawPropsFieldMap + ); + + const effectiveCaptureInfo = resolveCaptureInfo(captureInfo, ext.isInlinedQrl); + let ownerBody = ext.bodyText as string; + for (const child of [...children].sort((a, b) => b.argStart - a.argStart)) { + const start = child.argStart - ext.argStart; + const end = child.argEnd - ext.argStart; + ownerBody = ownerBody.slice(0, start) + ownerBody.slice(end); + } + const ownerIdentifiers = collectBodyIdentifiers(ownerBody); + const preservedImportNames = new Set( + ext.segmentImports + .filter( + (moduleImport) => + /\.css(?:$|\?)/.test(moduleImport.source) && ownerIdentifiers.has(moduleImport.localName) + ) + .map((moduleImport) => moduleImport.localName) + ); + + // Build import context + const importContext: SegmentImportData = { + moduleImports: segmentImportList, + sameFileSymbols, + defaultExportedNames: defaultExportedNames.size > 0 ? defaultExportedNames : undefined, + renamedExports: renamedExports.size > 0 ? renamedExports : undefined, + parentModulePath, + migrationDecisions: migrationDecisions.map((d) => { + const decl = moduleLevelDeclsByName.get(d.varName); + return { + varName: d.varName, + action: d.action, + isExported: decl?.isExported ?? false, + isDirectlyExported: decl?.isDirectlyExported ?? false, + }; + }), + useAutoExportForExported: ext.isInlinedQrl, + }; + + // When source carries a foreign `@jsxImportSource` pragma, skip Qwik's + // JSX-syntax rewrite entirely. The JSX in the body stays as-is and + // oxc-transform's default JSX transform (run by `postProcessSegmentCode`) + // honors the pragma we prepend below. + const segmentResult = stripped + ? { code: generateStrippedSegmentCode(ext.symbolName) } + : generateSegmentCode( + ext, + nestedQrlDecls.length > 0 ? nestedQrlDecls : undefined, + effectiveCaptureInfo, + (() => { + const srcExt = sourceExtensions.get(ext.symbolName) ?? ext.extension; + return ( + !ctx.hasForeignJsxRuntime && + shouldTranspileJsx && + (srcExt === '.tsx' || srcExt === '.jsx' || isJsx) + ); + })() + ? { + enableJsx: true, + importedNames, + paramNames: ext.paramNames.length > 0 ? new Set(ext.paramNames) : undefined, + relPath, + // JSX dev-info `fileName:` only switches to the user-supplied + // dev path when explicitly set on the input. The composed + // `devFile` (srcDir+relPath fallback) keeps `relPath` + // semantics here. + devOptions: isDevMode ? { relPath: ctx.userDevPath ?? relPath } : undefined, + // Source-relative dev-info positions. `transformSegmentJsx` + // wraps the body as `(${bodyText})` before parsing; the + // wrapper length is 1 (single `(`), and `ext.argStart` is the + // body's byte offset in the original source. Only populated + // when dev-info is requested. + source: isDevMode ? ctx.repairedCode : undefined, + bodyOriginOffset: isDevMode ? ext.argStart : undefined, + keyCounterStart: segmentKeyCounter, + } + : undefined, + nestedCallSites.length > 0 ? nestedCallSites : undefined, + importContext, + enumValueMap.size > 0 ? enumValueMap : undefined, + ctx.inlinedIdentifiersByParent.get(ext.symbolName) + ); + let segmentCode = segmentResult.code; + + // Prepend the foreign `@jsxImportSource` pragma so oxc-transform's + // TS-strip + JSX-transform pass inside `postProcessSegmentCode` honors + // it and emits `import { jsx as _jsx } from "/jsx-runtime"`. The + // segment file is a brand-new generated module and wouldn't otherwise + // inherit the pragma from the user source. + if (!stripped && ctx.hasForeignJsxRuntime && ctx.foreignJsxPragmaText) { + segmentCode = ctx.foreignJsxPragmaText + '\n' + segmentCode; + } + + if (!stripped) { + segmentCode = postProcessSegmentCode(segmentCode, { + symbolName: ext.symbolName, + canonicalFilename: ext.canonicalFilename, + extension: ext.extension, + ctxName: ext.ctxName, + isBare: ext.isBare, + sourceExtensions, + parentModulePath, + parentSourceExt, + origin: { filename: relPath, text: ctx.repairedCode }, + shouldTranspileTs, + shouldTranspileJsx, + isServer: options.isServer, + emitMode, + devFile, + prioritizeGeneratedCaptures: + effectiveCaptureInfo !== undefined && + effectiveCaptureInfo.captureNames.length > 0 && + !effectiveCaptureInfo.skipCaptureInjection, + preserveImportNames: preservedImportNames, + }); + segmentCode = hoistInlinedQrlBodies(segmentCode); + } + + const entryField = resolveEntryField( + entryStrategy.type, + ext, + hasManualEntryMap(entryStrategy) ? entryStrategy.manual : undefined + ); + + const outputExtension = resolveSegmentFileExtension( + ext.symbolName, + ext.extension, + qrlOutputExt, + sourceExtensions + ); + const segmentAnalysis = buildSegmentMetadata( + ext, + entryField, + outputExtension, + segmentResult.paramNames + ); + + return { + module: { + kind: 'segment', + path: mkRelativePath( + join(getDirectory(ctx.inputPath), ext.canonicalFilename + outputExtension) + ), + isEntry: true, + code: segmentCode, + map: null, + segment: segmentAnalysis, + }, + keyCounterValue: segmentResult.keyCounterValue, + }; +} + +/** + * Forks per extraction on entry strategy: inline/hoist → {@link buildInlineStrategySegment} + * (metadata-only emit), default → {@link buildDefaultStrategySegment} (full code + metadata). The + * JSX key counter is threaded across segments so per-module JSX keys stay unique. + */ +export function generateAllSegmentModules(ctx: SegmentGenerationContext): TransformModule[] { + const prep = computeSegmentGenerationPrep(ctx); + const allModules: TransformModule[] = []; + + // Pre-compute the per-segment JSX-key starting counter in SOURCE order, + // independent of the depth-first processing order used by the main loop + // below. JSX keys are consumed per source position; consuming in depth-first + // segment order produces stable but wrong keys (e.g. a Foo body at line 13 + // would get `u6_1` because Root_1's `
` at line 27 gets `u6_0` when + // processed first as a depth-1 child). + const segmentStartKey = computeSegmentStartKeys( + prep.sortedExtractions, + ctx.parentJsxKeyCounterValue, + ctx.closureNodes, + ctx.jsxRegionKeyBases + ); + + for (const ext of prep.sortedExtractions) { + if (ext.isSync) { + continue; + } + + const stripped = + !matchesRegCtxName(ext, ctx.options.regCtxName) && + isStrippedExtraction(ext, ctx.options.stripCtxName, ctx.options.stripEventHandlers); + // Stripped-segment fallback zeros loc before SegmentAnalysis emission. + // Internal-builder cast — see extract.ts `Mutable`. + if (stripped) { + (ext as Mutable).loc = [mkByteOffset(0), mkByteOffset(0)]; + } + + // Clear capture metadata for event-handler segments stripped via + // `stripEventHandlers`: the body is gone and the runtime never consumes the + // captures, so emit `captures: false, captureNames: []`. + // `stripCtxName`-stripped segments preserve their captures (different + // policy: those carry runtime-meaningful info even with `null` body). + if (stripped && ctx.options.stripEventHandlers && ext.ctxKind === 'eventHandler') { + const mut = ext as Mutable; + mut.captures = false; + mut.captureNames = []; + } + + if (ctx.isInlineStrategy) { + const inlineModule = buildInlineStrategySegment(ext, ctx, prep, stripped); + // null result means non-stripped inline — body inlined into parent, + // no segment file emitted. + if (inlineModule !== null) { + allModules.push(inlineModule); + } + continue; + } + + const startKey = segmentStartKey.get(ext.symbolName) ?? ctx.parentJsxKeyCounterValue; + const result = buildDefaultStrategySegment(ext, ctx, prep, stripped, startKey); + allModules.push(result.module); + } + + return allModules; +} + +/** + * The JSX-key ordering rule: top-level extractions are keyed in SOURCE order (by body byte offset); + * within each subtree the traversal is depth-first leaves-first (children consume keys before their + * parent). `sortedExtractions` is global depth-first descending — fine for codegen ordering but it + * mixes subtrees across top-level extractions, giving wrong keys when two top-level subtrees both + * contain JSX. + * + * Each segment's exclusive JSX consumption is its own body's JSX-element count minus the sum of its + * direct children's totals — a parent's bodyText textually contains its children's, so naive totals + * would double-count. + */ +function computeSegmentStartKeys( + sortedExtractions: readonly ConsolidatedSegment[], + parentJsxKeyCounterValue: number, + closureNodes: ReadonlyMap, + regionKeyBases?: ReadonlyMap +): Map { + // Total JSX-element count per extraction. The Phase-1 closure node IS the raw + // body's AST — walk it directly; fall back to a body-text parse only when no + // node was threaded (e.g. inlinedQrl spec paths). + const totalCount = new Map(); + for (const ext of sortedExtractions) { + const node = closureNodes.get(ext.symbolName); + if (node) { + totalCount.set(ext.symbolName, countJsxKeysInNode(node)); + } else { + totalCount.set(ext.symbolName, countJsxKeyConsumption(ext.bodyText)); + } + } + + const childrenBySymbol = new Map(); + for (const ext of sortedExtractions) { + if (ext.parent === null) { + continue; + } + const arr = childrenBySymbol.get(ext.parent); + if (arr) { + arr.push(ext); + } else { + childrenBySymbol.set(ext.parent, [ext]); + } + } + + const exclusiveCount = new Map(); + for (const ext of sortedExtractions) { + let own = totalCount.get(ext.symbolName) ?? 0; + const directChildren = childrenBySymbol.get(ext.symbolName) ?? []; + for (const child of directChildren) { + own -= totalCount.get(child.symbolName) ?? 0; + } + exclusiveCount.set(ext.symbolName, Math.max(0, own)); + } + + const result = new Map(); + let counter = parentJsxKeyCounterValue; + + function visit(ext: ConsolidatedSegment): void { + if (ext.isSync) { + return; + } + const children = childrenBySymbol.get(ext.symbolName) ?? []; + const childrenInSourceOrder = [...children].sort((a, b) => a.argStart - b.argStart); + for (const child of childrenInSourceOrder) { + visit(child); + } + result.set(ext.symbolName, counter); + counter += exclusiveCount.get(ext.symbolName) ?? 0; + } + + const topLevelInSourceOrder = sortedExtractions + .filter((ext) => ext.parent === null) + .slice() + .sort((a, b) => a.argStart - b.argStart); + for (const ext of topLevelInSourceOrder) { + // The parent's module-order walk reserved this extraction's key range; + // continue from that base so SSR and client numbering agree. + const reservedBase = regionKeyBases?.get(ext.argStart); + if (reservedBase !== undefined) { + counter = reservedBase; + } + visit(ext); + } + + return result; +} + +/** + * Counts how many JSX elements/fragments a body consumes from the JSX key counter, so + * {@link computeSegmentStartKeys} knows how far each segment advances it. The key-emission rule: a + * JSXElement that is a JSX-child of a JSXElement/JSXFragment AND has an HTML tag (lowercase first + * char) gets a `null` key and does NOT advance the counter; all other JSXElements and every + * JSXFragment advance it. Without this, a `<>
` body over-counts by one. + */ +function countJsxKeyConsumption(bodyText: string): number { + // Cheap regex prefilter — if the body contains nothing JSX-shaped, skip + // the parse. Both `` (fragment) start with `<`. + if (bodyText.indexOf('<') === -1) { + return 0; + } + try { + const parsed = parseWithRawTransfer('segment-jsx-count.tsx', `(${bodyText})`); + return countJsxKeysInNode(parsed.program); + } catch { + return 0; + } +} + +/** Counting walk shared by the node path and the body-text fallback. */ +export function countJsxKeysInNode(root: AstNode, jsxFunctions?: ReadonlySet): number { + let count = 0; + function isHtmlElementName(n: AstNode | null | undefined): boolean { + if (!n || n.type !== 'JSXElement') { + return false; + } + const name = n.openingElement?.name; + if (!name || name.type !== 'JSXIdentifier') { + return false; + } + return isHtmlElement(name.name); + } + function walk(n: AstNode | null | undefined, parentIsJsxParent: boolean): void { + if (!n) { + return; + } + if (n.type === 'JSXElement') { + // Skip: an explicit key attribute consumes no counter key, and an HTML + // element that's a JSX child of another JSXElement / JSXFragment + // receives a null key. + const hasExplicitKey = n.openingElement?.attributes?.some( + (a) => a.type === 'JSXAttribute' && getJsxAttributeName(a) === 'key' + ); + if (!hasExplicitKey && !(parentIsJsxParent && isHtmlElementName(n))) { + count++; + } + } else if (n.type === 'JSXFragment') { + count++; + } else if (jsxFunctions && isJsxCall(n, jsxFunctions) && n.type === 'CallExpression') { + const args = n.arguments ?? []; + const hasExplicitKey = args.length === 3 && args[2] && args[2].type !== 'SpreadElement'; + if (!hasExplicitKey) { + count++; + } + } + const childIsInJsxParent = n.type === 'JSXElement' || n.type === 'JSXFragment'; + forEachAstChild(n, (child) => walk(child, childIsInJsxParent)); + } + walk(root, false); + return count; +} diff --git a/packages/ts-optimizer/src/optimizer/segment/strip-ctx.ts b/packages/ts-optimizer/src/optimizer/segment/strip-ctx.ts new file mode 100644 index 00000000000..7fbf0ba4c4e --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/segment/strip-ctx.ts @@ -0,0 +1,3 @@ +export function generateStrippedSegmentCode(symbolName: string): string { + return `export const ${symbolName} = null;`; +} diff --git a/packages/ts-optimizer/src/optimizer/transform/dead-code.ts b/packages/ts-optimizer/src/optimizer/transform/dead-code.ts new file mode 100644 index 00000000000..290d409f885 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/transform/dead-code.ts @@ -0,0 +1,205 @@ +/** + * Constant-branch folding on the AST: `if (true/false)` statements, `cond ? a : b`, + * `!true`/`!false`, and `true && x` / `false || x` / `false && x` shapes collapse via range edits + * against the original text — one parse, one MagicString write, no textual pattern matching. Dead + * branches are marked and never walked, so cascades (`else if` chains, nested folds) resolve in a + * single pass. + */ + +import MagicString from 'magic-string'; +import type { AstMaybeNode, AstNode, AstProgram } from '../../ast-types.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { forEachAstChild } from '../ast/guards.js'; + +/** + * Every fold below bottoms out at a boolean literal (directly, parenthesised, or under `!`), so a + * module without one has nothing to fold. Enumerating the _shapes_ instead would silently skip the + * pass whenever a new one appeared — that is how constant-test ternaries went unfolded. + */ +const booleanLiteral = /\b(?:true|false)\b/; + +export function hasSegmentDcePatterns(code: string): boolean { + return booleanLiteral.test(code); +} + +/** Resolve an expression to a compile-time boolean when possible. */ +function resolveBoolValue(node: AstMaybeNode): boolean | undefined { + if (node == null) { + return undefined; + } + switch (node.type) { + case 'Literal': + return typeof node.value === 'boolean' ? node.value : undefined; + case 'ParenthesizedExpression': + return resolveBoolValue(node.expression); + case 'UnaryExpression': { + if (node.operator !== '!') { + return undefined; + } + const inner = resolveBoolValue(node.argument); + return inner === undefined ? undefined : !inner; + } + default: + return undefined; + } +} + +/** Top-level lexical declarations make a block unsafe to unwrap into statement position. */ +function blockHasLexicalDecls(block: AstNode): boolean { + if (block.type !== 'BlockStatement') { + return false; + } + for (const stmt of block.body ?? []) { + if ( + stmt.type === 'VariableDeclaration' || + stmt.type === 'FunctionDeclaration' || + stmt.type === 'ClassDeclaration' + ) { + return true; + } + } + return false; +} + +export function applySegmentDCE( + code: string, + filename = 'dce.tsx', + preParsedProgram?: AstProgram +): string { + let program; + if (preParsedProgram) { + program = preParsedProgram; + } else { + try { + program = parseWithRawTransfer(filename, code).program; + } catch { + return code; + } + } + + const s = new MagicString(code); + let changed = false; + + /** + * Where a fold's edits land, given the if's position: `elseFrom` covers the parent's `else` + * keyword when this if is an else-if arm; `inStatementList` allows unwrapping a kept block (only + * safe directly inside a Program/BlockStatement body). + */ + interface FoldContext { + elseFrom?: number; + inStatementList: boolean; + } + const EXPR_CTX: FoldContext = { inStatementList: false }; + + function foldIf(node: AstNode & { type: 'IfStatement' }, ctx: FoldContext, value: boolean): void { + changed = true; + const kept = value ? node.consequent : (node.alternate ?? null); + if (kept === null) { + // Dropping an else-if arm must consume the parent's `else` keyword too. + s.remove(ctx.elseFrom ?? node.start, node.end); + return; + } + s.remove(node.start, kept.start); + if (kept.end < node.end) { + s.remove(kept.end, node.end); + } + if ( + ctx.inStatementList && + ctx.elseFrom === undefined && + kept.type === 'BlockStatement' && + !blockHasLexicalDecls(kept) + ) { + // Unwrap `{ ... }` into statement position (rust parity). + s.remove(kept.start, kept.start + 1); + s.remove(kept.end - 1, kept.end); + } + walk(kept, { inStatementList: ctx.inStatementList, elseFrom: ctx.elseFrom }); + } + + function walk(node: AstMaybeNode, ctx: FoldContext): void { + if (node == null) { + return; + } + + if (node.type === 'IfStatement') { + const value = resolveBoolValue(node.test); + if (value !== undefined) { + foldIf(node, ctx, value); + return; + } + walk(node.test, EXPR_CTX); + walk(node.consequent, { inStatementList: false }); + // An else-if arm folds together with our `else` keyword. + walk(node.alternate, { inStatementList: false, elseFrom: node.consequent.end }); + return; + } + + if (node.type === 'ConditionalExpression') { + const value = resolveBoolValue(node.test); + if (value !== undefined) { + changed = true; + const kept = value ? node.consequent : node.alternate; + const keptText = code.slice(kept.start, kept.end); + // An object or function branch would reparse as a block or declaration + // once the ternary around it is gone. + const needsParens = /^[{(]?\s*$|^[{]|^function\b|^class\b/.test(keptText); + s.overwrite(node.start, node.end, needsParens ? `(${keptText})` : keptText); + walk(kept, EXPR_CTX); + return; + } + walk(node.test, EXPR_CTX); + walk(node.consequent, EXPR_CTX); + walk(node.alternate, EXPR_CTX); + return; + } + + if (node.type === 'LogicalExpression') { + const left = resolveBoolValue(node.left); + if (node.operator === '&&' && left === true) { + changed = true; + s.remove(node.start, node.right.start); + walk(node.right, EXPR_CTX); + return; + } + if (node.operator === '&&' && left === false) { + changed = true; + s.overwrite(node.start, node.end, 'false'); + return; + } + if (node.operator === '||' && left === false) { + changed = true; + s.remove(node.start, node.right.start); + walk(node.right, EXPR_CTX); + return; + } + walk(node.left, EXPR_CTX); + walk(node.right, EXPR_CTX); + return; + } + + if (node.type === 'UnaryExpression' && node.operator === '!') { + const value = resolveBoolValue(node); + if (value !== undefined) { + changed = true; + s.overwrite(node.start, node.end, String(value)); + return; + } + } + + if (node.type === 'Program' || node.type === 'BlockStatement' || node.type === 'StaticBlock') { + for (const stmt of node.body ?? []) { + walk(stmt, { inStatementList: true }); + } + return; + } + + forEachAstChild(node, (child) => walk(child, EXPR_CTX)); + } + + walk(program as unknown as AstNode, { inStatementList: true }); + + if (!changed) { + return code; + } + return s.toString(); +} diff --git a/packages/ts-optimizer/src/optimizer/transform/hygiene-renames.ts b/packages/ts-optimizer/src/optimizer/transform/hygiene-renames.ts new file mode 100644 index 00000000000..ec74c2c8444 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/transform/hygiene-renames.ts @@ -0,0 +1,160 @@ +/** + * SWC-hygiene emulation for the emitted parent module: a function-local binding whose name is also + * used as a FREE identifier elsewhere at module level (e.g. a hoisted segment body referencing + * globals) gets a numeric suffix, exactly like SWC's hygiene pass renames colliding contexts. + */ + +import MagicString from 'magic-string'; +import { walk } from 'oxc-walker'; +import type { AstNode, AstProgram } from '../../ast-types.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { ScopeQueryTracker } from '../analysis/scope-query-tracker.js'; + +interface IdentifierRecord { + readonly start: number; + readonly end: number; + readonly name: string; + readonly scopeKey: string; + readonly declKey: string | null; + readonly isShorthand: boolean; +} + +export function applyModuleHygieneRenames(code: string, filename: string): string { + let program: AstProgram; + try { + program = parseWithRawTransfer(filename, code).program; + } catch { + return code; + } + + const tracker = new ScopeQueryTracker({ preserveExitedScopes: true }); + walk(program, { scopeTracker: tracker }); + tracker.freeze(); + + // Record every identifier with its resolved declaration (keyed by the + // declaration node's position so distinct bindings of one name separate). + const records: IdentifierRecord[] = []; + const allNames = new Set(); + // `const fieldN = _captures[i]` unpacks mirror an outer binding SWC renames + // in lockstep — their names are reusable, not collisions. + const captureUnpackNames = new Set(); + const captureUnpackDeclKeys = new Set(); + walk(program, { + scopeTracker: tracker, + enter(node, parent) { + const n = node as AstNode; + if (n.type !== 'Identifier') { + return; + } + const p = parent as AstNode | null; + if (p) { + if (p.type === 'MemberExpression' && (p as never as { property: AstNode }).property === n) { + const computed = (p as never as { computed?: boolean }).computed; + if (!computed) { + return; + } + } + if ( + p.type === 'Property' && + (p as never as { key: AstNode }).key === n && + !(p as never as { computed?: boolean }).computed && + !(p as never as { shorthand?: boolean }).shorthand + ) { + return; + } + if (p.type === 'ImportSpecifier' && (p as never as { imported: AstNode }).imported === n) { + return; + } + // Re-export specifiers (`export { r as x } from '…'`) bind nothing + // in module scope — their names are not collisions. + if (p.type === 'ExportSpecifier') { + return; + } + } + const name = (n as never as { name: string }).name; + allNames.add(name); + const isCaptureUnpack = + p?.type === 'VariableDeclarator' && + (p as never as { id: AstNode }).id === n && + (p as never as { init?: AstNode }).init?.type === 'MemberExpression' && + ( + (p as never as { init: { object?: { type: string; name?: string } } }).init.object ?? + ({} as { type?: string; name?: string }) + ).name === '_captures'; + if (isCaptureUnpack) { + captureUnpackNames.add(name); + const unpackDecl = tracker.getDeclaration(name) as { start?: number; end?: number } | null; + if (unpackDecl && unpackDecl.start !== undefined) { + captureUnpackDeclKeys.add(`${name}@${unpackDecl.start}:${unpackDecl.end}`); + } + } + const decl = tracker.getDeclaration(name) as { start?: number; end?: number } | null; + const isShorthand = + p?.type === 'Property' && + (p as never as { shorthand?: boolean }).shorthand === true && + (p as never as { value: AstNode }).value === n; + records.push({ + start: n.start, + end: n.end, + name, + scopeKey: tracker.getCurrentScope(), + declKey: decl && decl.start !== undefined ? `${name}@${decl.start}:${decl.end}` : null, + isShorthand, + }); + }, + }); + + // Free names: referenced somewhere with no resolvable declaration. + const freeNames = new Set(); + for (const rec of records) { + if (rec.declKey === null) { + freeNames.add(rec.name); + } + } + if (freeNames.size === 0) { + return code; + } + + // Declared bindings sharing a free name get renamed — but only non-root + // bindings (a top-level decl of that name would BE the free ref's target). + const renameByDecl = new Map(); + for (const rec of records) { + if (rec.declKey === null) { + continue; + } + if (!freeNames.has(rec.name)) { + continue; + } + if (renameByDecl.has(rec.declKey)) { + continue; + } + let counter = 1; + let candidate = `${rec.name}${counter}`; + while (allNames.has(candidate) && !captureUnpackNames.has(candidate)) { + counter++; + candidate = `${rec.name}${counter}`; + } + allNames.add(candidate); + // An unpack cluster's new name stays shareable with its outer twin. + if (captureUnpackDeclKeys.has(rec.declKey)) { + captureUnpackNames.add(candidate); + } + renameByDecl.set(rec.declKey, candidate); + } + if (renameByDecl.size === 0) { + return code; + } + + const s = new MagicString(code); + for (const rec of records) { + if (rec.declKey === null) { + continue; + } + const renamed = renameByDecl.get(rec.declKey); + if (renamed === undefined) { + continue; + } + s.overwrite(rec.start, rec.end, rec.isShorthand ? `${rec.name}: ${renamed}` : renamed); + } + return s.toString(); +} diff --git a/packages/ts-optimizer/src/optimizer/transform/index.ts b/packages/ts-optimizer/src/optimizer/transform/index.ts new file mode 100644 index 00000000000..fd885398198 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/transform/index.ts @@ -0,0 +1,1532 @@ +/** + * Public entry point for the Qwik optimizer. `transformModule` accepts `TransformModulesOptions` + * and returns `TransformOutput`, sequencing extraction, capture analysis, variable migration, + * parent rewriting, and segment codegen behind one public API. + */ + +import type { + AstEcmaScriptModule, + AstFunction, + AstNode, + AstProgram, + TSEnumDeclaration, +} from '../../ast-types.js'; +import MagicString from 'magic-string'; +import { stripTypeScript } from '../edit/strip-types.js'; +import { blankNonCode, skipStringLiteralForward } from '../edit/text-scanning.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { isStrippedExtraction } from '../rewrite/predicates.js'; +import { flattenDestructureUseCalls } from '../prepare/flatten-destructures.js'; +import { normalizeInlineComponentProps } from '../prepare/inline-component-props.js'; +import { detectForeignJsxRuntime } from '../jsx/jsx-import-source.js'; +import type { ConsolidatedSegment, ExtractionResult, Mutable } from '../extraction/extract.js'; +import { + rewriteParentModule, + resolveConstLiteralsInClosure, + resolveWholeBodyIdentifier, + type InlineStrategyOptions, + type JsxRewriteOptions, + type ParentRewriteResult, +} from '../rewrite/index.js'; +import { + collectImports, + sourceMayContainMarkers, + type ImportInfo, +} from '../extraction/marker-detection.js'; +import { buildDevFilePath } from '../segment/dev-mode.js'; +import { isSimpleIdentifierName } from '../ast/identifier-name.js'; +import { + type SymbolName, + mkBodyText, + mkByteOffset, + mkRelativePath, + mkSymbolName, + type RelativePath, +} from '../types/brands.js'; +import { + analyzeCaptures, + collectScopeIdentifiers, + excludeNestedExtractionCaptures, +} from '../analysis/capture-analysis.js'; +import { + gatherModuleFacts, + type ModuleGatherFacts, + type PassiveConflict, +} from '../analysis/module-gather-walk.js'; +import type { ScopeAwareCollectResult } from '../jsx/jsx.js'; +import { + analyzeMigration, + collectModuleLevelDecls, + filterInlineStrategyMigrations, + MIG_REASON, + type MigrationDecision, + type ModuleLevelDecl, +} from '../analysis/variable-migration.js'; +import type { + Diagnostic, + EmitMode, + EntryStrategy, + TransformModuleInput, + TransformModulesOptions, + TransformOutput, + TransformModule, +} from '../types/types.js'; +import { + classifyDeclarationType, + classifyDeclarationTypeInClosure, + parseDisableDirectives, + filterSuppressedDiagnostics, +} from '../diagnostics/diagnostics.js'; +import { + computeOutputExtension, + computeParentModulePath, + computeRelPath, + getExtension, + stripExtension, +} from '../../paths.js'; +import { + buildParentExtractionMap, + buildPassthroughModule, + runDcePipeline, +} from './module-cleanup.js'; +import { deriveIsDev } from '../rewrite/const-replacement.js'; +import { applyModuleHygieneRenames } from './hygiene-renames.js'; +import { + detectC02Diagnostics, + detectC03Diagnostics, + detectC05Diagnostics, + emitPassiveConflictDiagnostics, +} from '../diagnostics/diagnostic-detection.js'; +import { + leadingSquareBracket, + trailingSquareBracket, + paddingParam, +} from '../segment/post-process.js'; +import { + promoteEventHandlerCaptures, + unifyParameterSlots, + buildElementCaptureMap, + type EventCaptureContext, +} from '../jsx/event-capture-promotion.js'; +import type { LoopContext } from '../jsx/loop-hoisting.js'; +import { + generateAllSegmentModules, + type SegmentGenerationContext, +} from '../segment/segment-generation.js'; + +/** + * Collect import sources from generated text whose parse isn't cached. Patterns end at the opening + * quote and match on the blanked copy (string/comment contents spaced out, positions preserved), so + * import-shaped text inside a string or comment can't register; the source itself reads from the + * original through the string scanner. + */ +function addImportSourcesFromText( + code: string, + patterns: readonly RegExp[], + sources: Set +): void { + const blanked = blankNonCode(code); + for (const pattern of patterns) { + for (const m of blanked.matchAll(pattern)) { + const quoteIdx = m.index + m[0].length - 1; + const close = skipStringLiteralForward(code, quoteIdx); + if (close < code.length) { + sources.add(code.slice(quoteIdx + 1, close)); + } + } + } +} + +const dynamicImportOpen = /\bimport\s*\(\s*["']/g; +const staticImportOpens = [/\bfrom\s*["']/g, /^\s*import\s*["']/gm] as const; + +/** + * Import sources present in the original module but absent from the final parent output. The vite + * plugin uses these to keep stripped-away imports in the module graph (SSR restore). + */ +function collectRemovedImportSources( + originalImports: ReadonlyMap, + originalCode: string, + cleanedCode: string +): string[] { + const originalSources = new Set(); + for (const [, info] of originalImports) { + if (info.source) { + originalSources.add(info.source); + } + } + addImportSourcesFromText(originalCode, [dynamicImportOpen], originalSources); + if (originalSources.size === 0) { + return []; + } + + const keptSources = new Set(); + addImportSourcesFromText(cleanedCode, [dynamicImportOpen, ...staticImportOpens], keptSources); + + return [...originalSources].filter((source) => !keptSources.has(source)); +} + +/** + * Passthrough modules skip the rewrite pipeline, but isServer/isBrowser branches must still fold so + * client bundles never reference server-only imports. + */ +function applyPassthroughConstFolding( + module: TransformModule, + mod: ModuleContext +): TransformModule { + const { options, relPath, ext } = mod; + let code = module.code; + if (code === '') { + return module; + } + + // The Rust optimizer transpiles TS even in marker-free modules; downstream + // consumers (e.g. the vite plugin's literal replacements) rely on that. + if (options.transpileTs && (ext === '.ts' || ext === '.tsx' || ext === '.mts')) { + code = stripTypeScript( + relPath, + code, + { + typescript: { onlyRemoveTypeImports: false }, + ...(ext === '.tsx' ? {} : { jsx: 'preserve' as const }), + }, + `source file "${relPath}"`, + { filename: relPath, text: code } + ); + } + + code = runDcePipeline(code, relPath, { + isServer: options.isServer, + isDev: deriveIsDev(options.mode), + isLibMode: options.mode === 'lib', + onlyIfFoldChanges: true, + }); + + return code === module.code ? module : { ...module, code }; +} + +/** + * Output-level source-kind flags accumulated across input files. The accumulation is + * order-sensitive: a file sees `isJsx: true` if any earlier input (or itself) was JSX. + */ +interface ModuleKindFlags { + readonly isTypeScript: boolean; + readonly isJsx: boolean; +} + +/** Per-input invariants shared by every phase helper. */ +interface ModuleContext { + readonly input: TransformModuleInput; + readonly options: TransformModulesOptions; + readonly relPath: RelativePath; + readonly ext: string; +} + +/** Phase 0 + 0.5 result: prepared source and its parse. */ +interface PreparedModuleInput { + readonly repairedCode: string; + readonly originalOffset: (offset: number) => number; + readonly program: AstProgram; + readonly parserModule: AstEcmaScriptModule | undefined; + readonly diagnostics: readonly Diagnostic[]; + readonly hasForeignJsxRuntime: boolean; + readonly foreignJsxPragmaText: string | null; +} + +/** + * Phase 1+2 walk result: the extraction set plus every gathered per-module fact, all from the + * single fused program traversal. + */ +interface ExtractedModule { + readonly kind: 'extracted'; + readonly extractions: ExtractionResult[]; + readonly closureNodes: Map; + readonly facts: ModuleGatherFacts; +} + +/** + * Phase 1 result. `passthrough` is the early exit for inputs with no marker calls and no JSX to + * transpile — the module is emitted verbatim. + */ +type SegmentExtraction = + | { readonly kind: 'passthrough'; readonly module: TransformModule } + | ExtractedModule; + +/** Emit configuration derived purely from options + input extension. */ +interface EmitConfig { + readonly emitMode: EmitMode; + readonly entryStrategy: EntryStrategy; + readonly isInlineStrategy: boolean; + readonly isLibMode: boolean; + readonly shouldTranspileJsx: boolean; + readonly shouldTranspileTs: boolean; + readonly qrlOutputExt: string; + readonly parentModulePath: string; + readonly parentOutputPath: RelativePath; +} + +/** + * Phase 2 result. Capture analysis also mutates `extractions` in place + * (captureNames/paramNames/captures and the `'captured'` phase flip). The usage maps and + * passive-conflict list arrive pre-built from the Phase-1 gather walk; `scopeAwareBindings` is + * present only when this module will run the Phase-4 JSX transform. + */ +interface CaptureAnalysis { + readonly originalImports: Map; + readonly importedNames: Set; + readonly enclosingExtMap: Map; + readonly extractionLoopMap: Map; + readonly elementQpParamsMap: Map; + readonly segmentUsage: Map>; + readonly rootUsage: Set; + readonly passiveConflicts: readonly PassiveConflict[]; + readonly scopeAwareBindings: ScopeAwareCollectResult | undefined; +} + +/** Phase 3 result: module-level decl inventory + migration decisions. */ +interface MigrationAnalysis { + readonly moduleLevelDecls: ModuleLevelDecl[]; + readonly moduleLevelDeclsByName: Map; + readonly segmentUsage: Map>; + readonly migrationDecisions: MigrationDecision[]; +} + +/** Phase 4 result: the rewritten parent module + rewrite byproducts. */ +interface ParentRewrite { + readonly parentModule: TransformModule; + readonly parentResult: ParentRewriteResult; + readonly devFile: string | undefined; +} + +/** Everything one input file contributes to the final TransformOutput. */ +interface ModuleTransformResult { + readonly modules: readonly TransformModule[]; + readonly diagnostics: readonly Diagnostic[]; + readonly flags: ModuleKindFlags; +} + +/** + * Transform Qwik source modules by extracting segments, rewriting the parent module, and generating + * segment module code. Public API consumed by the Qwik Vite plugin. + * + * Pipeline per input file (see `transformOneModule`): repair -> extract -> analyze captures -> + * migrate -> rewrite parent -> generate segments + */ +export function transformModule(options: TransformModulesOptions): TransformOutput { + const allModules: TransformModule[] = []; + const diagnostics: Diagnostic[] = []; + let flags: ModuleKindFlags = { isTypeScript: false, isJsx: false }; + + for (const input of options.input) { + const result = transformOneModule(input, options, flags); + allModules.push(...result.modules); + diagnostics.push(...result.diagnostics); + flags = result.flags; + } + + return { + modules: allModules, + diagnostics: applyDiagnosticSuppression(diagnostics, options.input), + isTypeScript: flags.isTypeScript, + isJsx: flags.isJsx, + }; +} + +/** + * Run the full per-file pipeline for one input module. Phase 6 (diagnostic suppression) is + * cross-file and runs once in `transformModule`, not here. + */ +function transformOneModule( + input: TransformModuleInput, + options: TransformModulesOptions, + priorFlags: ModuleKindFlags +): ModuleTransformResult { + const relPath = computeRelPath(input.path, options.srcDir); + const ext = getExtension(relPath); + const flags: ModuleKindFlags = { + isTypeScript: priorFlags.isTypeScript || ext === '.ts' || ext === '.tsx', + isJsx: priorFlags.isJsx || ext === '.tsx' || ext === '.jsx', + }; + const mod: ModuleContext = { input, options, relPath, ext }; + const diagnostics: Diagnostic[] = []; + + const prepared = prepareModuleInput(mod); + if (prepared.diagnostics.length > 0) { + return { + modules: [ + { + kind: 'parent', + path: relPath, + isEntry: false, + code: input.code, + map: null, + origPath: input.path, + }, + ], + diagnostics: prepared.diagnostics, + flags, + }; + } + const extracted = extractModuleSegments(mod, prepared); + if (extracted.kind === 'passthrough') { + return { + modules: [applyPassthroughConstFolding(extracted.module, mod)], + diagnostics, + flags, + }; + } + const { extractions, closureNodes } = extracted; + + const emit = resolveEmitConfig(mod); + const analysis = analyzeModuleCaptures( + mod, + prepared, + extracted, + emit.entryStrategy, + diagnostics, + emit.shouldTranspileJsx + ); + const migration = attributeSegmentUsage( + mod, + prepared, + extractions, + analysis, + emit.isInlineStrategy + ); + const preRenameSymbolName = applyProdRename(extractions, closureNodes, emit.emitMode); + const sourceExtensions = downgradeExtensions( + extractions, + emit.shouldTranspileJsx, + emit.shouldTranspileTs + ); + const parent = rewriteParent( + mod, + prepared, + extractions, + closureNodes, + analysis, + migration, + emit, + diagnostics + ); + const segmentModules = generateSegments( + mod, + prepared, + parent, + extractions, + closureNodes, + analysis, + migration, + emit, + preRenameSymbolName, + sourceExtensions, + flags.isJsx + ); + + return { + modules: [parent.parentModule, ...segmentModules], + diagnostics, + flags, + }; +} + +/** Phase 0 + 0.5: parse, flatten, and detect a foreign JSX runtime. */ +function prepareModuleInput(mod: ModuleContext): PreparedModuleInput { + const { input, relPath } = mod; + + let repairedCode = input.code; + let program: AstProgram; + let parserModule: AstEcmaScriptModule | undefined; + let diagnostics: readonly Diagnostic[] = []; + if (input.program) { + program = input.program; + parserModule = input.module; + } else { + const parsed = parseWithRawTransfer(relPath, repairedCode); + program = parsed.program; + parserModule = parsed.module; + if (program.body.length === 0) { + diagnostics = (parsed.errors ?? []).map((error) => ({ + category: 'error', + code: 'PARSE_ERROR', + file: relPath, + message: error.message, + highlights: null, + suggestions: null, + scope: 'optimizer', + })); + } + } + + if (diagnostics.length > 0) { + return { + repairedCode, + originalOffset: (offset) => offset, + program, + parserModule, + diagnostics, + hasForeignJsxRuntime: false, + foreignJsxPragmaText: null, + }; + } + + const edits = new MagicString(repairedCode); + const flattened = flattenDestructureUseCalls(repairedCode, relPath, program, edits); + const inlineProps = normalizeInlineComponentProps(repairedCode, relPath, program, edits); + let originalOffset = (offset: number): number => offset; + if (flattened.changed || inlineProps.changed) { + originalOffset = createOriginalOffsetMapper( + repairedCode, + edits.toString(), + edits.generateDecodedMap({ hires: true }).mappings + ); + repairedCode = edits.toString(); + const reparsed = parseWithRawTransfer(relPath, repairedCode); + program = reparsed.program; + parserModule = reparsed.module ?? parserModule; + } + + // Detect a foreign `@jsxImportSource` pragma once; threaded into rewrite + + // segment generation so both skip Qwik's JSX rewrite and defer to + // oxc-transform's default JSX handling for the pragma's runtime. + const { hasForeignJsxRuntime, pragmaText: foreignJsxPragmaText } = + detectForeignJsxRuntime(repairedCode); + + return { + repairedCode, + originalOffset, + program, + parserModule, + diagnostics, + hasForeignJsxRuntime, + foreignJsxPragmaText, + }; +} + +function createOriginalOffsetMapper( + original: string, + generated: string, + mappings: ReturnType['mappings'] +): (offset: number) => number { + const lineStarts = (source: string): number[] => { + const starts = [0]; + for (let i = 0; i < source.length; i++) { + if (source.charCodeAt(i) === 10) { + starts.push(i + 1); + } + } + return starts; + }; + const originalLines = lineStarts(original); + const generatedLines = lineStarts(generated); + const offsets = new Map([[generated.length, original.length]]); + for (let line = 0; line < mappings.length; line++) { + for (const segment of mappings[line]) { + const originalLine = segment[2]; + const originalColumn = segment[3]; + if (originalLine !== undefined && originalColumn !== undefined) { + offsets.set( + generatedLines[line] + segment[0], + originalLines[originalLine] + originalColumn + ); + } + } + } + return (offset) => offsets.get(offset) ?? offset; +} + +/** + * Drop `inlinedQrl` extractions nested as a value inside another `inlinedQrl`'s captures array. A + * QRL used as a capture value is not a lazy boundary; extracting it would rewrite its call site + * inside the outer `.w([...])` and collide with the outer capture-wrap edit. Containment is read + * off offsets: a call inside another inlinedQrl but after its arg0 (`callStart >= argEnd`) can only + * sit in the captures array. + */ +function filterCaptureInlinedQrls(extractions: ExtractionResult[]): ExtractionResult[] { + const inlined = extractions.filter((e) => e.isInlinedQrl); + if (inlined.length < 2) { + return extractions; + } + + const captureInlined = new Set(); + for (const inner of inlined) { + for (const outer of inlined) { + if (inner === outer) { + continue; + } + if ( + inner.callStart > outer.callStart && + inner.callEnd < outer.callEnd && + inner.callStart >= outer.argEnd + ) { + captureInlined.add(inner); + break; + } + } + } + + if (captureInlined.size === 0) { + return extractions; + } + return extractions.filter((e) => !captureInlined.has(e)); +} + +function extractModuleSegments( + mod: ModuleContext, + prepared: PreparedModuleInput +): SegmentExtraction { + const { input, options, relPath, ext } = mod; + const { repairedCode, program, parserModule } = prepared; + + const willTranspileJsx = options.transpileJsx !== false && (ext === '.tsx' || ext === '.jsx'); + + // Sound prefilter: a module whose text cannot contain an extraction trigger + // (see `sourceMayContainMarkers`) and has no JSX to transpile is a + // passthrough — skipping the gather walk here cannot change behavior. + if (!willTranspileJsx && !sourceMayContainMarkers(repairedCode)) { + return { + kind: 'passthrough', + module: buildPassthroughModule(repairedCode, relPath, input.path, program), + }; + } + + // Closure AST nodes are threaded out keyed by post-disambiguation + // symbolName, so downstream phases reuse the original parse instead of + // re-parsing each extraction's body. + const closureNodes = new Map(); + // One fused traversal produces the extraction set and every per-module fact. + // Identity-dependent projections key off closures discovered mid-walk; + // symbolName-keyed maps are derived post-walk, after disambiguation. + const facts = gatherModuleFacts({ + program, + repairedCode, + scopeEntries: true, + passiveConflicts: true, + // Gather scope bindings only when the Phase-4 JSX transform will consume + // them (same `willTranspileJsx` gate `rewriteParent` uses). + scopeBindings: willTranspileJsx, + extraction: { + source: repairedCode, + relPath, + scope: options.scope, + transpileJsx: willTranspileJsx, + // Explicit user-set flag (defaults false) for the ctxKind classifier — + // distinct from `willTranspileJsx`, which defaults true on .tsx/.jsx. + explicitTranspileJsx: options.transpileJsx === true, + parserModule, + closureNodesOut: closureNodes, + }, + }); + // The collector's `readonly ExtractedSegment[]` is widened once here; the + // orchestrator then advances elements in place through the `captured` → + // `consolidated` phases via per-mutation `Mutable` casts. + const extractions = facts.extractions as ExtractionResult[]; + for (const extraction of extractions) { + (extraction as Mutable).loc = [ + mkByteOffset(prepared.originalOffset(extraction.loc[0]) + 1), + mkByteOffset(prepared.originalOffset(extraction.loc[1]) + 1), + ]; + } + + if (extractions.length === 0 && !willTranspileJsx) { + return { + kind: 'passthrough', + module: buildPassthroughModule(repairedCode, relPath, input.path, program), + }; + } + + return { + kind: 'extracted', + extractions: filterCaptureInlinedQrls(extractions), + closureNodes, + facts, + }; +} + +/** Resolve the emit configuration (pure derivation from options + ext). */ +function resolveEmitConfig(mod: ModuleContext): EmitConfig { + const { options, relPath, ext } = mod; + + // `mode: 'lib'` emits a single-module library output: it reuses the inline + // pipeline, and a post-pass in `output-assembly.ts` collapses the inline + // shape into `inlinedQrl(body, name, [caps])`. + let entryStrategy: EntryStrategy; + if (options.mode === 'lib') { + entryStrategy = { type: 'inline' as const }; + } else { + entryStrategy = options.entryStrategy ?? { type: 'smart' as const }; + } + + const qrlOutputExt = computeOutputExtension(ext, options.transpileTs, options.transpileJsx); + const parentOutputExt = ext === '.mjs' || ext === '.cjs' ? ext : qrlOutputExt; + const parentOutputPath = options.preserveFilenames + ? relPath + : mkRelativePath(stripExtension(relPath) + parentOutputExt); + + return { + emitMode: options.mode ?? 'prod', + entryStrategy, + isInlineStrategy: entryStrategy.type === 'inline' || entryStrategy.type === 'hoist', + isLibMode: options.mode === 'lib', + shouldTranspileJsx: options.transpileJsx !== false, + shouldTranspileTs: options.transpileTs === true, + qrlOutputExt, + parentModulePath: computeParentModulePath(parentOutputPath, options.explicitExtensions), + parentOutputPath, + }; +} + +/** + * Phase 2: collect imports and analyze captures. + * + * Populates `captureNames` / `paramNames` / `captures` on every extraction (in place), runs + * event-handler capture-to-param promotion, emits C02 diagnostics, and flips the extraction phase + * discriminator to `'captured'`. + */ +function analyzeModuleCaptures( + mod: ModuleContext, + prepared: PreparedModuleInput, + extracted: ExtractedModule, + entryStrategy: EntryStrategy, + diagnostics: Diagnostic[], + shouldTranspileJsx = true +): CaptureAnalysis { + const { options, relPath } = mod; + const { repairedCode, program } = prepared; + const { extractions, closureNodes } = extracted; + + const originalImports = collectImports(program, prepared.parserModule); + const importedNames = new Set(originalImports.keys()); + + const enclosingExtMap = buildParentExtractionMap(extractions); + + const emitsChildFiles = entryStrategy.type !== 'inline' && entryStrategy.type !== 'hoist'; + const childRangesByParent = new Map>(); + if (emitsChildFiles) { + for (const child of extractions) { + const parent = enclosingExtMap.get(child.symbolName); + if (!parent) { + continue; + } + const ranges = childRangesByParent.get(parent.symbolName) ?? []; + ranges.push([child.argStart, child.argEnd]); + childRangesByParent.set(parent.symbolName, ranges); + } + } + + const moduleScopeIds = collectScopeIdentifiers(program, repairedCode, relPath); + + // Collect each segment body's scope identifiers for nested capture analysis; + // the closure nodes were already threaded through, so no body re-parse. + const bodyScopeIds = new Map>(); + for (const [symbolName, closureNode] of closureNodes) { + const bodyIds = collectScopeIdentifiers(closureNode, '', ''); + bodyScopeIds.set(symbolName, bodyIds); + } + + // Facts from the fused gather walk; node-identity keys (free identifiers, + // lexical scopes) survive the prod `s_` rename. + const { + closureFreeIdentifiers, + closureLexicalScopes, + nonFunctionCaptures, + extractionLoopMap, + loopBodyVarDecls, + allScopeEntries, + segmentUsage, + rootUsage, + passiveConflicts, + scopeAwareBindings, + } = extracted.facts; + + detectC03Diagnostics( + extractions, + nonFunctionCaptures, + repairedCode, + mod.input.code, + relPath, + diagnostics + ); + + for (const extraction of extractions) { + const closureNode = closureNodes.get(extraction.symbolName); + if (!closureNode) { + continue; + } + + const enclosingExt = enclosingExtMap.get(extraction.symbolName) ?? null; + + const lexicalScope = closureLexicalScopes.get(closureNode); + const parentScopeIds: Set = lexicalScope ?? moduleScopeIds; + + // inlinedQrl carries explicit captures — read them rather than analyzing. + if (extraction.isInlinedQrl) { + if (extraction.explicitCaptures) { + const items = extraction.explicitCaptures + .replace(leadingSquareBracket, '') + .replace(trailingSquareBracket, '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const identCaptures = items.filter( + (s) => + isSimpleIdentifierName(s) && + s !== 'true' && + s !== 'false' && + s !== 'null' && + s !== 'undefined' + ); + extraction.captureNames = identCaptures; + extraction.captures = identCaptures.length > 0; + } + continue; + } + + const result = analyzeCaptures( + closureNode, + parentScopeIds, + closureFreeIdentifiers.get(closureNode) ?? [] + ); + extraction.captureNames = result.captureNames; + extraction.paramNames = result.paramNames; + extraction.captures = result.captures; + + // Function/class declarations aren't serialized captures — keep only vars. + if (extraction.captureNames.length > 0) { + const enclosingClosure = enclosingExt ? closureNodes.get(enclosingExt.symbolName) : undefined; + extraction.captureNames = extraction.captureNames.filter((name) => { + const declType = enclosingClosure + ? classifyDeclarationTypeInClosure(enclosingClosure, name) + : classifyDeclarationType(program, name); + return declType === 'var'; + }); + extraction.captures = extraction.captureNames.length > 0; + } + + const childRanges = childRangesByParent.get(extraction.symbolName); + if (childRanges && extraction.captureNames.length > 0) { + extraction.captureNames = excludeNestedExtractionCaptures( + closureNode, + extraction.captureNames, + childRanges, + moduleScopeIds + ); + extraction.captures = extraction.captureNames.length > 0; + } + + if (extraction.captures && extraction.paramNames.length > 0) { + const paramSet = new Set(extraction.paramNames); + const allCapturesInParams = extraction.captureNames.every((name) => paramSet.has(name)); + if (allCapturesInParams) { + extraction.captures = false; + } + } + } + + // Resolve const literals for event handlers before capture-to-param promotion + for (const extraction of extractions) { + if (extraction.ctxKind !== 'eventHandler') { + continue; + } + if (extraction.isInlinedQrl || extraction.captureNames.length === 0) { + continue; + } + const enclosingExt = enclosingExtMap.get(extraction.symbolName) ?? null; + if (!enclosingExt) { + continue; + } + const enclosingClosure = closureNodes.get(enclosingExt.symbolName); + if (!enclosingClosure) { + continue; + } + const constValues = resolveConstLiteralsInClosure( + enclosingClosure, + repairedCode, + extraction.captureNames + ); + if (constValues.size > 0) { + extraction.constLiterals = constValues; + extraction.captureNames = extraction.captureNames.filter((n) => !constValues.has(n)); + extraction.captures = extraction.captureNames.length > 0; + } + } + + const globalDeclPositions = new Map(); + // Only `inline` (not `hoist`) skips captures→paramNames promotion: `hoist` + // still needs the `(_, _1, capture)` param-padding form, while `inline` + // keeps captures in `captureNames` for the `_captures[N]` unpacking path. + const isInlineOnlyStrategy = entryStrategy.type === 'inline'; + const eventCaptureCtx: EventCaptureContext = { + extractions, + closureNodes, + closureFreeIdentifiers, + bodyScopeIds, + moduleScopeIds, + importedNames, + enclosingExtMap, + extractionLoopMap, + allScopeEntries, + loopBodyVarDecls, + repairedCode, + isInlineStrategy: isInlineOnlyStrategy, + liftParentLevelHandlers: shouldTranspileJsx, + moduleTopLevelNames: new Set(collectModuleLevelDecls(program, repairedCode).map((d) => d.name)), + }; + + promoteEventHandlerCaptures(eventCaptureCtx, globalDeclPositions); + + // A worker wrapper delegates positionally to its worker child, so it takes + // the child's lifted params and moved-captures marker verbatim. + for (const wrapper of extractions) { + if (!wrapper.isWorkerEventWrapper) { + continue; + } + const worker = extractions.find( + (e) => + e.isWorkerEventHandler && e.callStart >= wrapper.argStart && e.callEnd <= wrapper.argEnd + ); + if (!worker) { + continue; + } + const mut = wrapper as Mutable; + mut.paramNames = [...worker.paramNames]; + mut.movedCaptures = worker.movedCaptures; + mut.captureNames = []; + mut.captures = false; + } + + unifyParameterSlots(eventCaptureCtx, globalDeclPositions); + + // Threads strip-config so stripped event handlers' captures still populate + // `elementQpParamsMap` for segment-body JSX. + const elementQpParamsMap = buildElementCaptureMap( + eventCaptureCtx, + globalDeclPositions, + options.stripCtxName, + options.stripEventHandlers + ); + + detectC02Diagnostics( + extractions, + closureNodes, + closureFreeIdentifiers, + enclosingExtMap, + importedNames, + program, + relPath, + diagnostics + ); + + // Flip the phase discriminator to 'captured' now that captureNames / + // paramNames are populated. Internal-builder cast avoids a pass-through map. + for (const extraction of extractions) { + (extraction as Mutable).phase = 'captured'; + } + + return { + originalImports, + importedNames, + enclosingExtMap, + extractionLoopMap, + elementQpParamsMap, + segmentUsage, + rootUsage, + passiveConflicts, + scopeAwareBindings, + }; +} + +/** Top-level module references resolve through module wiring, never QRL captures. */ +function dropTopLevelModuleScopeCaptures( + extractions: ExtractionResult[], + moduleLevelDeclsByName: ReadonlyMap +): void { + for (const ext of extractions) { + if (ext.parent !== null || ext.captureNames.length === 0) { + continue; + } + const kept = ext.captureNames.filter((name) => !moduleLevelDeclsByName.has(name)); + if (kept.length === ext.captureNames.length) { + continue; + } + const mut = ext as Mutable; + mut.captureNames = kept; + mut.captures = kept.length > 0; + } +} + +/** Phase 3: attribute module-level decl usage and decide migrations. */ +function attributeSegmentUsage( + mod: ModuleContext, + prepared: PreparedModuleInput, + extractions: ExtractionResult[], + analysis: CaptureAnalysis, + isInlineStrategy: boolean +): MigrationAnalysis { + const { options } = mod; + const { repairedCode, program } = prepared; + const { enclosingExtMap } = analysis; + + const moduleLevelDecls = collectModuleLevelDecls(program, repairedCode); + const moduleLevelDeclsByName = new Map(); + for (const d of moduleLevelDecls) { + moduleLevelDeclsByName.set(d.name, d); + } + + // With zero extractions nothing became dead, and the gather walk skipped + // usage classification (rootUsage is empty) — deciding migrations here + // would drop every non-exported decl as "unreferenced". + if (extractions.length === 0) { + return { + moduleLevelDecls, + moduleLevelDeclsByName, + segmentUsage: analysis.segmentUsage, + migrationDecisions: [], + }; + } + // Module-level refs inside an `inlinedQrl` body still need migration + // attribution: explicit captures cover only closure variables, so + // body-referenced module decls arrive via the Phase-2 usage maps, not + // captures. + const { segmentUsage, rootUsage } = analysis; + + // Augment segmentUsage with a `$()` body's captured names. inlinedQrl + // captures arrive via `_captures`, not an import — folding them in would + // wrongly mark them dual-use and reexport them. + for (const ext of extractions) { + if (ext.isInlinedQrl) { + continue; + } + const usage = segmentUsage.get(ext.symbolName); + if (usage && ext.captureNames) { + for (const name of ext.captureNames) { + usage.add(name); + } + } + } + + // Captures delivered via q:p (paramNames slots >= 2) are referenced by the parent. + for (const ext of extractions) { + if (ext.ctxKind !== 'eventHandler') { + continue; + } + if (ext.paramNames.length < 3 || ext.paramNames[0] !== '_' || ext.paramNames[1] !== '_1') { + continue; + } + const parentExt = enclosingExtMap.get(ext.symbolName) ?? null; + if (!parentExt) { + continue; + } + const parentUsage = segmentUsage.get(parentExt.symbolName); + if (!parentUsage) { + continue; + } + for (let i = 2; i < ext.paramNames.length; i++) { + const p = ext.paramNames[i]; + if (paddingParam.test(p)) { + continue; + } + parentUsage.add(p); + } + } + + // With transpileTs, TS enum values are inlined into segment bodies, so their + // names shouldn't count as segment usage. + if (options.transpileTs === true) { + const enumNames = new Set(); + for (const node of program.body) { + let enumDecl: TSEnumDeclaration | null = null; + if (node.type === 'TSEnumDeclaration') { + enumDecl = node; + } else if ( + node.type === 'ExportNamedDeclaration' && + node.declaration?.type === 'TSEnumDeclaration' + ) { + enumDecl = node.declaration; + } + if (enumDecl?.id?.name) { + enumNames.add(enumDecl.id.name); + } + } + if (enumNames.size > 0) { + for (const [, usedNames] of segmentUsage) { + for (const name of enumNames) { + usedNames.delete(name); + } + } + } + } + + // Stripped segments never resume, so their uses of module decls don't + // count; a decl consumed only by stripped segments is dropped outright, + // side effects included. + const strippedOnlyNames = new Set(); + if (options.stripCtxName || options.stripEventHandlers) { + const strippedSegments = new Set( + extractions + .filter((e) => isStrippedExtraction(e, options.stripCtxName, options.stripEventHandlers)) + .map((e) => e.symbolName as string) + ); + if (strippedSegments.size > 0) { + const usedByStripped = new Set(); + for (const [seg, names] of segmentUsage) { + if (strippedSegments.has(seg)) { + for (const name of names) { + usedByStripped.add(name); + } + } + } + for (const seg of strippedSegments) { + segmentUsage.delete(seg); + } + const usedByLiveSegment = new Set(); + for (const [, names] of segmentUsage) { + for (const name of names) { + usedByLiveSegment.add(name); + } + } + for (const name of usedByStripped) { + if (!usedByLiveSegment.has(name) && !rootUsage.has(name)) { + strippedOnlyNames.add(name); + } + } + } + } + + let migrationDecisions = analyzeMigration(moduleLevelDecls, segmentUsage, rootUsage, program); + const inlinedSegmentNames = new Set(); + for (const ext of extractions) { + if (!ext.isInlinedQrl) { + continue; + } + for (const name of segmentUsage.get(ext.symbolName) ?? []) { + inlinedSegmentNames.add(name); + } + } + migrationDecisions = migrationDecisions.map((decision) => + decision.action === 'reexport' && + moduleLevelDeclsByName.get(decision.varName)?.isExported && + !moduleLevelDeclsByName.get(decision.varName)?.isDirectlyExported && + inlinedSegmentNames.has(decision.varName) + ? { ...decision, useAutoExport: true } + : decision + ); + // A decl whose init holds an extraction keeps its (pure) marker-call + // registration as a bare statement — only the binding disappears, so + // whole-decl dropping would erase the registration. + migrationDecisions = migrationDecisions.map((d) => { + const decl = moduleLevelDeclsByName.get(d.varName); + if (!decl) { + return d; + } + const holdsExtraction = extractions.some( + (e) => e.callStart >= decl.declStart && e.callEnd <= decl.declEnd + ); + if (d.action === 'drop' && holdsExtraction) { + return { ...d, action: 'keep' as const, reason: MIG_REASON.KEEP_UNUSED }; + } + if ( + d.action === 'keep' && + strippedOnlyNames.has(d.varName) && + !decl.isExported && + !holdsExtraction + ) { + return { ...d, action: 'drop' as const, reason: MIG_REASON.DROP_STRIPPED_ONLY }; + } + return d; + }); + + // Captures of dropped decls no longer exist at runtime; scrub them so + // segment metadata and `.w()` wiring don't reference removed bindings. + const droppedNames = new Set( + migrationDecisions.filter((d) => d.action === 'drop').map((d) => d.varName) + ); + if (droppedNames.size > 0) { + for (const ext of extractions) { + if (ext.captureNames.length === 0) { + continue; + } + const kept = ext.captureNames.filter((name) => !droppedNames.has(name)); + if (kept.length === ext.captureNames.length) { + continue; + } + const mut = ext as Mutable; + mut.captureNames = kept; + mut.captures = kept.length > 0; + } + } + if (isInlineStrategy) { + migrationDecisions = filterInlineStrategyMigrations(migrationDecisions); + dropTopLevelModuleScopeCaptures(extractions, moduleLevelDeclsByName); + } else if (options.stripCtxName || options.stripEventHandlers) { + dropTopLevelModuleScopeCaptures( + extractions.filter((ext) => + isStrippedExtraction(ext, options.stripCtxName, options.stripEventHandlers) + ), + moduleLevelDeclsByName + ); + } + + return { + moduleLevelDecls, + moduleLevelDeclsByName, + segmentUsage, + migrationDecisions, + }; +} + +/** + * Prod mode: rename symbols to `s_`. Returns the renamed → original map used for + * migration-decision keying. + */ +function applyProdRename( + extractions: ExtractionResult[], + closureNodes: Map, + emitMode: EmitMode +): Map { + const preRenameSymbolName = new Map(); + if (emitMode !== 'prod') { + return preRenameSymbolName; + } + + for (const ext of extractions) { + // inlinedQrl extractions are renamed under prod too, preserving the hash + // suffix. Runtime QRL resolution is hash-keyed, not name-keyed, so the + // rename is safe even for peer-tool-supplied names. + const original = ext.symbolName; + // Internal-builder cast: rename mutates identity in place post-extraction. + (ext as Mutable).symbolName = mkSymbolName('s_' + ext.hash); + preRenameSymbolName.set(ext.symbolName, original); + // Mirror the rename in `closureNodes` so post-rename lookups still resolve. + const closure = closureNodes.get(original); + if (closure) { + closureNodes.delete(original); + closureNodes.set(ext.symbolName, closure); + } + } + return preRenameSymbolName; +} + +/** + * Downgrade extraction extensions for the transpile targets. Returns the pre-downgrade extension + * per symbol (segment codegen needs the source dialect even after the output extension changes). + */ +function downgradeExtensions( + extractions: ExtractionResult[], + shouldTranspileJsx: boolean, + shouldTranspileTs: boolean +): Map { + const sourceExtensions = new Map(); + for (const extraction of extractions) { + sourceExtensions.set(extraction.symbolName, extraction.extension); + } + + // In-place mutation via internal-builder cast. + if (shouldTranspileJsx || shouldTranspileTs) { + for (const extraction of extractions) { + const wip = extraction as Mutable; + if (shouldTranspileJsx) { + if (wip.extension === '.tsx') { + wip.extension = shouldTranspileTs ? '.js' : '.ts'; + } else if (wip.extension === '.jsx') { + wip.extension = '.js'; + } else if (shouldTranspileTs && wip.extension === '.ts') { + wip.extension = '.js'; + } + } else if (shouldTranspileTs) { + if (wip.extension === '.ts') { + wip.extension = '.js'; + } else if (wip.extension === '.tsx') { + wip.extension = '.jsx'; + } + } + } + } + return sourceExtensions; +} + +/** Phase 4: rewrite the parent module; emits C05 + passive-conflict diagnostics. */ +function rewriteParent( + mod: ModuleContext, + prepared: PreparedModuleInput, + extractions: ExtractionResult[], + closureNodes: Map, + analysis: CaptureAnalysis, + migration: MigrationAnalysis, + emit: EmitConfig, + diagnostics: Diagnostic[] +): ParentRewrite { + const { input, options, relPath, ext } = mod; + const { repairedCode, program, parserModule, hasForeignJsxRuntime } = prepared; + + const hasLocalInlinedQrl = extractions.some( + (e) => e.isInlinedQrl && !relPath.includes('node_modules') + ); + let devFile: string | undefined; + if (emit.emitMode === 'dev' || emit.emitMode === 'hmr' || hasLocalInlinedQrl) { + devFile = buildDevFilePath(input.path, options.srcDir, input.devPath); + } + + let jsxOptions: JsxRewriteOptions | undefined; + if (emit.shouldTranspileJsx && (ext === '.tsx' || ext === '.jsx')) { + jsxOptions = { + enableJsx: true, + importedNames: analysis.importedNames, + enableSignals: true, + // Phase-2 gather-walk projection; saves transformAllJsx its own bindings + // walk. Positions are plain numbers, so intervening parses can't + // invalidate them. + precomputedScopeBindings: analysis.scopeAwareBindings, + }; + } + + let strategyOptions: InlineStrategyOptions | undefined; + if (emit.isInlineStrategy) { + strategyOptions = { + inline: true, + entryType: emit.entryStrategy.type as 'inline' | 'hoist', + isLibMode: emit.isLibMode, + stripCtxName: options.stripCtxName, + stripEventHandlers: options.stripEventHandlers, + regCtxName: options.regCtxName, + }; + } else if (options.stripCtxName || options.stripEventHandlers || options.regCtxName) { + strategyOptions = { + inline: false, + stripCtxName: options.stripCtxName, + stripEventHandlers: options.stripEventHandlers, + regCtxName: options.regCtxName, + }; + } + + const parentResult = rewriteParentModule( + repairedCode, + relPath, + extractions, + analysis.originalImports, + migration.migrationDecisions, + migration.moduleLevelDecls, + jsxOptions, + emit.emitMode, + devFile, + strategyOptions, + options.stripExports, + options.isServer, + options.explicitExtensions, + options.transpileTs, + options.minify, + emit.qrlOutputExt, + program, + closureNodes, + input.devPath, + hasForeignJsxRuntime, + analysis.elementQpParamsMap + ); + + const cleanedCode = runDcePipeline(parentResult.code, relPath, { + isServer: options.isServer, + isDev: deriveIsDev(options.mode), + isLibMode: emit.isLibMode, + keepRelativeSideEffects: + emit.isInlineStrategy && !!(options.stripCtxName?.length || options.stripEventHandlers), + }); + const hygienicCode = applyModuleHygieneRenames(cleanedCode, relPath); + const removedImportSources = collectRemovedImportSources( + analysis.originalImports, + repairedCode, + hygienicCode + ); + const parentModule: TransformModule = { + kind: 'parent', + path: emit.parentOutputPath, + isEntry: false, + code: hygienicCode, + map: null, + origPath: input.path, + imports: removedImportSources.length > 0 ? removedImportSources : undefined, + }; + + detectC05Diagnostics( + program, + parserModule, + analysis.originalImports, + repairedCode, + relPath, + diagnostics + ); + + if (ext === '.tsx' || ext === '.jsx') { + emitPassiveConflictDiagnostics(analysis.passiveConflicts, relPath, repairedCode, diagnostics); + } + + return { parentModule, parentResult, devFile }; +} + +/** + * Phase 5: generate one module per non-stripped segment. Always runs the pipeline (lib mode relies + * on its side effects during the parent inline collapse) but returns no modules in lib mode — + * bodies were inlined into the parent. + */ +function generateSegments( + mod: ModuleContext, + prepared: PreparedModuleInput, + parent: ParentRewrite, + extractions: ExtractionResult[], + closureNodes: Map, + analysis: CaptureAnalysis, + migration: MigrationAnalysis, + emit: EmitConfig, + preRenameSymbolName: Map, + sourceExtensions: Map, + isJsx: boolean +): TransformModule[] { + const { input, options, relPath, ext } = mod; + const { repairedCode, program, hasForeignJsxRuntime, foreignJsxPragmaText } = prepared; + const { parentResult, devFile } = parent; + + // `parentResult.extractions` is already `ConsolidatedSegment[]` — the parent + // rewrite flipped the phase discriminator after `resolveNesting`. + const updatedExtractions = parentResult.extractions; + + // Resolve const-literal captures for child segments (default strategy only). + const constLiteralsMap = new Map>(); + const inlinedIdentifiersByParent = new Map>(); + if (!emit.isInlineStrategy) { + // A whole-body identifier (`$(render)`) inlines its const init so the + // segment doesn't emit a dangling reference. + for (const ext of updatedExtractions) { + if (ext.isSync) { + continue; + } + const bare = ext.bodyText.trim(); + if (!isSimpleIdentifierName(bare)) { + continue; + } + const scopeNode = ext.parent ? closureNodes.get(ext.parent) : (program as unknown as AstNode); + if (!scopeNode) { + continue; + } + const init = resolveWholeBodyIdentifier(scopeNode as AstNode, repairedCode, bare); + if (init !== null) { + const mutable = ext as Mutable; + mutable.bodyText = mkBodyText(init.text); + if (!ext.isInlinedQrl) { + mutable.loc = [ + mkByteOffset(prepared.originalOffset(init.start) + 1), + mkByteOffset(prepared.originalOffset(init.end) + 1), + ]; + } + if (ext.parent) { + const names = inlinedIdentifiersByParent.get(ext.parent) ?? new Set(); + names.add(bare); + inlinedIdentifiersByParent.set(ext.parent, names); + } + } + } + for (const ext of updatedExtractions) { + if (ext.isSync || ext.parent === null) { + continue; + } + if (ext.constLiterals && ext.constLiterals.size > 0) { + constLiteralsMap.set(ext.symbolName, ext.constLiterals); + } + if (ext.captureNames.length === 0) { + continue; + } + const parentExt = updatedExtractions.find((e) => e.symbolName === ext.parent); + if (!parentExt) { + continue; + } + const parentClosure = closureNodes.get(parentExt.symbolName); + if (!parentClosure) { + continue; + } + const constValues = resolveConstLiteralsInClosure( + parentClosure, + repairedCode, + ext.captureNames + ); + if (constValues.size > 0) { + const existing = constLiteralsMap.get(ext.symbolName); + if (existing) { + for (const [k, v] of constValues) { + existing.set(k, v); + } + } else { + constLiteralsMap.set(ext.symbolName, constValues); + } + ext.captureNames = ext.captureNames.filter((n) => !constValues.has(n)); + ext.captures = ext.captureNames.length > 0; + } + } + } + + const segmentCtx: SegmentGenerationContext = { + // Same array as `updatedExtractions` (mutated in place); narrowed for Phase 5. + extractions: extractions as ConsolidatedSegment[], + updatedExtractions, + closureNodes, + program, + originalImports: analysis.originalImports, + options, + repairedCode, + relPath, + // Original consumer-supplied `input.path` (vs the srcDir-relative + // `relPath`). Segment `module.path` derives its directory from this so + // output paths share the input's namespace — absolute in, absolute out. + inputPath: input.path, + emitMode: emit.emitMode, + devFile, + userDevPath: input.devPath, + isInlineStrategy: emit.isInlineStrategy, + entryStrategy: emit.entryStrategy, + migrationDecisions: migration.migrationDecisions, + moduleLevelDecls: migration.moduleLevelDecls, + moduleLevelDeclsByName: migration.moduleLevelDeclsByName, + movedDeclSnapshots: parentResult.movedDeclSnapshots, + segmentUsage: migration.segmentUsage, + parentModulePath: emit.parentModulePath, + preRenameSymbolName, + qrlOutputExt: emit.qrlOutputExt, + sourceExtensions, + // Drives oxc-transform's parser-dialect selection; falls back to `.tsx` + // (parses both TS and JSX) when the extension is missing. + parentSourceExt: ext || '.tsx', + shouldTranspileJsx: emit.shouldTranspileJsx, + shouldTranspileTs: emit.shouldTranspileTs, + isJsx, + importedNames: analysis.importedNames, + enclosingExtMap: analysis.enclosingExtMap, + elementQpParamsMap: analysis.elementQpParamsMap, + extractionLoopMap: analysis.extractionLoopMap, + constLiteralsMap, + inlinedIdentifiersByParent, + parentJsxKeyCounterValue: parentResult.jsxKeyCounterValue ?? 0, + jsxRegionKeyBases: parentResult.jsxRegionKeyBases, + hasForeignJsxRuntime, + foreignJsxPragmaText, + }; + + const segmentModules = generateAllSegmentModules(segmentCtx); + // lib mode inlines segment bodies into the parent; skip emitting the + // separately-generated segment modules. + if (emit.isLibMode) { + return []; + } + return segmentModules; +} + +/** Phase 6: apply diagnostic suppression directives (cross-file). */ +function applyDiagnosticSuppression( + diagnostics: Diagnostic[], + inputs: readonly TransformModuleInput[] +): Diagnostic[] { + let filtered = diagnostics; + for (const input of inputs) { + const directives = parseDisableDirectives(input.code); + if (directives.size > 0) { + filtered = filterSuppressedDiagnostics(filtered, directives); + } + } + return filtered; +} diff --git a/packages/ts-optimizer/src/optimizer/transform/module-cleanup.ts b/packages/ts-optimizer/src/optimizer/transform/module-cleanup.ts new file mode 100644 index 00000000000..37f35ee1e4b --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/transform/module-cleanup.ts @@ -0,0 +1,761 @@ +import MagicString from 'magic-string'; +import { walk } from 'oxc-walker'; +import { ScopeQueryTracker } from '../analysis/scope-query-tracker.js'; +import type { + AstFunction, + AstNode, + AstParentNode, + AstParseResult, + AstProgram, + ImportDeclaration, + ImportDeclarationSpecifier, + ImportSpecifier, +} from '../../ast-types.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { replaceResolvedConstIdentifiers } from '../rewrite/const-replacement.js'; +import { isRelativePathInsideBase } from '../../paths.js'; +import { applySegmentDCE, hasSegmentDcePatterns } from './dead-code.js'; +import { applyStatementDCE, extractBinaryOperandIdentifiers } from './statement-dce.js'; +import { + formatImportParts, + formatImportStatement, + formatNamedImportPart, +} from '../edit/import-format.js'; + +import { applyReplacements } from '../edit/range-replace.js'; +import { blankNonCode } from '../edit/text-scanning.js'; +import { isLibModePreservedMarker } from '../qwik/qrl-naming.js'; +import type { ExtractionResult } from '../extraction/extract.js'; +import type { TransformModule } from '../types/types.js'; +import type { RelativePath } from '../types/brands.js'; + +const exportConstLine = /^export const \w+ = /m; + +const CONST_IMPORT_SOURCES: readonly string[] = [ + '@qwik.dev/core', + '@qwik.dev/core/build', + '@builder.io/qwik', + '@builder.io/qwik/build', + '@builder.io/qwik-city/build', +]; + +function getImportedSpecifierName(spec: ImportSpecifier): string | undefined { + if (spec.imported.type === 'Identifier') { + return spec.imported.name; + } + return spec.imported.value; +} + +export function applySegmentConstReplacement( + code: string, + filename: string, + isServer?: boolean, + isDev?: boolean, + preParsedProgram?: AstProgram +): string { + if (isServer === undefined && isDev === undefined) { + return code; + } + + let program: AstProgram; + if (preParsedProgram) { + program = preParsedProgram; + } else { + try { + program = parseWithRawTransfer(filename, code).program; + } catch { + return code; + } + } + + const replacements = new Map(); + const importRanges = new Set(); + + for (const node of program.body) { + if (node.type !== 'ImportDeclaration') { + continue; + } + const source = node.source?.value ?? ''; + if (!CONST_IMPORT_SOURCES.includes(source)) { + continue; + } + + for (const spec of node.specifiers || []) { + importRanges.add(`${spec.local.start}:${spec.local.end}`); + if (spec.type !== 'ImportSpecifier') { + continue; + } + + const importedName = getImportedSpecifierName(spec) ?? spec.local?.name; + const localName = spec.local?.name; + if (!localName) { + continue; + } + + if (isServer !== undefined && importedName === 'isServer') { + replacements.set(localName, String(isServer)); + } else if (isServer !== undefined && importedName === 'isBrowser') { + replacements.set(localName, String(!isServer)); + } else if (isDev !== undefined && importedName === 'isDev') { + replacements.set(localName, String(isDev)); + } + } + } + + if (replacements.size === 0) { + return code; + } + + const s = new MagicString(code); + replaceResolvedConstIdentifiers(s, program, replacements, importRanges); + return s.toString(); +} + +export function injectUseHmr( + segmentCode: string, + devFile: string, + preParsedProgram?: AstProgram +): string { + let program: AstProgram; + if (preParsedProgram) { + program = preParsedProgram; + } else { + try { + program = parseWithRawTransfer('__segment_hmr__.tsx', segmentCode).program; + } catch { + return segmentCode; + } + } + + let targetFn: AstFunction | null = null; + for (const stmt of program.body) { + if ( + stmt.type !== 'ExportNamedDeclaration' || + stmt.declaration?.type !== 'VariableDeclaration' + ) { + continue; + } + const init = stmt.declaration.declarations?.[0]?.init; + if (!init) { + continue; + } + if ( + (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression') && + init.body + ) { + targetFn = init; + break; + } + } + if (!targetFn || !targetFn.body) { + return segmentCode; + } + + let result = injectHmrCallIntoFunctionBody(segmentCode, targetFn.body, devFile); + + if (!result.includes('import { _useHmr }')) { + const sepIdx = result.indexOf('\n//\n'); + if (sepIdx >= 0) { + result = + result.slice(0, sepIdx) + + '\nimport { _useHmr } from "@qwik.dev/core";' + + result.slice(sepIdx); + } else { + result = 'import { _useHmr } from "@qwik.dev/core";\n//\n' + result; + } + } + + return result; +} + +function injectHmrCallIntoFunctionBody( + code: string, + fnBody: NonNullable, + devFile: string +): string { + const s = new MagicString(code); + const indent = inferBlockIndent(code, fnBody.start + 1); + const hmrLine = `${indent}_useHmr("${devFile}");`; + + if (fnBody.type === 'BlockStatement') { + s.appendLeft(fnBody.start + 1, `\n${hmrLine}`); + } else { + const expressionText = code.slice(fnBody.start, fnBody.end); + s.overwrite(fnBody.start, fnBody.end, `{\n${hmrLine}\n${indent}return ${expressionText};\n}`); + } + return s.toString(); +} + +export function injectUseHmrIntoInlineBody(bodyText: string, devFile: string): string { + let program: AstProgram; + try { + program = parseWithRawTransfer('__inline_hmr__.tsx', bodyText).program; + } catch { + return bodyText; + } + const stmt = program.body[0]; + if (!stmt || stmt.type !== 'ExpressionStatement') { + return bodyText; + } + const fn = stmt.expression; + if ((fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression') || !fn.body) { + return bodyText; + } + return injectHmrCallIntoFunctionBody(bodyText, fn.body, devFile); +} + +function inferBlockIndent(code: string, start: number): string { + const afterBrace = code.slice(start); + const indentMatch = afterBrace.match(/\n(\s+)/); + return indentMatch ? indentMatch[1] : ' '; +} + +export function applySegmentSideEffectSimplification( + code: string, + filename: string, + preParsedProgram?: AstProgram +): string { + // Match on the blanked copy so an export-shaped line inside a template + // literal can't anchor the export block; positions are preserved. + const exportMatch = blankNonCode(code).match(exportConstLine); + if (!exportMatch) { + return code; + } + + const exportStart = exportMatch.index!; + + // Parse the full `code` and filter the walk by absolute position + // (`>= exportStart`) rather than reparsing a substring — the AST is never + // re-derived from a slice of the original input. + let program: AstProgram; + if (preParsedProgram) { + program = preParsedProgram; + } else { + try { + program = parseWithRawTransfer(filename, code).program; + } catch { + return code; + } + } + + const allRefs = new Map(); + const varDecls: Array<{ + name: string; + initStart: number; + initEnd: number; + declStart: number; + declEnd: number; + initType: string; + initText: string; + initNode: AstNode; + }> = []; + + walk(program, { + enter(node: AstNode, parent: AstParentNode) { + if (node.type === 'Identifier' && node.name) { + if (parent?.type === 'VariableDeclarator' && parent.id === node) { + return; + } + if (parent?.type === 'ImportSpecifier') { + return; + } + // Count references module-wide: parent modules interleave uses + // before the export block (unlike segments). + allRefs.set(node.name, (allRefs.get(node.name) ?? 0) + 1); + } + + if (node.type === 'VariableDeclaration' && node.kind === 'const') { + if (node.start === undefined || node.start < exportStart) { + return; + } + // Exported declarations are the module's contract — never rewrite them. + if (parent?.type === 'ExportNamedDeclaration') { + return; + } + for (const declarator of node.declarations) { + if (declarator.id?.type === 'Identifier' && declarator.init) { + if (node.declarations?.length > 1) { + continue; + } + varDecls.push({ + name: declarator.id.name, + initStart: declarator.init.start, + initEnd: declarator.init.end, + declStart: node.start, + declEnd: node.end, + initType: declarator.init.type, + initText: code.slice(declarator.init.start, declarator.init.end), + initNode: declarator.init, + }); + } + } + } + }, + }); + + const replacements: Array<{ start: number; end: number; replacement: string }> = []; + + for (const decl of varDecls) { + const refCount = allRefs.get(decl.name) ?? 0; + if (refCount > 0) { + continue; + } + if ( + decl.initType === 'ClassExpression' || + decl.initType === 'FunctionExpression' || + decl.initType === 'ArrowFunctionExpression' + ) { + continue; + } + if (code.indexOf(`export const ${decl.name} =`, exportStart) >= 0) { + continue; + } + + let replacement: string; + if (decl.initType === 'MemberExpression' || decl.initType === 'CallExpression') { + replacement = decl.initText + ';'; + } else if (decl.initType === 'BinaryExpression') { + const operandIds = extractBinaryOperandIdentifiers(decl.initNode); + replacement = operandIds.length > 0 ? operandIds.join(', ') + ';' : decl.initText + ';'; + } else { + continue; + } + + replacements.push({ + start: decl.declStart, + end: decl.declEnd, + replacement, + }); + } + + return applyReplacements(code, replacements); +} + +export function removeUnusedImports( + code: string, + filename: string, + preParsedProgram?: AstProgram, + isLibMode?: boolean, + keepRelativeSideEffects?: boolean, + preserveImportNames?: ReadonlySet +): string { + let parsed: AstParseResult | { program: AstProgram }; + if (preParsedProgram) { + parsed = { program: preParsedProgram }; + } else { + try { + parsed = parseWithRawTransfer(filename, code); + } catch { + return code; + } + } + + const importSpecs: Array<{ + localName: string; + node: ImportDeclaration; + specNode: ImportDeclarationSpecifier; + }> = []; + + for (const node of parsed.program.body) { + if (node.type !== 'ImportDeclaration') { + continue; + } + if (!node.specifiers || node.specifiers.length === 0) { + continue; + } + + for (const spec of node.specifiers) { + const localName = spec.local?.name; + if (localName) { + importSpecs.push({ localName, node, specNode: spec }); + } + } + } + + if (importSpecs.length === 0) { + return code; + } + + // Scope-aware: a local binding (param, let, catch) shadowing an import + // must not count as a reference to it. + const scopeTracker = new ScopeQueryTracker(); + const referencedNames = new Set(); + walk(parsed.program, { + scopeTracker, + enter(this: { skip: () => void }, node: AstNode, parent: AstParentNode) { + if (node.type === 'ImportDeclaration') { + this.skip(); + return; + } + // Type-position-only usages keep no import alive (matches Rust); + // expression wrappers (`x as T`, `x!`) still carry real references. + if ( + node.type.startsWith('TS') && + node.type !== 'TSAsExpression' && + node.type !== 'TSSatisfiesExpression' && + node.type !== 'TSNonNullExpression' && + node.type !== 'TSInstantiationExpression' && + node.type !== 'TSTypeAssertion' + ) { + this.skip(); + return; + } + + if ((node.type === 'Identifier' || node.type === 'JSXIdentifier') && node.name) { + if ( + parent?.type === 'Property' && + parent.key === node && + !parent.shorthand && + !parent.computed + ) { + return; + } + if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) { + return; + } + const decl = scopeTracker.getDeclaration(node.name); + if (decl && decl.type !== 'Import') { + return; + } + referencedNames.add(node.name); + } + }, + }); + + const unreferencedSpecs = importSpecs.filter((spec) => { + if (referencedNames.has(spec.localName) || preserveImportNames?.has(spec.localName)) { + return false; + } + + const importSource = spec.node.source?.value ?? ''; + + // lib mode preserves `$`-suffix marker imports and the `jsx as _jsx` + // jsx-runtime import even when unused — they're public-surface imports for + // downstream library consumers. + if (isLibMode) { + const importedName = + spec.specNode.type === 'ImportSpecifier' + ? (getImportedSpecifierName(spec.specNode) ?? spec.localName) + : spec.localName; + if (isLibModePreservedMarker(importedName) && importSource === '@qwik.dev/core') { + return false; + } + if (importedName === 'jsx' && importSource === '@qwik.dev/core/jsx-runtime') { + return false; + } + } + + return true; + }); + + if (unreferencedSpecs.length === 0) { + return code; + } + + const ms = new MagicString(code); + const specsByNode = new Map< + ImportDeclaration, + Array<{ + localName: string; + node: ImportDeclaration; + specNode: ImportDeclarationSpecifier; + }> + >(); + + for (const spec of unreferencedSpecs) { + const existing = specsByNode.get(spec.node) ?? []; + existing.push(spec); + specsByNode.set(spec.node, existing); + } + + for (const [node, specs] of specsByNode) { + const totalSpecs = node.specifiers?.length ?? 0; + if (specs.length >= totalSpecs) { + const src = node.source?.value ?? ''; + if (keepRelativeSideEffects && isRelativePathInsideBase(src, filename as RelativePath)) { + // Stripped-away client code still owns this module's evaluation + // order: keep the import as a bare side-effect form. + const quote = code[node.source.start] === "'" ? "'" : '"'; + ms.overwrite(node.start, node.end, `import ${quote}${src}${quote};`); + continue; + } + let end = node.end; + if (end < code.length && code[end] === '\n') { + end++; + } + ms.overwrite(node.start, end, ''); + continue; + } + + const unreferencedNames = new Set(specs.map((spec) => spec.localName)); + const keptParts: string[] = []; + let defaultPart = ''; + let nsPart = ''; + + for (const spec of node.specifiers ?? []) { + const localName = spec.local?.name; + if (unreferencedNames.has(localName)) { + continue; + } + + if (spec.type === 'ImportDefaultSpecifier') { + defaultPart = localName; + } else if (spec.type === 'ImportNamespaceSpecifier') { + nsPart = `* as ${localName}`; + } else { + const importedName = getImportedSpecifierName(spec) ?? localName; + keptParts.push(formatNamedImportPart(importedName, localName)); + } + } + + const importParts = formatImportParts(defaultPart, nsPart, keptParts); + const sourceValue = node.source?.value ?? ''; + const quote = code[node.source.start] === "'" ? "'" : '"'; + const newImport = formatImportStatement(importParts, quote, sourceValue); + let end = node.end; + if (end < code.length && code[end] === '\n') { + end++; + } + ms.overwrite(node.start, end, newImport + '\n'); + } + + const output = ms.toString(); + return keepRelativeSideEffects ? moveSideEffectImportsFirst(output, filename) : output; +} + +function moveSideEffectImportsFirst(code: string, filename: string): string { + const program = parseWithRawTransfer(filename, code).program; + const imports = program.body.filter((node) => node.type === 'ImportDeclaration'); + const firstBare = imports.findIndex((node) => node.specifiers.length === 0); + if ( + firstBare < 0 || + !imports.slice(0, firstBare).some((node) => node.specifiers.length > 0) || + imports.some( + (node, index) => index > 0 && !/^\s*$/.test(code.slice(imports[index - 1].end, node.start)) + ) + ) { + return code; + } + const ordered = [ + ...imports.filter((node) => node.specifiers.length === 0), + ...imports.filter((node) => node.specifiers.length > 0), + ]; + return ( + code.slice(0, imports[0].start) + + ordered.map((node) => code.slice(node.start, node.end)).join('\n') + + code.slice(imports.at(-1)!.end) + ); +} + +function isBareScript(program: AstProgram): boolean { + return !program.body.some( + (node) => + node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportDefaultDeclaration' || + node.type === 'ExportAllDeclaration' + ); +} + +function emptyParentModule(relPath: RelativePath, origPath: string): TransformModule { + return { kind: 'parent', path: relPath, isEntry: false, code: '', map: null, origPath }; +} + +export function buildPassthroughModule( + repairedCode: string, + relPath: RelativePath, + origPath: string, + cachedProgram: AstProgram | undefined +): TransformModule { + const program = cachedProgram ?? parseWithRawTransfer(relPath, repairedCode).program; + + if (isBareScript(program)) { + return emptyParentModule(relPath, origPath); + } + + const s = new MagicString(repairedCode); + + let lastImportEnd = -1; + for (const node of program.body) { + if (node.type === 'ImportDeclaration') { + let end = node.end; + if (end < repairedCode.length && repairedCode[end] === '\n') { + end++; + } + lastImportEnd = end; + } + } + + const bodyReferencedNames = new Set(); + walk(program, { + enter(this: { skip: () => void }, node: AstNode, parent: AstParentNode) { + if ( + parent?.type === 'Program' && + (node.type === 'ImportDeclaration' || node.type === 'VariableDeclaration') + ) { + this.skip(); + return; + } + if (node.type === 'Identifier' && node.name) { + bodyReferencedNames.add(node.name); + } + }, + }); + + for (const stmt of program.body) { + if (stmt.type !== 'VariableDeclaration') { + continue; + } + if (stmt.declarations?.length !== 1) { + continue; + } + const declarator = stmt.declarations[0]; + if (!declarator.init || declarator.init.type !== 'CallExpression') { + continue; + } + const callee = declarator.init.callee; + if (callee?.type !== 'Identifier' || callee.name !== 'inlinedQrl') { + continue; + } + const varName = declarator.id?.type === 'Identifier' ? declarator.id.name : null; + if (varName && !bodyReferencedNames.has(varName)) { + s.remove(stmt.start, declarator.init.start); + } + } + + if (lastImportEnd >= 0) { + let bodyStart = lastImportEnd; + while (bodyStart < repairedCode.length && repairedCode[bodyStart] === '\n') { + bodyStart++; + } + if (bodyStart > lastImportEnd) { + s.overwrite(lastImportEnd, bodyStart, '//\n'); + } else { + s.appendRight(lastImportEnd, '//\n'); + } + } else { + s.prepend('//\n'); + } + + return { + kind: 'parent', + path: relPath, + isEntry: false, + code: s.toString(), + map: null, + origPath, + }; +} + +export function buildParentExtractionMap( + extractions: ExtractionResult[] +): Map { + const map = new Map(); + for (const ext of extractions) { + let best: ExtractionResult | null = null; + for (const other of extractions) { + if (other.symbolName === ext.symbolName) { + continue; + } + // Worker wrappers are zero-scope shells around the worker call — scope + // visibility comes from the wrapper's own enclosing extraction. + if (other.isWorkerEventWrapper) { + continue; + } + if (ext.callStart >= other.argStart && ext.callEnd <= other.argEnd) { + if (!best || (other.argStart >= best.argStart && other.argEnd <= best.argEnd)) { + best = other; + } + } + } + if (best) { + map.set(ext.symbolName, best); + } + } + return map; +} + +export interface DcePipelineOptions { + isServer?: boolean; + isDev?: boolean; + /** Lib output serves both environments: skip env folding and statement-level DCE. */ + isLibMode?: boolean; + /** Passthrough modules: only clean up when the env fold actually changed the code. */ + onlyIfFoldChanges?: boolean; + /** Inject the HMR prologue after DCE, before the unused-import prune. */ + hmrDevFile?: string; + /** Inline strategy with stripping: downgrade fully-unused relative imports to side-effect form. */ + keepRelativeSideEffects?: boolean; + /** Imports retained because a directly nested extraction owns their evaluation. */ + preserveImportNames?: ReadonlySet; +} + +/** + * The one post-transform cleanup sequence: env-const fold, branch fold, statement DCE, side-effect + * simplification, optional HMR prologue, unused-import prune. Every emitted module (segment, + * parent, passthrough) runs the same order so the passes stay in lockstep. + */ +export function runDcePipeline(code: string, filename: string, opts: DcePipelineOptions): string { + let result = code; + + // Single-parse cache across stages; invalidated whenever a stage mutates. + let cachedProgram: AstProgram | undefined; + const lazyParse = (): AstProgram | undefined => { + if (cachedProgram) { + return cachedProgram; + } + try { + cachedProgram = parseWithRawTransfer(filename, result).program; + } catch { + cachedProgram = undefined; + } + return cachedProgram; + }; + const runStage = (stage: () => string): void => { + const before = result; + result = stage(); + if (result !== before) { + cachedProgram = undefined; + } + }; + + if ( + (opts.isServer !== undefined || opts.isDev !== undefined) && + !opts.isLibMode && + (result.includes('@qwik.dev/core') || result.includes('@builder.io/qwik')) + ) { + runStage(() => + applySegmentConstReplacement(result, filename, opts.isServer, opts.isDev, lazyParse()) + ); + } + if (opts.onlyIfFoldChanges && result === code) { + return code; + } + + if (hasSegmentDcePatterns(result)) { + runStage(() => applySegmentDCE(result, filename, lazyParse())); + } + if (!opts.isLibMode) { + runStage(() => applyStatementDCE(result, filename, lazyParse())); + runStage(() => applySegmentSideEffectSimplification(result, filename, lazyParse())); + } + + if (opts.hmrDevFile !== undefined) { + runStage(() => injectUseHmr(result, opts.hmrDevFile!, lazyParse())); + } + + if (result.startsWith('import ') || result.includes('\nimport ')) { + runStage(() => + removeUnusedImports( + result, + filename, + lazyParse(), + opts.isLibMode, + opts.keepRelativeSideEffects, + opts.preserveImportNames + ) + ); + } + + return result; +} diff --git a/packages/ts-optimizer/src/optimizer/transform/statement-dce.ts b/packages/ts-optimizer/src/optimizer/transform/statement-dce.ts new file mode 100644 index 00000000000..cb541cdf9c3 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/transform/statement-dce.ts @@ -0,0 +1,375 @@ +/** + * Statement-level dead-code elimination mirroring SWC's simplify pass on emitted modules: unused + * pure declarations, unused function/class declarations, and empty try statements are removed from + * block bodies. Runs to a fixpoint on one parse: removed statements join a dead-range set, and + * reference counting simply skips dead ranges — no reparse between passes. + */ + +import MagicString from 'magic-string'; +import { walk } from 'oxc-walker'; +import type { AstNode, AstProgram } from '../../ast-types.js'; +import { parseWithRawTransfer } from '../ast/parse.js'; +import { forEachAstChild } from '../ast/guards.js'; +import { isNonReferenceIdentifier } from '../analysis/variable-migration.js'; + +interface RangedNode { + readonly type: string; + readonly start: number; + readonly end: number; +} + +function isRecordNode(value: unknown): value is Record & RangedNode { + return typeof value === 'object' && value !== null && 'type' in value; +} + +export function extractBinaryOperandIdentifiers(node: AstNode): string[] { + const names: string[] = []; + const visit = (current: AstNode | null | undefined): boolean => { + if (!current) { + return false; + } + if (current.type === 'Identifier') { + names.push(current.name); + return true; + } + if (current.type === 'Literal') { + return true; + } + if (current.type === 'BinaryExpression') { + return visit(current.left) && visit(current.right); + } else if (current.type === 'ParenthesizedExpression') { + return visit(current.expression); + } + return false; + }; + return visit(node) ? names : []; +} + +/** Names referenced anywhere except declaration-name positions; dead ranges don't count. */ +function collectReferencedNames(program: AstProgram, dead: readonly RangedNode[]): Set { + const referenced = new Set(); + walk(program, { + enter(node, parent) { + const n = node as AstNode; + const p = parent as AstNode | null; + if (n.type !== 'Identifier' && n.type !== 'JSXIdentifier') { + return; + } + if (dead.some((d) => n.start >= d.start && n.end <= d.end)) { + return; + } + if (isNonReferenceIdentifier(n, p)) { + return; + } + if (p) { + // Declaration-name positions are bindings, not references. + if ( + (p.type === 'FunctionDeclaration' || + p.type === 'ClassDeclaration' || + p.type === 'VariableDeclarator') && + (p as unknown as { id?: unknown }).id === n + ) { + return; + } + } + referenced.add((n as { name: string }).name); + }, + }); + return referenced; +} + +function isPureInit(init: unknown): boolean { + if (init == null) { + return true; + } + if (!isRecordNode(init)) { + return false; + } + switch (init.type) { + case 'Literal': + case 'Identifier': + case 'ArrowFunctionExpression': + case 'FunctionExpression': + return true; + case 'TemplateLiteral': + return (init.expressions as unknown[] | undefined)?.length === 0; + case 'ObjectExpression': + return ((init.properties as unknown[] | undefined) ?? []).every((property) => { + if (!isRecordNode(property) || property.type !== 'Property' || property.computed) { + return false; + } + return isPureInit(property.value); + }); + case 'ArrayExpression': + return ((init.elements as unknown[] | undefined) ?? []).every( + (element) => element == null || isPureInit(element) + ); + default: + return false; + } +} + +function isRemovableVarDecl(stmt: Record, referenced: Set): boolean { + const decls = stmt.declarations as Array> | undefined; + if (!decls || decls.length === 0) { + return false; + } + for (const decl of decls) { + const id = decl.id as Record | undefined; + if (!id || id.type !== 'Identifier') { + return false; + } + if (referenced.has(id.name as string)) { + return false; + } + if (!isPureInit(decl.init)) { + return false; + } + } + return true; +} + +function classHasSideEffects(stmt: Record): boolean { + const superClass = stmt.superClass as Record | undefined | null; + if (superClass && superClass.type !== 'Identifier') { + return true; + } + const body = stmt.body as Record | undefined; + const members = (body?.body as Array> | undefined) ?? []; + for (const member of members) { + if (member.type === 'StaticBlock') { + return true; + } + if (member.type === 'PropertyDefinition' && (!member.static || !isPureInit(member.value))) { + return true; + } + } + return false; +} + +function isRemovableStatement(stmt: unknown, referenced: Set): stmt is RangedNode { + if (!isRecordNode(stmt)) { + return false; + } + switch (stmt.type) { + case 'VariableDeclaration': + return isRemovableVarDecl(stmt, referenced); + case 'FunctionDeclaration': { + const id = stmt.id as Record | undefined; + return !!id && !referenced.has(id.name as string); + } + case 'ClassDeclaration': { + const id = stmt.id as Record | undefined; + if (!id || referenced.has(id.name as string)) { + return false; + } + return !classHasSideEffects(stmt); + } + case 'TryStatement': { + const block = stmt.block as Record | undefined; + const blockEmpty = ((block?.body as unknown[] | undefined) ?? []).length === 0; + const finalizer = stmt.finalizer as Record | undefined | null; + const finalizerEmpty = + !finalizer || ((finalizer.body as unknown[] | undefined) ?? []).length === 0; + return blockEmpty && finalizerEmpty; + } + default: + return false; + } +} + +/** Statements that always complete abruptly, so nothing after them in the same block can run. */ +function alwaysExits(stmt: Record): boolean { + return ( + stmt.type === 'ReturnStatement' || + stmt.type === 'ThrowStatement' || + stmt.type === 'BreakStatement' || + stmt.type === 'ContinueStatement' + ); +} + +/** Hoisted bindings stay observable from before the exit, so they survive it. */ +function isHoistedDeclaration(stmt: Record): boolean { + return ( + stmt.type === 'FunctionDeclaration' || + (stmt.type === 'VariableDeclaration' && stmt.kind === 'var') + ); +} + +function collectBlockBodies(program: AstProgram): unknown[][] { + const bodies: unknown[][] = []; + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) { + visit(item); + } + return; + } + if (!isRecordNode(node)) { + return; + } + if ((node.type === 'BlockStatement' || node.type === 'Program') && Array.isArray(node.body)) { + bodies.push(node.body as unknown[]); + } + forEachAstChild(node as AstNode, (child) => visit(child)); + }; + visit(program); + return bodies; +} + +export function applyStatementDCE( + code: string, + filename: string, + preParsedProgram?: AstProgram +): string { + let program: AstProgram; + if (preParsedProgram) { + program = preParsedProgram; + } else { + try { + program = parseWithRawTransfer(filename, code).program; + } catch { + return code; + } + } + + const bodies = collectBlockBodies(program); + const dead: RangedNode[] = []; + const isDead = (stmt: RangedNode): boolean => + dead.some((d) => stmt.start >= d.start && stmt.end <= d.end); + const isRedundantLabel = (stmt: RangedNode & Record): boolean => { + if (stmt.type !== 'LabeledStatement') { + return false; + } + const label = stmt.label as Record | undefined; + const block = stmt.body as Record | undefined; + if (label?.type !== 'Identifier' || block?.type !== 'BlockStatement') { + return false; + } + const live = ((block.body as RangedNode[] | undefined) ?? []).filter((child) => !isDead(child)); + if (live.length === 0) { + return true; + } + const only = live[0] as RangedNode & Record; + const breakLabel = only.label as Record | undefined; + return ( + live.length === 1 && + only.type === 'BreakStatement' && + breakLabel?.type === 'Identifier' && + breakLabel.name === label.name + ); + }; + + // Fixpoint on the single parse: each pass recounts references while + // skipping statements already marked dead, so one removal can free another. + let changed = true; + while (changed) { + changed = false; + const referenced = collectReferencedNames(program, dead); + for (const body of bodies) { + let exited = false; + for (const stmt of body) { + if (!isRecordNode(stmt) || isDead(stmt)) { + continue; + } + if (exited && !isHoistedDeclaration(stmt)) { + dead.push(stmt); + changed = true; + continue; + } + if (isRedundantLabel(stmt)) { + dead.push(stmt); + changed = true; + continue; + } + if (isRemovableStatement(stmt, referenced)) { + dead.push(stmt); + changed = true; + continue; + } + if (alwaysExits(stmt)) { + exited = true; + } + } + } + } + + const referenced = collectReferencedNames(program, dead); + const preservedInitializers: Array<{ stmt: RangedNode; text: string }> = []; + for (const body of bodies) { + for (const stmt of body) { + if (!isRecordNode(stmt) || stmt.type !== 'VariableDeclaration' || isDead(stmt)) { + continue; + } + const declarations = stmt.declarations as Array> | undefined; + if (declarations?.length !== 1) { + continue; + } + const declarator = declarations[0]; + const id = declarator.id as Record | undefined; + const init = declarator.init; + if (id?.type === 'Identifier' && isRecordNode(init) && init.type === 'CallExpression') { + const callee = init.callee as Record | undefined; + const args = init.arguments as Array> | undefined; + const arg = args?.[0]; + const param = (callee?.params as Array> | undefined)?.[0]; + if ( + isRecordNode(callee) && + callee.type === 'FunctionExpression' && + param?.type === 'Identifier' && + param.name === id.name && + args?.length === 1 && + isRecordNode(arg) && + arg.type === 'LogicalExpression' && + arg.operator === '||' && + (arg.left as Record | undefined)?.type === 'Identifier' && + (arg.left as Record).name === id.name && + (arg.right as Record | undefined)?.type === 'ObjectExpression' && + !collectReferencedNames(program, [...dead, stmt]).has(id.name as string) + ) { + const pureComment = + code.slice(stmt.start, init.start).match(/\/\*\s*[@#]__PURE__\s*\*\/\s*$/)?.[0] ?? ''; + preservedInitializers.push({ + stmt, + text: `${pureComment}(${code.slice(init.start, callee.end)})${code.slice(callee.end, arg.start)}{}${code.slice(arg.end, init.end)};`, + }); + continue; + } + } + if (body === program.body) { + continue; + } + if ( + id?.type === 'Identifier' && + !referenced.has(id.name as string) && + !isPureInit(init) && + isRecordNode(init) + ) { + const binaryNames = + init.type === 'BinaryExpression' + ? extractBinaryOperandIdentifiers(init as unknown as AstNode) + : []; + preservedInitializers.push({ + stmt, + text: `${binaryNames.length ? binaryNames.join(', ') : code.slice(init.start, init.end)};`, + }); + } + } + } + + if (dead.length === 0 && preservedInitializers.length === 0) { + return code; + } + const s = new MagicString(code); + for (const { stmt, text } of preservedInitializers) { + s.overwrite(stmt.start, stmt.end, text); + } + for (const stmt of dead) { + let end = stmt.end; + if (end < code.length && code[end] === '\n') { + end++; + } + s.remove(stmt.start, end); + } + return s.toString(); +} diff --git a/packages/ts-optimizer/src/optimizer/types/brands.ts b/packages/ts-optimizer/src/optimizer/types/brands.ts new file mode 100644 index 00000000000..1148b968a0d --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/types/brands.ts @@ -0,0 +1,137 @@ +/** + * Branded primitive types for the optimizer. Each brand has exactly one smart constructor + * (`mk`); an invalid value is a defect, so the constructor throws rather than returning a + * recoverable error. + */ + +export type SymbolName = string & { readonly __brand: 'SymbolName' }; +export type Hash = string & { readonly __brand: 'Hash' }; +export type CanonicalFilename = string & { readonly __brand: 'CanonicalFilename' }; +export type DisplayName = string & { readonly __brand: 'DisplayName' }; +export type CtxName = string & { readonly __brand: 'CtxName' }; + +export type Origin = string & { readonly __brand: 'Origin' }; +export type RelativePath = string & { readonly __brand: 'RelativePath' }; +export type FilePath = string & { readonly __brand: 'FilePath' }; + +export type SourceText = string & { readonly __brand: 'SourceText' }; +export type BodyText = string & { readonly __brand: 'BodyText' }; + +export type ByteOffset = number & { readonly __brand: 'ByteOffset' }; +export type LineNumber = number & { readonly __brand: 'LineNumber' }; +export type ColumnNumber = number & { readonly __brand: 'ColumnNumber' }; + +const IDENTIFIER_SHAPE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +/** + * Accepts any non-empty alphanumeric-or-underscore string. Underscores are allowed because the + * inlinedQrl fallback can use a whole symbol name (e.g. `Foo_component_bbb`) as the hash slot; + * whitespace, path separators, `.`, and `-` are still rejected, and the distinct brand keeps this + * apart from `SymbolName`. + */ +const HASH_SHAPE = /^[A-Za-z0-9_]+$/; + +/** + * Context-name shape. Accepts the bare `$` marker, marker callees (`component$`), post-rewrite + * `Qrl` forms (`componentQrl`), and JSX attribute names whose namespace (`:`) and hyphen (`-`) + * characters would otherwise be rejected. + */ +const CTX_NAME_SHAPE = /^(\$|[a-zA-Z_][a-zA-Z0-9_:-]*\$?)$/; + +export function mkSymbolName(s: string): SymbolName { + if (!IDENTIFIER_SHAPE.test(s)) { + throw new Error(`mkSymbolName: invalid identifier shape: ${JSON.stringify(s)}`); + } + return s as SymbolName; +} + +export function mkHash(s: string): Hash { + if (!HASH_SHAPE.test(s)) { + throw new Error(`mkHash: expected non-empty [A-Za-z0-9_]+, got: ${JSON.stringify(s)}`); + } + return s as Hash; +} + +/** + * Loose by design: canonical filenames and display names span more than a JS identifier + * (`[[...slug]].tsx_slug_component`, `404.tsx__404_component`), so non-empty is the only invariant + * enforceable without rejecting real input. The brand still keeps them distinct from `SymbolName` + * and `Hash`. + */ +export function mkCanonicalFilename(s: string): CanonicalFilename { + if (s.length === 0) { + throw new Error('mkCanonicalFilename: empty string'); + } + return s as CanonicalFilename; +} + +export function mkDisplayName(s: string): DisplayName { + if (s.length === 0) { + throw new Error('mkDisplayName: empty string'); + } + return s as DisplayName; +} + +export function mkCtxName(s: string): CtxName { + if (!CTX_NAME_SHAPE.test(s)) { + throw new Error(`mkCtxName: invalid context-name shape: ${JSON.stringify(s)}`); + } + return s as CtxName; +} + +export function mkOrigin(s: string): Origin { + if (s.length === 0) { + throw new Error('mkOrigin: empty string'); + } + return s as Origin; +} + +// `TransformModule.path` must preserve the consumer's `input.path` namespace +// so a bundler-supplied absolute path round-trips as an absolute output path +// (the bundler's `resolveId` hook depends on it). Empty-string is the only +// invariant enforceable here. +export function mkRelativePath(s: string): RelativePath { + if (s.length === 0) { + throw new Error('mkRelativePath: empty string'); + } + return s as RelativePath; +} + +export function mkFilePath(s: string): FilePath { + if (s.length === 0) { + throw new Error('mkFilePath: empty string'); + } + if (s.includes('//')) { + throw new Error(`mkFilePath: empty path segment (//): ${JSON.stringify(s)}`); + } + return s as FilePath; +} + +export function mkSourceText(s: string): SourceText { + return s as SourceText; +} + +export function mkBodyText(s: string): BodyText { + return s as BodyText; +} + +export function mkByteOffset(n: number): ByteOffset { + if (!Number.isInteger(n) || n < 0) { + throw new Error(`mkByteOffset: expected non-negative integer, got: ${n}`); + } + return n as ByteOffset; +} + +export function mkLineNumber(n: number): LineNumber { + if (!Number.isInteger(n) || n < 0) { + throw new Error(`mkLineNumber: expected non-negative integer, got: ${n}`); + } + return n as LineNumber; +} + +export function mkColumnNumber(n: number): ColumnNumber { + if (!Number.isInteger(n) || n < 0) { + throw new Error(`mkColumnNumber: expected non-negative integer, got: ${n}`); + } + return n as ColumnNumber; +} diff --git a/packages/ts-optimizer/src/optimizer/types/types.ts b/packages/ts-optimizer/src/optimizer/types/types.ts new file mode 100644 index 00000000000..a5a3e56e0d9 --- /dev/null +++ b/packages/ts-optimizer/src/optimizer/types/types.ts @@ -0,0 +1,253 @@ +/** + * API types for the Qwik optimizer — the public interface for `transformModule` and related + * functions. The shape is a fixed compatibility contract, so some fields exist for that contract + * without being read yet (noted at the type level). + */ + +import type { AstEcmaScriptModule, AstProgram } from '../../ast-types.js'; + +import type { + ByteOffset, + CanonicalFilename, + ColumnNumber, + CtxName, + DisplayName, + Hash, + LineNumber, + Origin, + RelativePath, + SymbolName, +} from './brands.js'; + +/** + * Options for the `transformModule` batch transform. Some fields are accepted for API compatibility + * but not yet read by this optimizer (`rootDir`, `sourceMaps`, `preserveFilenames`). Defaults: + * `entryStrategy` smart, `minify` simplify, `mode` prod, `transpileJsx` on. + */ +export interface TransformModulesOptions { + readonly input: readonly TransformModuleInput[]; + + readonly srcDir: string; + + readonly rootDir?: string; + + readonly entryStrategy?: EntryStrategy; + + readonly minify?: MinifyMode; + + readonly sourceMaps?: boolean; + + readonly transpileTs?: boolean; + + readonly transpileJsx?: boolean; + + readonly preserveFilenames?: boolean; + + readonly explicitExtensions?: boolean; + + readonly mode?: EmitMode; + + readonly scope?: string; + + readonly stripExports?: readonly string[]; + + readonly regCtxName?: readonly string[]; + + readonly stripCtxName?: readonly string[]; + + readonly stripEventHandlers?: boolean; + + readonly isServer?: boolean; +} + +/** + * One source file passed to `transformModule` (transformed independently into a parent module plus + * zero-or-more segment modules). When `program` (and its optional sibling `module`) is supplied, + * the optimizer skips its internal parse and trusts the AST without re-validating it against + * `code`. + */ +export interface TransformModuleInput { + readonly path: string; + + readonly code: string; + + readonly devPath?: string; + + readonly program?: AstProgram; + + readonly module?: AstEcmaScriptModule; +} + +/** + * Result of a `transformModule` invocation: rewritten parent modules and their extracted segment + * modules (in dependency-friendly order) plus a flat diagnostics list. + */ +export interface TransformOutput { + readonly modules: readonly TransformModule[]; + + readonly diagnostics: readonly Diagnostic[]; + + readonly isTypeScript: boolean; + + readonly isJsx: boolean; +} + +/** + * One emitted module — a rewritten parent shell or an extracted segment, discriminated on `kind`. + * Source maps are not yet wired, so every `map` field is always `null`. + */ +export type TransformModule = TransformModuleParent | TransformModuleSegment; + +/** Rewritten parent module — the original file's QRL-referenced shell. */ +export interface TransformModuleParent { + readonly kind: 'parent'; + + readonly path: RelativePath; + + readonly isEntry: false; + + readonly code: string; + + readonly map: string | null; + + readonly origPath: string; + + /** Import sources removed as unused (e.g. only consumed by stripped segments). */ + readonly imports?: readonly string[]; +} + +/** + * Extracted segment module — a single `$()` body lifted into its own lazy-loadable file (`code` is + * empty for inline-strategy segments). + */ +export interface TransformModuleSegment { + readonly kind: 'segment'; + + readonly path: RelativePath; + + readonly isEntry: true; + + readonly code: string; + + readonly map: string | null; + + readonly segment: SegmentAnalysis; +} + +/** + * Per-segment metadata emitted alongside each extracted segment module and used by the runtime to + * wire `qrl(...)` references to the correct file. `captures` is derived from `captureNames` at + * extraction time and can diverge from it during later filtering (props consolidation, const + * inline, migration). + */ +export interface SegmentAnalysis { + readonly origin: Origin; + + readonly name: SymbolName; + + readonly entry: string | null; + + readonly displayName: DisplayName; + + readonly hash: Hash; + + readonly canonicalFilename: CanonicalFilename; + + readonly path: string; + + readonly extension: string; + + readonly parent: SymbolName | null; + + readonly ctxKind: 'eventHandler' | 'function' | 'jSXProp'; + + readonly ctxName: CtxName; + + readonly captures: boolean; + + readonly loc: readonly [ByteOffset, ByteOffset]; +} + +/** + * `SegmentAnalysis` plus the snapshot-only fields (`paramNames`, `captureNames`) that appear in + * metadata blocks but aren't part of the public API. `captureNames` is mutated across Phase 4–5 + * (props consolidation, const inline, migration filtering) before being snapshotted. + */ +export interface SegmentMetadataInternal extends SegmentAnalysis { + readonly paramNames?: readonly string[]; + + readonly captureNames?: readonly string[]; +} + +/** + * Per-symbol override map mixin for the `EntryStrategy` variants that emit separate segment files. + * `inline`/`hoist` deliberately omit it — they emit bodies inside the parent, so there is no + * per-segment file path to override. + */ +export interface WithManualEntryMap { + readonly manual?: Record; +} + +/** + * How the optimizer lays out lazy-load boundaries. `smart`/`segment`/`hook` emit one file per + * segment; `inline`/`hoist` keep bodies in the parent; `single` bundles every segment into one + * `entry_hooks` file; `component` groups by enclosing `component$`. The file-emitting variants + * accept a per-symbol manual override via {@link WithManualEntryMap}. + */ +export type EntryStrategy = + | { type: 'inline' } + | { type: 'hoist' } + | (WithManualEntryMap & { type: 'hook' }) + | (WithManualEntryMap & { type: 'segment' }) + | (WithManualEntryMap & { type: 'single' }) + | (WithManualEntryMap & { type: 'component' }) + | (WithManualEntryMap & { type: 'smart' }); + +export function hasManualEntryMap(s: EntryStrategy): s is EntryStrategy & WithManualEntryMap { + return s.type !== 'inline' && s.type !== 'hoist'; +} + +/** Post-transform parent-module simplification level. */ +export type MinifyMode = 'simplify' | 'none'; + +/** + * Build flavor for the emit pipeline: `prod` rewrites symbols to short `s_` form; `dev` keeps + * long names and emits `qrlDEV(...)` metadata; `hmr` adds `useHmr(devFile)` injection on + * `component$` segments; `lib` inlines bodies as `inlinedQrl(body, name)` in a single module; + * `test` applies no special handling. + */ +export type EmitMode = 'dev' | 'prod' | 'lib' | 'hmr' | 'test'; + +/** + * Source-range descriptor for diagnostics: byte offsets (`lo` inclusive, `hi` exclusive) plus + * line/column pairs. Lines are 1-based; columns are 0-based. + */ +export interface DiagnosticHighlightFlat { + readonly lo: ByteOffset; + readonly hi: ByteOffset; + readonly startLine: LineNumber; + readonly startCol: ColumnNumber; + readonly endLine: LineNumber; + readonly endCol: ColumnNumber; +} + +/** + * One error or warning surfaced by the optimizer, collected into `TransformOutput.diagnostics`. + * Suppressible from source via `// @qwik-disable-next-line ` directives. `suggestions` is + * always `null` (not yet wired). + */ +export interface Diagnostic { + readonly category: 'error' | 'warning'; + + readonly code: string; + + readonly file: string; + + readonly message: string; + + readonly highlights: readonly DiagnosticHighlightFlat[] | null; + + readonly suggestions: null; + + readonly scope: string; +} diff --git a/packages/ts-optimizer/src/paths.ts b/packages/ts-optimizer/src/paths.ts new file mode 100644 index 00000000000..ea12c9ca9b2 --- /dev/null +++ b/packages/ts-optimizer/src/paths.ts @@ -0,0 +1,151 @@ +/** + * Shared path-string helpers. Operate on normalized string paths for deterministic cross-platform + * behavior. + */ +import { + basename, + dirname, + extname, + isAbsolute, + normalize, + normalizeString, + relative, +} from 'pathe'; +import { type RelativePath, mkRelativePath } from './optimizer/types/brands.js'; + +// High-level entry points (`computeRelPath`, `computeParentModulePath`, +// `computeOutputExtension`) take/return branded path types; the low-level string +// helpers below accept plain `string` — branding them would force casts at every +// consumer with no type-safety win. + +export function getExtension(filePath: string): string { + return extname(normalizePath(filePath)); +} + +export function stripExtension(filePath: string): string { + const hasLeadingDotSlash = filePath.startsWith('./'); + const normalized = normalizePath(filePath); + const extension = extname(normalized); + if (!extension) { + return restoreDotSlash(normalized, hasLeadingDotSlash); + } + return restoreDotSlash(normalized.slice(0, -extension.length), hasLeadingDotSlash); +} + +export function getBasename(filePath: string): string { + return basename(normalizePath(filePath)); +} + +export function getDirectory(filePath: string): string { + const dir = dirname(normalizePath(filePath)); + return dir === '.' ? '' : dir; +} + +export function getFileStem(filePath: string): string { + return stripExtension(getBasename(filePath)); +} + +export function normalizePath(filePath: string): string { + return normalize(filePath); +} + +/** + * Compute the relative path from srcDir. When the path is outside srcDir, returns a normalized form + * with any leading `/` stripped, so the result is always usable as a relative path (no leading + * slash) — matching `RelativePath`'s shape rule. + * + * A leading `./` from the input is preserved: the hash function and the JSX dev-info `fileName:` + * emission both key off the user-provided path shape (e.g. `./node_modules/x` must stay + * `./node_modules/x`), so stripping it would shift hashes and dev-info paths for + * `node_modules`-prefixed inputs. Stripping the `./` for absolute-path concatenation happens at the + * call site. + */ +export function computeRelPath(inputPath: string, srcDir: string): RelativePath { + const hasLeadingDotSlash = inputPath.startsWith('./'); + const normInput = normalizePath(inputPath); + const normSrc = normalizePath(srcDir); + + if (normSrc === '.' || normSrc === '' || normSrc === './') { + return mkRelativePath(restoreDotSlash(stripLeadingSlash(normInput), hasLeadingDotSlash)); + } + + const rel = relative(normSrc, normInput); + if (rel !== '' && rel !== '.' && rel !== '..' && !rel.startsWith('../')) { + return mkRelativePath(restoreDotSlash(rel, hasLeadingDotSlash)); + } + + // Input lives outside srcDir. When both operands are absolute, `relative()` + // yields a well-formed `../`-prefixed path (e.g. a `node_modules` lib above the + // project root). Preserving that shape matters for the bundler: it anchors a + // segment's own relative imports against `origin`, and a slash-stripped absolute + // path resolves to garbage. `relative()` is only trustworthy with two absolute + // operands; a relative operand makes its result cwd-dependent, so those cases + // fall through to preserving the input verbatim. + if (isAbsolute(normInput) && isAbsolute(normSrc) && rel.startsWith('..')) { + return mkRelativePath(restoreDotSlash(rel, hasLeadingDotSlash)); + } + + return mkRelativePath(restoreDotSlash(stripLeadingSlash(normInput), hasLeadingDotSlash)); +} + +function restoreDotSlash(normalized: string, hadDotSlashPrefix: boolean): string { + if (!hadDotSlashPrefix) { + return normalized; + } + if (normalized.startsWith('./')) { + return normalized; + } + if (normalized === '' || normalized.startsWith('/')) { + return normalized; + } + return './' + normalized; +} + +function stripLeadingSlash(path: string): string { + return path.startsWith('/') ? path.slice(1) : path; +} + +export function isRelativePathInsideBase( + relativePath: string, + importerPath: RelativePath +): boolean { + if (!relativePath.startsWith('.')) { + return false; + } + + const importerDir = getDirectory(importerPath); + const combined = importerDir ? `${importerDir}/${relativePath}` : relativePath; + const normalized = normalizeString(normalizePath(combined), true); + + return normalized !== '..' && !normalized.startsWith('../'); +} + +/** + * Compute the parent module path for a segment's import back to the parent. Segments are emitted in + * the same directory as the parent, so only the basename is used (prefixed with `./`). + */ +export function computeParentModulePath( + relPath: RelativePath, + explicitExtensions?: boolean +): string { + const basename = getBasename(relPath); + if (explicitExtensions) { + return './' + basename; + } + return './' + stripExtension(basename); +} + +/** Output extension after independently stripping TypeScript and JSX syntax. */ +export function computeOutputExtension( + sourceExt: string, + transpileTs?: boolean, + transpileJsx?: boolean +): string { + if (transpileTs) { + return !transpileJsx && (sourceExt === '.tsx' || sourceExt === '.jsx') ? '.jsx' : '.js'; + } + if (transpileJsx) { + return sourceExt === '.ts' || sourceExt === '.tsx' ? '.ts' : '.js'; + } + return sourceExt; +} diff --git a/packages/ts-optimizer/src/testing/ast-compare.ts b/packages/ts-optimizer/src/testing/ast-compare.ts new file mode 100644 index 00000000000..635f6ec222a --- /dev/null +++ b/packages/ts-optimizer/src/testing/ast-compare.ts @@ -0,0 +1,210 @@ +import { isDeepStrictEqual } from 'node:util'; +import { parseSync } from 'oxc-parser'; + +export interface AstCompareResult { + match: boolean; + difference: string | null; + expectedParseError: string | null; + actualParseError: string | null; +} + +const POSITION_KEYS = new Set(['start', 'end', 'loc', 'range']); +const BLOCK_EQUIVALENT_STATEMENTS = new Set([ + 'BreakStatement', + 'ContinueStatement', + 'ExpressionStatement', + 'ForOfStatement', + 'ReturnStatement', + 'ThrowStatement', +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function collectIdentifierNames(node: unknown, names: Set): void { + if (Array.isArray(node)) { + for (const item of node) { + collectIdentifierNames(item, names); + } + return; + } + if (!isRecord(node)) { + return; + } + if (node.type === 'Identifier' && typeof node.name === 'string') { + names.add(node.name); + } + for (const value of Object.values(node)) { + collectIdentifierNames(value, names); + } +} + +function shouldStripRaw( + node: Record, + ancestors: readonly Record[] +): boolean { + if (node.type === 'Literal' || node.type === 'JSXText') { + return true; + } + const [parent, grandparent, greatGrandparent] = ancestors; + return ( + parent?.type === 'TemplateElement' && + grandparent?.type === 'TemplateLiteral' && + greatGrandparent?.type !== 'TaggedTemplateExpression' + ); +} + +export function stripAstPositions( + node: unknown, + ancestors: readonly Record[] = [] +): unknown { + if (Array.isArray(node)) { + return node.map((item) => stripAstPositions(item, ancestors)); + } + if (!isRecord(node)) { + return node; + } + if (node.type === 'ParenthesizedExpression' && node.expression) { + return stripAstPositions(node.expression, ancestors); + } + const parent = ancestors[0]; + const onlyStatement = Array.isArray(node.body) && node.body.length === 1 ? node.body[0] : null; + if ( + node.type === 'BlockStatement' && + isRecord(onlyStatement) && + (BLOCK_EQUIVALENT_STATEMENTS.has(String(onlyStatement.type)) || + (onlyStatement.type === 'IfStatement' && + (parent?.body === node || parent?.alternate === node))) + ) { + return stripAstPositions(onlyStatement, ancestors); + } + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (POSITION_KEYS.has(key) || (key === 'raw' && shouldStripRaw(node, ancestors))) { + continue; + } + if ( + key === 'key' && + node.type === 'Property' && + node.computed === false && + isRecord(value) && + (value.type === 'Identifier' || (value.type === 'Literal' && typeof value.value === 'string')) + ) { + result[key] = { + type: 'Literal', + value: value.type === 'Identifier' ? value.name : value.value, + }; + continue; + } + result[key] = + key === 'shorthand' && node.type === 'Property' + ? false + : stripAstPositions(value, [node, ...ancestors].slice(0, 3)); + } + return result; +} + +function normalizeImports(program: unknown): unknown { + if (!isRecord(program) || !Array.isArray(program.body)) { + return program; + } + + const groups = new Map; specifiers: unknown[] }>(); + let importCount = 0; + for (const statement of program.body) { + if (!isRecord(statement) || statement.type !== 'ImportDeclaration') { + break; + } + importCount++; + const declaration = { ...statement, specifiers: [] }; + const key = JSON.stringify(declaration); + const group = groups.get(key) ?? { declaration, specifiers: [] as unknown[] }; + group.specifiers.push(...(Array.isArray(statement.specifiers) ? statement.specifiers : [])); + groups.set(key, group); + } + if (importCount === 0) { + return program; + } + + const usedNames = new Set(); + collectIdentifierNames(program.body.slice(importCount), usedNames); + const imports = [...groups.values()].map(({ declaration, specifiers }) => ({ + ...declaration, + specifiers: specifiers + .filter( + (specifier) => + !isRecord(specifier) || + !isRecord(specifier.local) || + typeof specifier.local.name !== 'string' || + usedNames.has(specifier.local.name) + ) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + })); + return { ...program, body: [...imports, ...program.body.slice(importCount)] }; +} + +function parse(filename: string, code: string) { + const parsed = parseSync(filename, code); + if (!parsed.errors?.length || (!filename.endsWith('.js') && !filename.endsWith('.ts'))) { + return parsed; + } + + const jsxFilename = filename.replace(/\.(js|ts)$/, '.tsx'); + const jsxParsed = parseSync(jsxFilename, code); + return jsxParsed.errors.length < parsed.errors.length ? jsxParsed : parsed; +} + +function formatValue(value: unknown): string { + const formatted = JSON.stringify(value); + return formatted && formatted.length > 120 ? `${formatted.slice(0, 117)}...` : formatted; +} + +function findDifference(expected: unknown, actual: unknown, path = 'program'): string | null { + if (isDeepStrictEqual(expected, actual)) { + return null; + } + if (Array.isArray(expected) && Array.isArray(actual)) { + for (let index = 0; index < Math.min(expected.length, actual.length); index++) { + const difference = findDifference(expected[index], actual[index], `${path}[${index}]`); + if (difference) { + return difference; + } + } + return `${path}.length: expected ${expected.length}, received ${actual.length}`; + } + if (isRecord(expected) && isRecord(actual)) { + for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) { + if (!Object.hasOwn(expected, key) || !Object.hasOwn(actual, key)) { + return `${path}.${key}: ${Object.hasOwn(expected, key) ? 'missing from actual' : 'missing from expected'}`; + } + const difference = findDifference(expected[key], actual[key], `${path}.${key}`); + if (difference) { + return difference; + } + } + } + return `${path}: expected ${formatValue(expected)}, received ${formatValue(actual)}`; +} + +export function compareAst(expected: string, actual: string, filename: string): AstCompareResult { + const expectedResult = parse(filename, expected); + const actualResult = parse(filename, actual); + const expectedParseError = expectedResult.errors.length + ? expectedResult.errors.map((error) => error.message).join('; ') + : null; + const actualParseError = actualResult.errors.length + ? actualResult.errors.map((error) => error.message).join('; ') + : null; + + const parseErrorsMatch = (expectedParseError === null) === (actualParseError === null); + const difference = parseErrorsMatch + ? findDifference( + normalizeImports(stripAstPositions(expectedResult.program)), + normalizeImports(stripAstPositions(actualResult.program)) + ) + : 'program.parseErrors: only one side failed to parse'; + const match = parseErrorsMatch && difference === null; + + return { match, difference, expectedParseError, actualParseError }; +} diff --git a/packages/ts-optimizer/src/testing/batch-runner.ts b/packages/ts-optimizer/src/testing/batch-runner.ts new file mode 100644 index 00000000000..c51251108ea --- /dev/null +++ b/packages/ts-optimizer/src/testing/batch-runner.ts @@ -0,0 +1,92 @@ +import { readFileSync, existsSync, writeFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseSnapshot, type ParsedSnapshot } from './snapshot-parser.js'; + +export interface SnapshotTestResult { + file: string; + passed: boolean; + error?: string; +} + +export interface BatchResult { + total: number; + passed: number; + failed: number; + results: SnapshotTestResult[]; +} + +export interface BatchConfig { + snapshotDir: string; + batchSize: number; + batchIndex: number; + lockFile?: string; +} + +export function getSnapshotFiles(dir: string): string[] { + return readdirSync(dir) + .filter((f) => f.endsWith('.snap')) + .sort(); +} + +export function getBatchFiles(files: string[], batchSize: number, batchIndex: number): string[] { + const start = batchIndex * batchSize; + return files.slice(start, start + batchSize); +} + +export function loadLockedSnapshots(lockFile: string): string[] { + if (!existsSync(lockFile)) { + return []; + } + const content = readFileSync(lockFile, 'utf-8'); + return JSON.parse(content) as string[]; +} + +export function saveLockedSnapshots(lockFile: string, names: string[]): void { + writeFileSync(lockFile, JSON.stringify([...new Set(names)].sort(), null, 2) + '\n'); +} + +export function runBatch( + config: BatchConfig, + testFn?: (snapshot: ParsedSnapshot, filename: string) => { passed: boolean; error?: string } +): BatchResult { + const allFiles = getSnapshotFiles(config.snapshotDir); + const batchFiles = getBatchFiles(allFiles, config.batchSize, config.batchIndex); + const locked = config.lockFile ? loadLockedSnapshots(config.lockFile) : []; + + const results: SnapshotTestResult[] = []; + + for (const file of batchFiles) { + if (locked.includes(file)) { + results.push({ file, passed: true }); + continue; + } + + try { + const content = readFileSync(join(config.snapshotDir, file), 'utf-8'); + const snapshot = parseSnapshot(content); + + if (testFn) { + const result = testFn(snapshot, file); + results.push({ file, passed: result.passed, error: result.error }); + } else { + results.push({ file, passed: true }); + } + } catch (err) { + results.push({ file, passed: false, error: String(err) }); + } + } + + const passed = results.filter((r) => r.passed).length; + return { + total: results.length, + passed, + failed: results.length - passed, + results, + }; +} + +export function lockPassingSnapshots(lockFile: string, batchResult: BatchResult): void { + const existing = loadLockedSnapshots(lockFile); + const newlyPassing = batchResult.results.filter((r) => r.passed).map((r) => r.file); + saveLockedSnapshots(lockFile, [...existing, ...newlyPassing]); +} diff --git a/packages/ts-optimizer/src/testing/metadata-compare.ts b/packages/ts-optimizer/src/testing/metadata-compare.ts new file mode 100644 index 00000000000..efa9d73be7f --- /dev/null +++ b/packages/ts-optimizer/src/testing/metadata-compare.ts @@ -0,0 +1,55 @@ +import type { SegmentMetadata } from './snapshot-parser.js'; + +export interface MetadataFieldMismatch { + field: string; + expected: unknown; + actual: unknown; +} + +export interface MetadataCompareResult { + match: boolean; + mismatches: MetadataFieldMismatch[]; +} + +export function compareMetadata( + expected: SegmentMetadata, + actual: SegmentMetadata +): MetadataCompareResult { + const mismatches: MetadataFieldMismatch[] = []; + + const simpleFields: (keyof SegmentMetadata)[] = [ + 'origin', + 'name', + 'entry', + 'displayName', + 'hash', + 'canonicalFilename', + 'path', + 'extension', + 'parent', + 'ctxKind', + 'ctxName', + 'captures', + ]; + + for (const field of simpleFields) { + if (expected[field] !== actual[field]) { + mismatches.push({ field, expected: expected[field], actual: actual[field] }); + } + } + + if (expected.loc[0] !== actual.loc[0] || expected.loc[1] !== actual.loc[1]) { + mismatches.push({ field: 'loc', expected: expected.loc, actual: actual.loc }); + } + + const arrayFields: (keyof SegmentMetadata)[] = ['paramNames', 'captureNames']; + for (const field of arrayFields) { + const exp = expected[field]; + const act = actual[field]; + if (JSON.stringify(exp) !== JSON.stringify(act)) { + mismatches.push({ field, expected: exp, actual: act }); + } + } + + return { match: mismatches.length === 0, mismatches }; +} diff --git a/packages/ts-optimizer/src/testing/snapshot-parser.ts b/packages/ts-optimizer/src/testing/snapshot-parser.ts new file mode 100644 index 00000000000..6c7c82867ba --- /dev/null +++ b/packages/ts-optimizer/src/testing/snapshot-parser.ts @@ -0,0 +1,298 @@ +/** + * Parser for `.snap` fixture files: YAML frontmatter, optional INPUT section, segment blocks with + * metadata, parent module blocks, and diagnostics. + */ + +export interface SegmentMetadata { + origin: string; + name: string; + entry: string | null; + displayName: string; + hash: string; + canonicalFilename: string; + path: string; + extension: string; + parent: string | null; + ctxKind: string; + ctxName: string; + captures: boolean; + loc: [number, number]; + paramNames?: string[]; + captureNames?: string[]; +} + +export interface Diagnostic { + category: string; + code: string; + file: string; + message: string; + highlights: Array<{ + lo: number; + hi: number; + startLine: number; + startCol: number; + endLine: number; + endCol: number; + }> | null; + suggestions: null; + scope: string; +} + +export interface SegmentBlock { + filename: string; + isEntryPoint: boolean; + code: string; + sourceMap: string | null; + metadata: SegmentMetadata | null; +} + +export interface ParentModule { + filename: string; + code: string; + sourceMap: string | null; +} + +export interface ParsedSnapshot { + frontmatter: { source: string; assertionLine: number; expression: string }; + input: string | null; + inputs: Array<{ path: string; code: string }>; + segments: SegmentBlock[]; + parentModules: ParentModule[]; + diagnostics: Diagnostic[]; +} + +export function parseSnapshot(content: string): ParsedSnapshot { + const frontmatter = parseFrontmatter(content); + + const afterFrontmatter = stripFrontmatter(content); + + const { body, diagnostics } = extractDiagnostics(afterFrontmatter); + + const { input, inputs, rest } = extractInput(body); + + const { segments, parentModules } = parseSections(rest); + + return { + frontmatter, + input, + inputs, + segments, + parentModules, + diagnostics, + }; +} + +function parseFrontmatter(content: string): ParsedSnapshot['frontmatter'] { + const firstDash = content.indexOf('---'); + if (firstDash === -1) { + throw new Error('No frontmatter found (missing opening ---)'); + } + const secondDash = content.indexOf('---', firstDash + 3); + if (secondDash === -1) { + throw new Error('No frontmatter found (missing closing ---)'); + } + + const yaml = content.slice(firstDash + 3, secondDash).trim(); + const lines = yaml.split('\n'); + + let source = ''; + let assertionLine = 0; + let expression = ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('source:')) { + source = trimmed.slice('source:'.length).trim(); + } else if (trimmed.startsWith('assertion_line:')) { + assertionLine = parseInt(trimmed.slice('assertion_line:'.length).trim(), 10); + } else if (trimmed.startsWith('expression:')) { + expression = trimmed.slice('expression:'.length).trim(); + } + } + + return { source, assertionLine, expression }; +} + +function stripFrontmatter(content: string): string { + const firstDash = content.indexOf('---'); + const secondDash = content.indexOf('---', firstDash + 3); + return content.slice(secondDash + 3); +} + +function extractDiagnostics(body: string): { body: string; diagnostics: Diagnostic[] } { + const diagMarker = '== DIAGNOSTICS =='; + const diagIdx = body.indexOf(diagMarker); + + if (diagIdx === -1) { + return { body, diagnostics: [] }; + } + + const beforeDiag = body.slice(0, diagIdx); + const diagContent = body.slice(diagIdx + diagMarker.length).trim(); + + let diagnostics: Diagnostic[] = []; + if (diagContent) { + try { + diagnostics = JSON.parse(diagContent); + } catch { + diagnostics = []; + } + } + + return { body: beforeDiag, diagnostics }; +} + +function extractInput(body: string): { + input: string | null; + inputs: Array<{ path: string; code: string }>; + rest: string; +} { + const namedInputRe = /^==INPUT (.+)==$/gm; + const namedMarkers = [...body.matchAll(namedInputRe)]; + if (namedMarkers.length > 0) { + const section = body.match(/^={3,}\s*.+\s*==$/m); + const sectionStart = section?.index ?? body.length; + const inputs = namedMarkers.map((marker, index) => { + const start = marker.index! + marker[0].length; + const end = namedMarkers[index + 1]?.index ?? sectionStart; + const code = body.slice(start, end).replace(/^\n\n/, '').trimEnd(); + return { path: marker[1].trim(), code }; + }); + return { input: null, inputs, rest: body.slice(sectionStart) }; + } + + const inputMarker = '==INPUT=='; + const inputIdx = body.indexOf(inputMarker); + + if (inputIdx === -1) { + return { input: null, inputs: [], rest: body }; + } + + let afterInput = body.slice(inputIdx + inputMarker.length); + // Insta puts a blank separator after the marker; it is not part of the Rust test input. + afterInput = afterInput.replace(/^\n\n/, ''); + + const delimMatch = afterInput.match(/^={3,}\s*.+\s*==$/m); + if (!delimMatch || delimMatch.index === undefined) { + // trimEnd() preserves leading newlines — they affect the qrlDEV lo/hi byte + // offsets, computed from the original input including leading whitespace. + return { input: afterInput.trimEnd(), inputs: [], rest: '' }; + } + + const input = afterInput.slice(0, delimMatch.index).trimEnd(); + const rest = afterInput.slice(delimMatch.index); + + return { input: input || null, inputs: [], rest }; +} + +/** + * Section delimiter pattern: lines like ============================= filename.tsx (ENTRY POINT)== + * ============================= filename.tsx == + */ +const SECTION_DELIM_RE = /^(?={3,})\s*(?.+)\s*(?==)$/; + +function parseSections(body: string): { + segments: SegmentBlock[]; + parentModules: ParentModule[]; +} { + const segments: SegmentBlock[] = []; + const parentModules: ParentModule[] = []; + + const lines = body.split('\n'); + + const delimiters: Array<{ index: number; filename: string; isEntryPoint: boolean }> = []; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(SECTION_DELIM_RE); + if (match) { + const rawFilename = (match.groups?.name ?? match[2])!.trim(); + const isEntryPoint = rawFilename.includes('(ENTRY POINT)'); + const filename = rawFilename.replace('(ENTRY POINT)', '').trim(); + delimiters.push({ index: i, filename, isEntryPoint }); + } + } + + for (let d = 0; d < delimiters.length; d++) { + const delim = delimiters[d]; + const startLine = delim.index + 1; + const endLine = d + 1 < delimiters.length ? delimiters[d + 1].index : lines.length; + + const sectionLines = lines.slice(startLine, endLine); + const sectionBody = sectionLines.join('\n'); + + const metadata = extractMetadata(sectionBody); + + if (metadata !== null) { + const { code, sourceMap } = extractCodeAndSourceMap(sectionBody); + segments.push({ + filename: delim.filename, + isEntryPoint: delim.isEntryPoint, + code, + sourceMap, + metadata, + }); + } else { + const { code, sourceMap } = extractCodeAndSourceMap(sectionBody); + parentModules.push({ + filename: delim.filename, + code, + sourceMap, + }); + } + } + + return { segments, parentModules }; +} + +function extractMetadata(sectionBody: string): SegmentMetadata | null { + const metaStart = sectionBody.lastIndexOf('/*\n'); + if (metaStart === -1) { + return null; + } + + const metaEnd = sectionBody.indexOf('*/', metaStart); + if (metaEnd === -1) { + return null; + } + + const jsonStr = sectionBody.slice(metaStart + 2, metaEnd).trim(); + + try { + const parsed = JSON.parse(jsonStr); + return parsed as SegmentMetadata; + } catch { + return null; + } +} + +function extractCodeAndSourceMap(sectionBody: string): { + code: string; + sourceMap: string | null; +} { + const someMatch = sectionBody.match(/^Some\("(?.*)"\)$/m); + + let sourceMap: string | null = null; + let code: string; + + if (someMatch && someMatch.index !== undefined) { + sourceMap = (someMatch.groups?.val ?? someMatch[1])! + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + + code = sectionBody.slice(0, someMatch.index).trimEnd(); + } else { + code = sectionBody; + } + + const metaStart = code.lastIndexOf('/*\n'); + if (metaStart !== -1) { + const metaEnd = code.indexOf('*/', metaStart); + if (metaEnd !== -1) { + code = code.slice(0, metaStart).trimEnd(); + } + } + + code = code.replace(/^\n+/, '').replace(/\n+$/, ''); + + return { code, sourceMap }; +} diff --git a/packages/ts-optimizer/src/transform-run.ts b/packages/ts-optimizer/src/transform-run.ts new file mode 100644 index 00000000000..17f2bdd4ec8 --- /dev/null +++ b/packages/ts-optimizer/src/transform-run.ts @@ -0,0 +1,64 @@ +// Shared synchronous transform runner used by both the in-process optimizer +// and the worker-pool worker side. + +import { transformModule } from './optimizer/transform/index.js'; + +import type { Diagnostic, SegmentAnalysis, TransformModule } from './optimizer/types/types.js'; +import type { + NapiDiagnostic, + NapiSegmentAnalysis, + NapiSourceLocation, + NapiTransformModule, + NapiTransformModulesOptions, + NapiTransformOutput, +} from './create-optimizer.js'; + +function toNapiSegment(segment: SegmentAnalysis): NapiSegmentAnalysis { + return { ...segment, loc: [segment.loc[0], segment.loc[1]] }; +} + +function toNapiModule(module: TransformModule): NapiTransformModule { + switch (module.kind) { + case 'parent': + return { + path: module.path, + isEntry: module.isEntry, + code: module.code, + map: module.map, + segment: null, + origPath: module.origPath, + imports: module.imports ? [...module.imports] : undefined, + }; + case 'segment': + return { + path: module.path, + isEntry: module.isEntry, + code: module.code, + map: module.map, + segment: toNapiSegment(module.segment), + origPath: null, + }; + default: { + const _exhaustive: never = module; + throw new Error(`unhandled module kind: ${(_exhaustive as { kind?: string }).kind}`); + } + } +} + +function toNapiDiagnostic(diagnostic: Diagnostic): NapiDiagnostic { + let highlights: NapiSourceLocation[] | null = null; + if (diagnostic.highlights !== null) { + highlights = diagnostic.highlights.map((highlight) => ({ ...highlight })); + } + return { ...diagnostic, highlights }; +} + +export function runTransform(opts: NapiTransformModulesOptions): NapiTransformOutput { + const output = transformModule(opts); + return { + modules: output.modules.map(toNapiModule), + diagnostics: output.diagnostics.map(toNapiDiagnostic), + isTypeScript: output.isTypeScript, + isJsx: output.isJsx, + }; +} diff --git a/packages/ts-optimizer/src/worker-pool.ts b/packages/ts-optimizer/src/worker-pool.ts new file mode 100644 index 00000000000..4e019946aab --- /dev/null +++ b/packages/ts-optimizer/src/worker-pool.ts @@ -0,0 +1,216 @@ +// Worker-thread pool for `transformModules`. +// +// Transforms materialize large oxc AST object trees; running them inside the +// bundler process (vite build + SSR in one process) piles that allocation +// pressure onto the host heap. The pool moves each `transformModules` call +// into a `node:worker_threads` worker with its own V8 heap, which also lets +// independent per-file transforms run in parallel. +// +// This module is its own worker entry: spawning `import.meta.url` with the +// `WORKER_FLAG` in workerData starts the message loop at the bottom of this +// file. Everything degrades to `null`/in-process on any failure so browser, +// deno, and constrained environments keep working unchanged. + +import { runTransform } from './transform-run.js'; + +import type { NapiTransformModulesOptions, NapiTransformOutput } from './create-optimizer.js'; + +const WORKER_FLAG = '__qwik_ts_optimizer_worker__'; + +interface WorkerRequest { + id: number; + opts: NapiTransformModulesOptions; +} + +interface WorkerResponse { + id: number; + output?: NapiTransformOutput; + error?: string; +} + +export interface TransformWorkerPool { + transformModules(opts: NapiTransformModulesOptions): Promise; + dispose(): Promise; +} + +const isNode = typeof process === 'object' && !!process.versions?.node; + +/** Resolve the pool size: explicit option > env override > default. 0/1 disables the pool. */ +export function resolvePoolSize(workers?: number): number { + if (typeof workers === 'number') { + return Math.floor(workers); + } + if (!isNode) { + return 0; + } + const env = process.env.QWIK_TS_OPTIMIZER_WORKERS; + if (env !== undefined) { + const parsed = Number(env); + return Number.isFinite(parsed) ? Math.floor(parsed) : 0; + } + // Unit-test runners already parallelize across processes; don't nest pools. + if (process.env.VITEST) { + return 0; + } + const cores = globalThis.navigator?.hardwareConcurrency ?? 8; + return Math.min(4, Math.max(1, cores - 1)); +} + +/** + * Create a transform worker pool, or `null` when unsupported (non-node, size < 2 spawn failure). + * Worker crashes after startup permanently fall back to in-process transforms for pending and + * future calls — behavior stays identical, only isolation is lost. + */ +export async function createTransformWorkerPool(size: number): Promise { + if (!isNode || size < 1) { + return null; + } + let wt: typeof import('node:worker_threads'); + try { + wt = await import('node:worker_threads'); + } catch { + return null; + } + + interface PoolWorker { + worker: import('node:worker_threads').Worker; + inflight: number; + } + + const pending = new Map< + number, + { + opts: NapiTransformModulesOptions; + resolve: (output: NapiTransformOutput) => void; + reject: (error: Error) => void; + holder: PoolWorker; + } + >(); + let nextId = 0; + let broken = false; + const workers: PoolWorker[] = []; + + const runInProcess = (opts: NapiTransformModulesOptions): Promise => { + try { + return Promise.resolve(runTransform(opts)); + } catch (err) { + return Promise.reject(err instanceof Error ? err : new Error(String(err))); + } + }; + + /** A worker died: re-run its in-flight jobs in-process and stop using the pool. */ + const breakPool = (holder: PoolWorker) => { + broken = true; + for (const [id, job] of pending) { + if (job.holder === holder) { + pending.delete(id); + runInProcess(job.opts).then(job.resolve, job.reject); + } + } + }; + + try { + for (let i = 0; i < size; i++) { + const worker = new wt.Worker(new URL(import.meta.url), { + workerData: { [WORKER_FLAG]: true }, + }); + const holder: PoolWorker = { worker, inflight: 0 }; + worker.on('message', (msg: WorkerResponse) => { + const job = pending.get(msg.id); + if (!job) { + return; + } + pending.delete(msg.id); + holder.inflight--; + if (holder.inflight === 0) { + holder.worker.unref(); + } + if (msg.error !== undefined) { + job.reject(new Error(msg.error)); + } else { + job.resolve(msg.output!); + } + }); + worker.on('error', () => breakPool(holder)); + worker.on('exit', () => { + if (!broken && holder.inflight > 0) { + breakPool(holder); + } + }); + // After listener setup: attaching 'message' re-refs a worker, so unref + // last or the pool keeps the host process alive. + worker.unref(); + workers.push(holder); + } + } catch { + await Promise.all(workers.map((w) => w.worker.terminate())); + return null; + } + + return { + transformModules(opts) { + // Pre-parsed ASTs are large; cloning them defeats the purpose. + const hasPreparsedAst = opts.input?.some((input) => input.program || input.module) ?? false; + if (broken || hasPreparsedAst) { + return runInProcess(opts); + } + const holder = workers.reduce((a, b) => (b.inflight < a.inflight ? b : a)); + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { opts, resolve, reject, holder }); + holder.inflight++; + // A busy worker must keep the host loop alive until it responds. + if (holder.inflight === 1) { + holder.worker.ref(); + } + holder.worker.postMessage({ id, opts } satisfies WorkerRequest); + }); + }, + async dispose() { + broken = true; + await Promise.all(workers.map((w) => w.worker.terminate())); + }, + }; +} + +let sharedPool: Promise | undefined; + +/** + * Process-wide shared pool for default-configured optimizers. Bundlers create one optimizer per + * plugin instance (one per build); sharing keeps the worker count flat when many builds run in one + * process. Never disposed — workers are unref'd and die with the process. + */ +export function getSharedTransformPool(size: number): Promise { + sharedPool ??= createTransformWorkerPool(size); + return sharedPool; +} + +/** Worker-side bootstrap: runs the message loop when loaded with the pool's workerData flag. */ +async function maybeStartWorkerLoop(): Promise { + if (!isNode) { + return; + } + let wt: typeof import('node:worker_threads'); + try { + wt = await import('node:worker_threads'); + } catch { + return; + } + if (wt.isMainThread || !wt.parentPort) { + return; + } + if ((wt.workerData as Record | null)?.[WORKER_FLAG] !== true) { + return; + } + const port = wt.parentPort; + port.on('message', ({ id, opts }: WorkerRequest) => { + try { + port.postMessage({ id, output: runTransform(opts) } satisfies WorkerResponse); + } catch (err) { + const error = err instanceof Error ? (err.stack ?? err.message) : String(err); + port.postMessage({ id, error } satisfies WorkerResponse); + } + }); +} + +void maybeStartWorkerLoop(); diff --git a/packages/ts-optimizer/tests/README.md b/packages/ts-optimizer/tests/README.md new file mode 100644 index 00000000000..b06f23d6a97 --- /dev/null +++ b/packages/ts-optimizer/tests/README.md @@ -0,0 +1,85 @@ +# Test Suite + +## Overview + +The optimizer has two types of tests: + +1. **Unit tests** (`tests/optimizer/*.test.ts`) — test individual functions and behaviors +2. **Convergence tests** (`tests/optimizer/convergence.test.ts`) — compare our TS output against the SWC (Rust) optimizer's golden snapshots + +## Snapshot Directories + +### `../../optimizer/core/src/snapshots/` — The Source of Truth + +The live snapshot files produced by the **Rust optimizer** test suite, read directly from the `@qwik.dev/optimizer` package (path exported as `SNAP_DIR` from `tests/rust-snapshots.ts`). These are in Rust's [insta](https://insta.rs/) snapshot format with YAML frontmatter. + +Each snapshot contains: + +- `==INPUT==` — the original Qwik source code +- Parent module output — the rewritten source with `$()` calls replaced by QRL references +- Segment modules — the extracted lazy-loadable closures with metadata (name, hash, origin, captures) +- `==DIAGNOSTICS==` — any warnings/errors (C02, C05, etc.) + +**These are the target.** Because the tests read the Rust snapshots in place, any `pnpm test.rust.update` run immediately becomes the new convergence target — there is no vendored copy to refresh. + +### `ts-output/` — What We Currently Produce + +**Not committed to git** (gitignored). **Automatically regenerated** every time the convergence tests run. Each snapshot with an `==INPUT==` section gets transformed and written here (`relative_paths` has no input section). The convergence test compares against the Rust snapshots, not `ts-output/` — this directory is for human inspection only. + +## How Convergence Tests Work + +``` +optimizer/core/src/snapshots/*.snap → parseSnapshot() → extract INPUT + ↓ + transformModule() + ↓ + compareAst(expected, actual) +``` + +1. Read a golden snapshot from the Rust snapshot directory +2. Parse it to extract the INPUT code and expected outputs +3. Look up per-snapshot options in `tests/optimizer/snapshot-options.ts` +4. Run `transformModule()` with those options +5. Compare actual vs expected using **AST comparison** (ignores formatting, compares structure) +6. Compare segment metadata (hash, origin, ctxName, captures) as exact matches + +### Per-Snapshot Options + +Most snapshots use defaults, but ~40 have custom options defined in `snapshot-options.ts`: + +```typescript +// Default options +{ + transpileTs: false, + transpileJsx: false, + mode: 'lib', + entryStrategy: { type: 'segment' }, + minify: 'simplify', + filename: 'test.tsx', + srcDir: '/user/qwik/src/', +} +``` + +Overrides handle cases like dev mode, inline entry strategy, TypeScript transpilation, custom filenames, etc. + +## Running Tests + +```bash +# Run convergence tests (main command — also regenerates ts-output/) +pnpm vitest convergence + +# Run all tests +pnpm vitest run + +# Run a specific snapshot test +pnpm vitest run -t "example_1" + +# Run benchmarks (requires local Qwik repo at ~/dev/open-source/qwik) +BENCH=1 pnpm vitest run tests/benchmark/optimizer-benchmark.test.ts --no-file-parallelism +``` + +## Current State + +The convergence tests are a **measurement tool**, not a parity gate. They fail on transform errors and report how many snapshots strictly match or diverge. + +Check the test output for the current pass rate — it's printed as a summary at the end of the convergence test run. diff --git a/packages/ts-optimizer/tests/benchmark/optimizer-benchmark.test.ts b/packages/ts-optimizer/tests/benchmark/optimizer-benchmark.test.ts new file mode 100644 index 00000000000..bd6d089f786 --- /dev/null +++ b/packages/ts-optimizer/tests/benchmark/optimizer-benchmark.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { relative } from 'node:path'; +import { transformModule } from '../../src/optimizer/transform/index.js'; +import type { TransformModuleInput } from '../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../src/optimizer/types/brands.js'; +import * as process from 'node:process'; + +const QWIK_HOME = process.env.QWIK_HOME; + +const MONOREPO_RATIO_LIMIT = 1.15; +const SINGLE_FILE_RATIO_LIMIT = 1.5; + +const WARMUP_RUNS = 1; +const MEASURED_RUNS = 2; + +if (!QWIK_HOME) { + describe.skip('BENCH-00: QWIK_HOME was not set, skipping benchmark tests.', () => {}); +} else { + const QWIK_BINDING_MAP: Record> = { + darwin: { + arm64: [{ platformArchABI: 'qwik.darwin-arm64.node' }], + x64: [{ platformArchABI: 'qwik.darwin-x64.node' }], + }, + win32: { + x64: [{ platformArchABI: 'qwik.win32-x64-msvc.node' }], + }, + linux: { + x64: [{ platformArchABI: 'qwik.linux-x64-gnu.node' }], + }, + }; + + function resolveQwikSwcBindingBasename(): string { + const override = process.env.QWIK_SWC_BINDING_OVERRIDE; + if (override !== undefined && override !== '') { + return override; + } + const { platform, arch } = process; + const byPlatform = QWIK_BINDING_MAP[platform]; + if (!byPlatform) { + throw new Error( + `Unsupported platform "${platform}" for Qwik SWC binding autodetect. Set QWIK_SWC_BINDING_OVERRIDE.` + ); + } + const triples = byPlatform[arch]; + if (!triples?.length) { + throw new Error( + `Unsupported architecture "${arch}" on "${platform}" for Qwik SWC binding autodetect. Set QWIK_SWC_BINDING_OVERRIDE.` + ); + } + return triples[0].platformArchABI; + } + + const QWIK_PACKAGES_DIR = `${QWIK_HOME}/packages`; + const WORST_CASE_FILE = `${QWIK_PACKAGES_DIR}/qwik/src/core/tests/component.spec.tsx`; + const qwikSwcBindingBasename = resolveQwikSwcBindingBasename(); + const qwikSwcBindingPath = `${QWIK_PACKAGES_DIR}/optimizer/bindings/${qwikSwcBindingBasename}`; + + const swcBinding = require(qwikSwcBindingPath); + + let discoveredFiles: string[] | null = null; + + function discoverFiles(): string[] { + if (discoveredFiles) { + return discoveredFiles; + } + + const output = execSync( + `find ${QWIK_PACKAGES_DIR} -type f \\( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \\) ! -path "*/node_modules/*" ! -path "*/dist/*" ! -path "*/.turbo/*"`, + { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 } + ); + + discoveredFiles = output + .split('\n') + .map((f) => f.trim()) + .filter((f) => f.length > 0); + + return discoveredFiles; + } + + function buildTsInput(files: string[]): TransformModuleInput[] { + return files.map((file) => ({ + path: mkFilePath(relative(QWIK_PACKAGES_DIR, file)), + code: mkSourceText(readFileSync(file, 'utf-8')), + })); + } + + function buildSwcOptions(input: Array<{ path: string; code: string }>) { + return { + input, + rootDir: QWIK_PACKAGES_DIR, + srcDir: QWIK_PACKAGES_DIR, + sourceMaps: false, + minify: 'none', + transpileTs: true, + transpileJsx: true, + preserveFilenames: false, + explicitExtensions: false, + stripEventHandlers: false, + isServer: false, + mode: 'lib', + entryStrategy: 'segment', + }; + } + + async function measureMinAsync(fn: () => Promise, runs: number): Promise { + let min = Infinity; + for (let i = 0; i < runs; i++) { + const start = performance.now(); + await fn(); + const elapsed = performance.now() - start; + if (elapsed < min) { + min = elapsed; + } + } + return min; + } + + function measureMinSync(fn: () => unknown, runs: number): number { + let min = Infinity; + for (let i = 0; i < runs; i++) { + const start = performance.now(); + fn(); + const elapsed = performance.now() - start; + if (elapsed < min) { + min = elapsed; + } + } + return min; + } + + const runBenchmarks = process.env['BENCH'] === '1'; + const describeFn = runBenchmarks ? describe : describe.skip; + + describeFn('BENCH-01: full monorepo benchmark', () => { + it('TS optimizer is within 1.15x of SWC wall time for full monorepo', async () => { + const files = discoverFiles(); + const tsInput = buildTsInput(files); + const swcInput = tsInput.map((f) => ({ path: f.path, code: f.code })); + const swcOptions = buildSwcOptions(swcInput); + + for (let i = 0; i < WARMUP_RUNS; i++) { + await swcBinding.transform_modules(swcOptions); + transformModule({ + input: tsInput, + srcDir: mkFilePath(QWIK_PACKAGES_DIR), + rootDir: QWIK_PACKAGES_DIR, + entryStrategy: { type: 'segment' }, + minify: 'simplify', + transpileTs: true, + transpileJsx: true, + preserveFilenames: false, + explicitExtensions: false, + sourceMaps: false, + }); + } + + const swcTime = await measureMinAsync( + () => swcBinding.transform_modules(swcOptions), + MEASURED_RUNS + ); + + const tsTime = measureMinSync( + () => + transformModule({ + input: tsInput, + srcDir: mkFilePath(QWIK_PACKAGES_DIR), + rootDir: QWIK_PACKAGES_DIR, + entryStrategy: { type: 'segment' }, + minify: 'simplify', + transpileTs: true, + transpileJsx: true, + preserveFilenames: false, + explicitExtensions: false, + sourceMaps: false, + }), + MEASURED_RUNS + ); + + const ratio = tsTime / swcTime; + + console.log(''); + console.log('=== Full Monorepo Benchmark ==='); + console.log(`Files: ${files.length}`); + console.log(`SWC time: ${swcTime.toFixed(0)}ms`); + console.log(`TS time: ${tsTime.toFixed(0)}ms`); + console.log(`Ratio: ${ratio.toFixed(2)}x (limit: ${MONOREPO_RATIO_LIMIT}x)`); + console.log(`Status: ${ratio <= MONOREPO_RATIO_LIMIT ? 'PASS' : 'FAIL'}`); + console.log(''); + + expect(ratio).toBeLessThanOrEqual(MONOREPO_RATIO_LIMIT); + }, 120_000); + }); + + describeFn('BENCH-02: worst-case single file benchmark', () => { + it('TS optimizer is within 1.5x of SWC for worst-case single file', async () => { + const code = readFileSync(WORST_CASE_FILE, 'utf-8'); + const filePath = relative(QWIK_PACKAGES_DIR, WORST_CASE_FILE); + const lineCount = code.split('\n').length; + + const tsInput: TransformModuleInput[] = [ + { path: mkFilePath(filePath), code: mkSourceText(code) }, + ]; + const swcInput = [{ path: filePath, code }]; + const swcOptions = buildSwcOptions(swcInput); + + for (let i = 0; i < WARMUP_RUNS; i++) { + await swcBinding.transform_modules(swcOptions); + transformModule({ + input: tsInput, + srcDir: mkFilePath(QWIK_PACKAGES_DIR), + rootDir: QWIK_PACKAGES_DIR, + entryStrategy: { type: 'segment' }, + minify: 'simplify', + transpileTs: true, + transpileJsx: true, + preserveFilenames: false, + explicitExtensions: false, + sourceMaps: false, + }); + } + + const swcTime = await measureMinAsync( + () => swcBinding.transform_modules(swcOptions), + MEASURED_RUNS + ); + + const tsTime = measureMinSync( + () => + transformModule({ + input: tsInput, + srcDir: mkFilePath(QWIK_PACKAGES_DIR), + rootDir: QWIK_PACKAGES_DIR, + entryStrategy: { type: 'segment' }, + minify: 'simplify', + transpileTs: true, + transpileJsx: true, + preserveFilenames: false, + explicitExtensions: false, + sourceMaps: false, + }), + MEASURED_RUNS + ); + + const ratio = tsTime / swcTime; + + console.log(''); + console.log(`=== Single File Benchmark (${WORST_CASE_FILE.split('/').pop()}) ===`); + console.log(`Lines: ${lineCount}`); + console.log(`SWC time: ${swcTime.toFixed(0)}ms`); + console.log(`TS time: ${tsTime.toFixed(0)}ms`); + console.log(`Ratio: ${ratio.toFixed(2)}x (limit: ${SINGLE_FILE_RATIO_LIMIT}x)`); + console.log(`Status: ${ratio <= SINGLE_FILE_RATIO_LIMIT ? 'PASS' : 'FAIL'}`); + console.log(''); + + expect(ratio).toBeLessThanOrEqual(SINGLE_FILE_RATIO_LIMIT); + }, 30_000); + }); +} diff --git a/packages/ts-optimizer/tests/benchmark/profile-deep.test.ts b/packages/ts-optimizer/tests/benchmark/profile-deep.test.ts new file mode 100644 index 00000000000..13c69229143 --- /dev/null +++ b/packages/ts-optimizer/tests/benchmark/profile-deep.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import * as process from 'node:process'; +import { transformModule } from '../../src/optimizer/transform/index.js'; +import type { TransformModuleInput, TransformOutput } from '../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../src/optimizer/types/brands.js'; +import { parseSnapshot } from '../../src/testing/snapshot-parser.js'; +import { SNAP_DIR } from '../rust-snapshots.js'; +const QWIK_HOME = process.env.QWIK_HOME; +const ITERATIONS = Number(process.env.PROFILE_ITERATIONS ?? '3'); + +interface Workload { + readonly label: string; + readonly input: TransformModuleInput; + readonly srcDir: string; +} + +function worstCaseWorkload(qwikHome: string): Workload { + const packagesDir = `${qwikHome}/packages`; + const file = `${packagesDir}/qwik/src/core/tests/component.spec.tsx`; + return { + label: `QWIK_HOME worst-case (${relative(packagesDir, file)})`, + input: { + path: mkFilePath(relative(packagesDir, file)), + code: mkSourceText(readFileSync(file, 'utf-8')), + }, + srcDir: packagesDir, + }; +} + +function largestFixtureWorkload(): Workload { + let best: { name: string; input: string } | undefined; + for (const snapFile of readdirSync(SNAP_DIR)) { + if (!snapFile.endsWith('.snap')) { + continue; + } + const parsed = parseSnapshot(readFileSync(join(SNAP_DIR, snapFile), 'utf-8')); + if (!parsed.input) { + continue; + } + if (best === undefined || parsed.input.length > best.input.length) { + best = { name: snapFile, input: parsed.input }; + } + } + if (!best) { + throw new Error('no snapshot fixture with an input section found'); + } + return { + label: `largest fixture input (${best.name}, ${best.input.length} bytes)`, + input: { path: mkFilePath('test.tsx'), code: mkSourceText(best.input) }, + srcDir: '/', + }; +} + +describe('profiling harness', () => { + it('drives repeated transformModule iterations over the profiling workload', () => { + const workload = QWIK_HOME ? worstCaseWorkload(QWIK_HOME) : largestFixtureWorkload(); + + const run = (): TransformOutput => + transformModule({ + input: [workload.input], + srcDir: mkFilePath(workload.srcDir), + rootDir: workload.srcDir, + entryStrategy: { type: 'segment' }, + minify: 'simplify', + transpileTs: true, + transpileJsx: true, + preserveFilenames: false, + explicitExtensions: false, + sourceMaps: false, + }); + + const warmup = run(); + expect(warmup.modules.length).toBeGreaterThan(1); + + const perIteration: number[] = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + run(); + perIteration.push(performance.now() - start); + } + + const total = perIteration.reduce((a, b) => a + b, 0); + const min = Math.min(...perIteration); + console.log( + `PROFILE ${workload.label}: iterations=${ITERATIONS} ` + + `total=${total.toFixed(0)}ms min=${min.toFixed(1)}ms ` + + `avg=${(total / ITERATIONS).toFixed(1)}ms` + ); + expect(perIteration.length).toBe(ITERATIONS); + }, 300_000); +}); diff --git a/packages/ts-optimizer/tests/create-optimizer.test.ts b/packages/ts-optimizer/tests/create-optimizer.test.ts new file mode 100644 index 00000000000..b57a2e9a646 --- /dev/null +++ b/packages/ts-optimizer/tests/create-optimizer.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { createOptimizer, type OptimizerSystem, type SystemEnvironment } from '../src/index.js'; + +describe('createOptimizer', () => { + it('resolves to an instance with transformModules + sys', async () => { + const optimizer = await createOptimizer(); + + expect(typeof optimizer.transformModules).toBe('function'); + expect(optimizer.sys).toBeDefined(); + expect(typeof optimizer.sys.cwd).toBe('function'); + expect(typeof optimizer.sys.path.join).toBe('function'); + }); + + it('round-trips a minimal $() input through transformModules', async () => { + const optimizer = await createOptimizer(); + const result = await optimizer.transformModules({ + srcDir: '/src', + input: [ + { + path: 'test.tsx', + code: "import { $ } from '@qwik.dev/core'; export const x = $(() => 1);", + }, + ], + }); + + expect(result.modules.length).toBe(2); + expect(result.diagnostics.length).toBe(0); + expect(result.isTypeScript).toBe(true); + expect(result.isJsx).toBe(true); + + const parent = result.modules.find((m) => m.segment === null); + const segment = result.modules.find((m) => m.segment !== null); + expect(parent).toBeDefined(); + expect(parent?.origPath).toBe('test.tsx'); + expect(parent?.isEntry).toBe(false); + expect(segment).toBeDefined(); + expect(segment?.origPath).toBeNull(); + expect(segment?.isEntry).toBe(true); + expect(segment?.segment?.name).toBeTruthy(); + }); + + it('emits mutable output arrays', async () => { + const optimizer = await createOptimizer(); + const result = await optimizer.transformModules({ + srcDir: '/src', + input: [{ path: 'noop.ts', code: 'export const x = 1;' }], + }); + + result.modules.push(result.modules[0]!); + expect(result.modules.length).toBe(2); + + const second = await optimizer.transformModules({ + srcDir: '/src', + input: [{ path: 'noop.ts', code: 'export const x = 1;' }], + }); + expect(second.modules.length).toBe(1); + }); + + it('preserves a custom sys when supplied via OptimizerOptions', async () => { + const customSys: OptimizerSystem = { + cwd: () => '/custom/cwd', + env: 'deno' satisfies SystemEnvironment, + os: 'custom-os', + dynamicImport: (p) => Promise.resolve({ marker: 'custom', path: p }), + strictDynamicImport: (p) => Promise.resolve({ marker: 'custom', path: p }), + path: { + resolve: (...parts) => parts.join('/'), + normalize: (p) => p, + isAbsolute: (p) => p.startsWith('/'), + join: (...parts) => parts.join('/'), + relative: (_from, to) => to, + dirname: (p) => p, + basename: (p) => p, + extname: () => '', + format: (o) => `${o.dir}/${o.base}`, + parse: (p) => ({ root: '', dir: '', base: p, ext: '', name: p }), + sep: '/', + delimiter: ':', + win32: null, + get posix(): OptimizerSystem['path'] { + return customSys.path; + }, + }, + }; + + const optimizer = await createOptimizer({ sys: customSys }); + expect(optimizer.sys).toBe(customSys); + expect(optimizer.sys.cwd()).toBe('/custom/cwd'); + expect(optimizer.sys.env).toBe('deno'); + }); + + it('ignores passthrough OptimizerOptions fields without throwing', async () => { + const optimizer = await createOptimizer({ + binding: { fake: 'napi-binding' }, + inlineStylesUpToBytes: 4096, + sourcemap: true, + _optimizer: { swc: true }, + }); + + expect(typeof optimizer.transformModules).toBe('function'); + expect(optimizer.sys.env).toBe('node'); + }); + + it('default sys.path delegates to pathe (Node-compatible)', async () => { + const { sys } = await createOptimizer(); + expect(sys.path.join('/a', 'b', 'c')).toBe('/a/b/c'); + expect(sys.path.isAbsolute('/foo')).toBe(true); + expect(sys.path.extname('foo.tsx')).toBe('.tsx'); + expect(sys.path.sep).toBe('/'); + expect(sys.path.posix).toBe(sys.path); + }); + + it('exposes a Promise-shaped surface that the bundler `await`s through', async () => { + // Mirrors qwik-bundler/src/rolldown.ts:354 shape: + // const result = await (await getOptimizer()).transformModules(opts); + const factoryResult = createOptimizer(); + expect(factoryResult).toBeInstanceOf(Promise); + + const optimizer = await factoryResult; + const transformResult = optimizer.transformModules({ + srcDir: '/src', + input: [ + { + path: 'noop.ts', + code: 'export const x = 1;', + }, + ], + }); + expect(transformResult).toBeInstanceOf(Promise); + await expect(transformResult).resolves.toBeDefined(); + }); +}); diff --git a/packages/ts-optimizer/tests/fixtures/qwik-router-lib-snippet.mjs b/packages/ts-optimizer/tests/fixtures/qwik-router-lib-snippet.mjs new file mode 100644 index 00000000000..eb670a3cad1 --- /dev/null +++ b/packages/ts-optimizer/tests/fixtures/qwik-router-lib-snippet.mjs @@ -0,0 +1,2119 @@ +import { + componentQrl, + inlinedQrl, + useErrorBoundary, + useOnWindow, + _captures, + _jsxSorted, + Slot, + useSignal, + untrack, + _qrlSync, + useVisibleTaskQrl, + _jsxSplit, + _getConstProps, + _getVarProps, + isDev, + eventQrl, + isServer, + useStylesQrl, + useServerData, + useStore, + useContextProvider, + useTaskQrl, + getLocale, + noSerialize, + isBrowser, + useContext, + SkipRender, + implicit$FirstArg, + withLocale, + _wrapProp, + _restProps, + _fnSignal, + createElement, +} from '@qwik.dev/core'; +import { Fragment } from '@qwik.dev/core/jsx-runtime'; +import { + g as getClientNavPath, + s as shouldPreload, + p as preloadRouteBundles, + l as loadClientData, + i as isSamePath, + c as createLoaderSignal, + a as createDocumentHead, + b as loadRoute, + r as resolveHead, + C as CLIENT_DATA_CACHE, + d as isSameOrigin, + Q as Q_ROUTE, + D as DEFAULT_LOADERS_SERIALIZATION_STRATEGY, + t as toUrl, + e as clientNavigate, + f as toPath, + h as QFN_KEY, + j as QACTION_KEY, + k as QDATA_KEY, +} from './chunks/routing.qwik.mjs'; +import { + u as useNavigate, + a as useLocation, + b as useQwikRouterEnv, + C as ContentContext, + c as ContentInternalContext, + D as DocumentHeadContext, + H as HttpStatusContext, + R as RouteLocationContext, + d as RouteNavigateContext, + e as RouteStateContext, + f as RouteActionContext, + g as RoutePreventNavigateContext, + h as useAction, + i as useDocumentHead, +} from './chunks/use-functions.qwik.mjs'; +export { + j as useContent, + k as useHttpStatus, + l as usePreventNavigate$, + m as usePreventNavigateQrl, +} from './chunks/use-functions.qwik.mjs'; +import * as qwikRouterConfig from '@qwik-router-config'; +import { + _getContextContainer, + SerializerSymbol, + _UNINITIALIZED, + _hasStoreEffects, + forceStoreEffects, + _waitUntilRendered, + _getContextHostElement, + _getContextEvent, + _serialize, + _deserialize, + _resolveContextWithoutSequentialScope, +} from '@qwik.dev/core/internal'; +import { _asyncRequestStore } from '@qwik.dev/router/middleware/request-handler'; +import * as v from 'valibot'; +import * as z from 'zod'; +export { z } from 'zod'; +import swRegister from '@qwik-router-sw-register'; +import { renderToStream } from '@qwik.dev/core/server'; + +const ErrorBoundary = /* @__PURE__ */ componentQrl( + /* @__PURE__ */ inlinedQrl((props) => { + const store = useErrorBoundary(); + useOnWindow( + 'qerror', + /* @__PURE__ */ inlinedQrl( + (e) => { + const store2 = _captures[0]; + store2.error = e.detail.error; + }, + 'ErrorBoundary_component_useOnWindow_G0jFRpoNY0M', + [store] + ) + ); + if (store.error && props.fallback$) { + return /* @__PURE__ */ _jsxSorted( + Fragment, + null, + null, + props.fallback$(store.error), + 1, + 'bA_0' + ); + } + return /* @__PURE__ */ _jsxSorted(Slot, null, null, null, 3, 'bA_1'); + }, 'ErrorBoundary_component_pOa6vjtC7ik') +); + +const __vite_import_meta_env__ = {}; +const Link = /* @__PURE__ */ componentQrl( + /* @__PURE__ */ inlinedQrl((props) => { + const nav = useNavigate(); + const loc = useLocation(); + const originalHref = props.href; + const anchorRef = useSignal(); + const { + onClick$, + prefetch: prefetchProp, + reload, + replaceState, + scroll, + prefetchBundle: prefetchBundleProp = 'visible', + prefetchData: prefetchDataProp = prefetchProp === 'js' ? 'off' : 'intent', + ...linkProps + } = props; + const clientNavPath = untrack( + getClientNavPath, + { + ...linkProps, + reload, + }, + loc + ); + linkProps.href = clientNavPath || originalHref; + const isDepratedPrefetchDisabled = prefetchProp === false; + const shouldPrefetch = untrack(shouldPreload, clientNavPath, loc); + const shouldPrefetchBundle = + !!clientNavPath && + prefetchBundleProp !== 'off' && + shouldPrefetch && + !isDepratedPrefetchDisabled; + const shouldPrefetchData = + !!clientNavPath && + prefetchDataProp !== 'off' && + shouldPrefetch && + !isDepratedPrefetchDisabled; + const handleBundlePrefetch = shouldPrefetchBundle + ? /* @__PURE__ */ inlinedQrl((_, elm) => { + if (navigator.connection?.saveData) { + return; + } + if (elm && elm.href) { + const url = new URL(elm.href); + preloadRouteBundles(url.pathname); + } + }, 'Link_component_handleBundlePrefetch_ZttUP0kLKj0') + : null; + const handleDataPrefetch = shouldPrefetchData + ? /* @__PURE__ */ inlinedQrl((_, elm) => { + if (navigator.connection?.saveData) { + return; + } + if (elm && elm.href) { + const url = new URL(elm.href); + loadClientData(url, { + preloadRouteBundles: false, + isPrefetch: true, + }); + } + }, 'Link_component_handleDataPrefetch_lcd4O9LHbbA') + : null; + const preventDefault = clientNavPath + ? _qrlSync((event) => { + if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { + event.preventDefault(); + } + }, 'event=>{if(!(event.metaKey||event.ctrlKey||event.shiftKey||event.altKey)){event.preventDefault();}}') + : null; + const prefetchData = /* @__PURE__ */ inlinedQrl( + async (_, elm) => { + const handleDataPrefetch2 = _captures[0]; + handleDataPrefetch2?.(null, elm); + }, + 'Link_component_prefetchData_UwVx4NhHkSo', + [handleDataPrefetch] + ); + const onEnterKeyDown = /* @__PURE__ */ inlinedQrl( + (event, element) => { + const prefetchData2 = _captures[0]; + if (event.key === 'Enter') { + prefetchData2(null, element); + } + }, + 'Link_component_onEnterKeyDown_z274ws00irY', + [prefetchData] + ); + const handleClientSideNavigation = clientNavPath + ? /* @__PURE__ */ inlinedQrl( + (event, elm) => { + const nav2 = _captures[0], + reload2 = _captures[1], + replaceState2 = _captures[2], + scroll2 = _captures[3]; + if (event.defaultPrevented) { + if (elm.href) { + elm.setAttribute('aria-pressed', 'true'); + nav2(elm.href, { + forceReload: reload2, + replaceState: replaceState2, + scroll: scroll2, + }).then(() => { + elm.removeAttribute('aria-pressed'); + }); + } + } + }, + 'Link_component_handleClientSideNavigation_h3qenoGeI6M', + [nav, reload, replaceState, scroll] + ) + : null; + const handlePreload = /* @__PURE__ */ inlinedQrl((_, elm) => { + const url = new URL(elm.href); + preloadRouteBundles(url.pathname, 1); + }, 'Link_component_handlePreload_AAemwtuBjsE'); + useVisibleTaskQrl( + /* @__PURE__ */ inlinedQrl( + ({ track }) => { + const anchorRef2 = _captures[0], + handleBundlePrefetch2 = _captures[1], + handleDataPrefetch2 = _captures[2], + linkProps2 = _captures[3], + loc2 = _captures[4], + prefetchBundleProp2 = _captures[5], + prefetchDataProp2 = _captures[6], + prefetchProp2 = _captures[7]; + track(() => loc2.url.pathname); + const handler = linkProps2.onQVisible$; + if (handler) { + const event = new CustomEvent('qvisible'); + if (Array.isArray(handler)) { + handler.flat(10).forEach((handler2) => handler2?.(event, anchorRef2.value)); + } else { + handler?.(event, anchorRef2.value); + } + } + const isProdOrTest = !isDev || __vite_import_meta_env__?.TEST; + if (isProdOrTest && anchorRef2.value) { + if ( + prefetchBundleProp2 === 'visible' || // deprecated prop below, remove in favor of prefetchBundle + prefetchProp2 === 'js' || + prefetchProp2 === true + ) { + handleBundlePrefetch2?.(null, anchorRef2.value); + } + if ( + prefetchDataProp2 === 'visible' || // deprecated prop below, remove in favor of prefetchData + prefetchProp2 === true + ) { + handleDataPrefetch2?.(null, anchorRef2.value); + } + } + }, + 'Link_component_useVisibleTask_xKeuRmnoNSA', + [ + anchorRef, + handleBundlePrefetch, + handleDataPrefetch, + linkProps, + loc, + prefetchBundleProp, + prefetchDataProp, + prefetchProp, + ] + ) + ); + return /* @__PURE__ */ _jsxSplit( + 'a', + { + ref: anchorRef, + 'q:link': !!clientNavPath, + ..._getVarProps(linkProps), + ..._getConstProps(linkProps), + 'q-e:click': [preventDefault, handlePreload, onClick$, handleClientSideNavigation], + 'q-e:pointerenter': [ + linkProps.onMouseOver$, + prefetchDataProp === 'intent' ? prefetchData : null, + ], + 'q-e:focus': [linkProps.onFocus$, prefetchDataProp === 'intent' ? prefetchData : null], + 'q-e:pointerdown': [ + linkProps.onPointerDown$, + prefetchDataProp === 'commit' ? prefetchData : null, + ], + 'q-e:keydown': [ + linkProps.onKeyDown$, + prefetchDataProp === 'commit' ? onEnterKeyDown : null, + ], + }, + { + // We need to prevent the onQVisible$ from being called twice since it is handled in the visible task + 'q-e:qvisible': [], + }, + /* @__PURE__ */ _jsxSorted(Slot, null, null, null, 3, 'jO_0'), + 0, + 'jO_1' + ); + }, 'Link_component_VPmar9tb3t4') +); + +const transitionCss = + '@layer qwik{@supports selector(html:active-view-transition-type(type)){html:active-view-transition-type(qwik-navigation){:root{view-transition-name:none}}}@supports not selector(html:active-view-transition-type(type)){:root{view-transition-name:none}}}'; + +function callRestoreScrollOnDocument() { + if (document.__q_scroll_restore__) { + document.__q_scroll_restore__(); + document.__q_scroll_restore__ = void 0; + } +} +const hashScroll = (toUrl, fromUrl) => { + const elmId = toUrl.hash.slice(1); + const elm = elmId && document.getElementById(elmId); + if (elm) { + elm.scrollIntoView(); + return true; + } else if (!elm && toUrl.hash && isSamePath(toUrl, fromUrl)) { + return true; + } + return false; +}; +const restoreScroll = (type, toUrl, fromUrl, scroller, scrollState) => { + if (type === 'popstate' && scrollState) { + scroller.scrollTo(scrollState.x, scrollState.y); + } else if (type === 'link' || type === 'form') { + if (!hashScroll(toUrl, fromUrl)) { + scroller.scrollTo(0, 0); + } + } +}; +const currentScrollState = (elm) => { + return { + x: elm.scrollLeft, + y: elm.scrollTop, + w: Math.max(elm.scrollWidth, elm.clientWidth), + h: Math.max(elm.scrollHeight, elm.clientHeight), + }; +}; +const getScrollHistory = () => { + const state = history.state; + return state?._qRouterScroll; +}; +const saveScrollHistory = (scrollState) => { + const state = history.state || {}; + state._qRouterScroll = scrollState; + history.replaceState(state, ''); +}; + +const Q_ROUTER_POPSTATE_EVENT = 'qrouterpopstate'; +const createCurrentPathTracker = (initialPath) => { + let currentPath = initialPath; + return (nextPath) => { + if (currentPath !== nextPath) { + currentPath = nextPath; + return true; + } + return false; + }; +}; +const dispatchRouterPopstate = (document1, href) => { + document1.dispatchEvent( + new CustomEvent(Q_ROUTER_POPSTATE_EVENT, { + detail: { + href, + }, + }) + ); +}; +const spaInit = eventQrl( + /* @__PURE__ */ inlinedQrl((_, el) => { + if (!window._qRouterSPA && !window._qRouterInitPopstate) { + const hasPathChanged = createCurrentPathTracker(location.pathname + location.search); + const checkAndScroll = (scrollState) => { + if (scrollState) { + window.scrollTo(scrollState.x, scrollState.y); + } + }; + const currentScrollState = () => { + const elm = document.documentElement; + return { + x: elm.scrollLeft, + y: elm.scrollTop, + w: Math.max(elm.scrollWidth, elm.clientWidth), + h: Math.max(elm.scrollHeight, elm.clientHeight), + }; + }; + const saveScrollState = (scrollState) => { + const state = history.state || {}; + state._qRouterScroll = scrollState || currentScrollState(); + history.replaceState(state, ''); + }; + saveScrollState(); + window._qRouterInitPopstate = () => { + if (window._qRouterSPA) { + return; + } + window._qRouterScrollEnabled = false; + clearTimeout(window._qRouterScrollDebounce); + if (hasPathChanged(location.pathname + location.search)) { + dispatchRouterPopstate(document, location.href); + } else { + if (history.scrollRestoration === 'manual') { + const scrollState = history.state?._qRouterScroll; + checkAndScroll(scrollState); + window._qRouterScrollEnabled = true; + } + } + }; + if (!window._qRouterHistoryPatch) { + window._qRouterHistoryPatch = true; + const pushState = history.pushState; + const replaceState = history.replaceState; + const prepareState = (state) => { + if (state === null || typeof state === 'undefined') { + state = {}; + } else if (state?.constructor !== Object) { + state = { + _data: state, + }; + if (isDev) { + console.warn( + 'In a Qwik SPA context, `history.state` is used to store scroll state. Direct calls to `pushState()` and `replaceState()` must supply an actual Object type. We need to be able to automatically attach the scroll state to your state object. A new state object has been created, your data has been moved to: `history.state._data`' + ); + } + } + state._qRouterScroll = state._qRouterScroll || currentScrollState(); + return state; + }; + history.pushState = (state, title, url) => { + state = prepareState(state); + return pushState.call(history, state, title, url); + }; + history.replaceState = (state, title, url) => { + state = prepareState(state); + return replaceState.call(history, state, title, url); + }; + } + window._qRouterInitAnchors = (event) => { + if (window._qRouterSPA || event.defaultPrevented) { + return; + } + const target = event.target.closest('a[href]'); + if (target && !target.hasAttribute('preventdefault:click')) { + const href = target.getAttribute('href'); + const prev = new URL(location.href); + const dest = new URL(href, prev); + const sameOrigin = dest.origin === prev.origin; + const samePath = dest.pathname + dest.search === prev.pathname + prev.search; + if (sameOrigin && samePath) { + event.preventDefault(); + if (dest.href !== prev.href) { + history.pushState(null, '', dest); + } + if (!dest.hash) { + if (dest.href.endsWith('#')) { + window.scrollTo(0, 0); + } else { + window._qRouterScrollEnabled = false; + clearTimeout(window._qRouterScrollDebounce); + saveScrollState({ + ...currentScrollState(), + x: 0, + y: 0, + }); + location.reload(); + } + } else { + const elmId = dest.hash.slice(1); + const elm = document.getElementById(elmId); + if (elm) { + elm.scrollIntoView(); + } + } + } + } + }; + window._qRouterInitVisibility = () => { + if ( + !window._qRouterSPA && + window._qRouterScrollEnabled && + document.visibilityState === 'hidden' + ) { + saveScrollState(); + } + }; + window._qRouterInitScroll = () => { + if (window._qRouterSPA || !window._qRouterScrollEnabled) { + return; + } + clearTimeout(window._qRouterScrollDebounce); + window._qRouterScrollDebounce = setTimeout(() => { + saveScrollState(); + window._qRouterScrollDebounce = void 0; + }, 200); + }; + window._qRouterScrollEnabled = true; + setTimeout(() => { + window.addEventListener('popstate', window._qRouterInitPopstate); + window.addEventListener('scroll', window._qRouterInitScroll, { + passive: true, + }); + document.addEventListener('click', window._qRouterInitAnchors); + if (!window.navigation) { + document.addEventListener('visibilitychange', window._qRouterInitVisibility, { + passive: true, + }); + } + }, 0); + } + }, 'spa_init_event_igI1pUsax0E') +); + +const startViewTransition = (params) => { + if (!params.update) { + return; + } + if ('startViewTransition' in document) { + let transition; + try { + transition = document.startViewTransition(params); + } catch { + transition = document.startViewTransition(params.update); + } + const event = new CustomEvent('qviewtransition', { + detail: transition, + }); + document.dispatchEvent(event); + return transition; + } else { + params.update?.(); + } +}; + +const QWIK_CITY_SCROLLER = '_qCityScroller'; +const QWIK_ROUTER_SCROLLER = '_qRouterScroller'; +const preventNav = {}; +const internalState = { + navCount: 0, +}; +const useQwikRouter = (props) => { + if (!isServer) { + throw new Error( + 'useQwikRouter can only run during SSR on the server. If you are seeing this, it means you are re-rendering the root of your application. Fix that or use the component around the root of your application.' + ); + } + useStylesQrl( + /* @__PURE__ */ inlinedQrl(transitionCss, 'qwik_view_transition_css_inline_vNfd9raIMI0') + ); + const env = useQwikRouterEnv(); + if (!env?.params) { + throw new Error( + `Missing Qwik Router Env Data for help visit https://github.com/QwikDev/qwik/issues/6237` + ); + } + const urlEnv = useServerData('url'); + if (!urlEnv) { + throw new Error(`Missing Qwik URL Env Data`); + } + const serverHead = useServerData('documentHead'); + const manifestHash = useServerData('containerAttributes')?.['q:manifest-hash']; + const url = new URL(urlEnv); + const routeLocationTarget = { + url, + params: env.params, + isNavigating: false, + prevUrl: void 0, + }; + const routeLocation = useStore(routeLocationTarget, { + deep: false, + }); + const navResolver = {}; + const container = _getContextContainer(); + const getSerializationStrategy = (loaderId) => { + return ( + env.response.loadersSerializationStrategy.get(loaderId) || + DEFAULT_LOADERS_SERIALIZATION_STRATEGY + ); + }; + const loadersObject = {}; + const loaderState = {}; + for (const [key, value] of Object.entries(env.response.loaders)) { + loadersObject[key] = value; + loaderState[key] = createLoaderSignal( + loadersObject, + key, + url, + getSerializationStrategy(key), + container + ); + } + loadersObject[SerializerSymbol] = (obj) => { + const loadersSerializationObject = {}; + for (const [k, v] of Object.entries(obj)) { + loadersSerializationObject[k] = getSerializationStrategy(k) === 'always' ? v : _UNINITIALIZED; + } + return loadersSerializationObject; + }; + const routeInternal = useSignal({ + type: 'initial', + dest: url, + scroll: true, + }); + const documentHead = useStore(() => createDocumentHead(serverHead, manifestHash)); + const content = useStore({ + headings: void 0, + menu: void 0, + }); + const contentInternal = useSignal(); + const httpStatus = useSignal({ + status: env.response.status, + message: env.loadedRoute.$notFound$ ? 'Not Found' : (env.response.statusMessage ?? ''), + }); + const currentActionId = env.response.action; + const currentAction = currentActionId ? env.response.loaders[currentActionId] : void 0; + const actionState = useSignal( + currentAction + ? { + id: currentActionId, + data: env.response.formData, + output: { + result: currentAction, + status: env.response.status, + }, + } + : void 0 + ); + const registerPreventNav = /* @__PURE__ */ inlinedQrl((fn$) => { + if (!isBrowser) { + return; + } + preventNav.$handler$ ||= (event) => { + internalState.navCount++; + if (!preventNav.$cbs$) { + return; + } + const prevents = [...preventNav.$cbs$.values()].map((cb) => + cb.resolved ? cb.resolved() : cb() + ); + if (prevents.some(Boolean)) { + event.preventDefault(); + event.returnValue = true; + } + }; + (preventNav.$cbs$ ||= /* @__PURE__ */ new Set()).add(fn$); + fn$.resolve(); + window.addEventListener('beforeunload', preventNav.$handler$); + return () => { + if (preventNav.$cbs$) { + preventNav.$cbs$.delete(fn$); + if (!preventNav.$cbs$.size) { + preventNav.$cbs$ = void 0; + window.removeEventListener('beforeunload', preventNav.$handler$); + } + } + }; + }, 'useQwikRouter_registerPreventNav_69B0DK0eZJc'); + const getScroller = /* @__PURE__ */ inlinedQrl(() => { + let scroller = document.getElementById(QWIK_ROUTER_SCROLLER); + if (!scroller) { + scroller = document.getElementById(QWIK_CITY_SCROLLER); + if (scroller && isDev) { + console.warn( + `Please update your scroller ID to "${QWIK_ROUTER_SCROLLER}" as "${QWIK_CITY_SCROLLER}" is deprecated and will be removed in V3` + ); + } + } + return scroller ?? document.documentElement; + }, 'useQwikRouter_getScroller_0UhDFwlxeFQ'); + const goto = /* @__PURE__ */ inlinedQrl( + async (path, opt) => { + const actionState2 = _captures[0], + getScroller2 = _captures[1], + navResolver2 = _captures[2], + routeInternal2 = _captures[3], + routeLocation2 = _captures[4]; + const { + type = 'link', + forceReload = path === void 0, + replaceState = false, + scroll = true, + } = typeof opt === 'object' + ? opt + : { + forceReload: opt, + }; + internalState.navCount++; + if (isBrowser && type === 'link' && routeInternal2.value.type === 'initial') { + const url2 = new URL(window.location.href); + routeInternal2.value.dest = url2; + routeLocation2.url = url2; + } + const lastDest = routeInternal2.value.dest; + const dest = + path === void 0 + ? lastDest + : typeof path === 'number' + ? path + : toUrl(path, routeLocation2.url); + if ( + preventNav.$cbs$ && + (forceReload || + typeof dest === 'number' || + !isSamePath(dest, lastDest) || + !isSameOrigin(dest, lastDest)) + ) { + const ourNavId = internalState.navCount; + const prevents = await Promise.all([...preventNav.$cbs$.values()].map((cb) => cb(dest))); + if (ourNavId !== internalState.navCount || prevents.some(Boolean)) { + if (ourNavId === internalState.navCount && type === 'popstate') { + history.pushState(null, '', lastDest); + } + return; + } + } + if (typeof dest === 'number') { + if (isBrowser) { + history.go(dest); + } + return; + } + if (!isSameOrigin(dest, lastDest)) { + if (isBrowser) { + location.href = dest.href; + } + return; + } + if (!forceReload && isSamePath(dest, lastDest)) { + if (isBrowser) { + if (type === 'link' && dest.href !== location.href) { + history.pushState(null, '', dest); + } + const scroller = await getScroller2(); + restoreScroll(type, dest, new URL(location.href), scroller, getScrollHistory()); + if (type === 'popstate') { + window._qRouterScrollEnabled = true; + } + } + if (dest.href !== routeLocation2.url.href) { + const newUrl = new URL(dest.href); + routeInternal2.value.dest = newUrl; + routeLocation2.url = newUrl; + } + return; + } + let historyUpdated = false; + if (isBrowser && type === 'link' && !forceReload) { + const scroller = await getScroller2(); + window._qRouterScrollEnabled = false; + clearTimeout(window._qRouterScrollDebounce); + const scrollState = currentScrollState(scroller); + saveScrollHistory(scrollState); + clientNavigate(window, type, new URL(location.href), dest, replaceState); + historyUpdated = true; + } + routeInternal2.value = { + type, + dest, + forceReload, + replaceState, + scroll, + historyUpdated, + }; + if (isBrowser) { + loadClientData(dest); + loadRoute(qwikRouterConfig.routes, qwikRouterConfig.cacheModules, dest.pathname); + } + actionState2.value = void 0; + routeLocation2.isNavigating = true; + return new Promise((resolve) => { + navResolver2.r = resolve; + }); + }, + 'useQwikRouter_goto_8j8Vrz2yUIM', + [actionState, getScroller, navResolver, routeInternal, routeLocation] + ); + useContextProvider(ContentContext, content); + useContextProvider(ContentInternalContext, contentInternal); + useContextProvider(DocumentHeadContext, documentHead); + useContextProvider(HttpStatusContext, httpStatus); + useContextProvider(RouteLocationContext, routeLocation); + useContextProvider(RouteNavigateContext, goto); + useContextProvider(RouteStateContext, loaderState); + useContextProvider(RouteActionContext, actionState); + useContextProvider(RoutePreventNavigateContext, registerPreventNav); + useTaskQrl( + /* @__PURE__ */ inlinedQrl( + async ({ track }) => { + const actionState2 = _captures[0], + content2 = _captures[1], + contentInternal2 = _captures[2], + documentHead2 = _captures[3], + env2 = _captures[4], + getScroller2 = _captures[5], + goto2 = _captures[6], + httpStatus2 = _captures[7], + loaderState2 = _captures[8], + loadersObject2 = _captures[9], + navResolver2 = _captures[10], + props2 = _captures[11], + routeInternal2 = _captures[12], + routeLocation2 = _captures[13], + routeLocationTarget2 = _captures[14], + serverHead2 = _captures[15]; + const container2 = _getContextContainer(); + const navigation = track(routeInternal2); + const action = track(actionState2); + const locale = getLocale(''); + const prevUrl = routeLocation2.url; + const navType = action ? 'form' : navigation.type; + const replaceState = navigation.replaceState; + let trackUrl; + let clientPageData; + let loadedRoute; + if (isServer) { + trackUrl = new URL(navigation.dest, routeLocation2.url); + loadedRoute = env2.loadedRoute; + clientPageData = env2.response; + } else { + trackUrl = new URL(navigation.dest, location); + if (trackUrl.pathname.endsWith('/')) { + if (globalThis.__NO_TRAILING_SLASH__) { + trackUrl.pathname = trackUrl.pathname.slice(0, -1); + } + } else if (!globalThis.__NO_TRAILING_SLASH__) { + trackUrl.pathname += '/'; + } + let loadRoutePromise = loadRoute( + qwikRouterConfig.routes, + qwikRouterConfig.cacheModules, + trackUrl.pathname + ); + const pageData = (clientPageData = await loadClientData(trackUrl, { + action, + clearCache: true, + })); + if (!pageData) { + routeInternal2.untrackedValue = { + type: navType, + dest: trackUrl, + }; + return; + } + const newHref = pageData.href; + const newURL = new URL(newHref, trackUrl); + if (!isSamePath(newURL, trackUrl)) { + if (!pageData.isRewrite) { + trackUrl = newURL; + } + loadRoutePromise = loadRoute( + qwikRouterConfig.routes, + qwikRouterConfig.cacheModules, + newURL.pathname + // Load the actual required path. + ); + } + try { + loadedRoute = await loadRoutePromise; + } catch (e) { + console.error(e); + window.location.href = newHref; + return; + } + } + const { $routeName$, $params$, $mods$, $menu$, $notFound$ } = loadedRoute; + const contentModules = $mods$; + if ($notFound$) { + httpStatus2.value = { + status: 404, + message: 'Not Found', + }; + } else { + httpStatus2.value = { + status: clientPageData?.status ?? 200, + message: clientPageData?.statusMessage ?? '', + }; + } + const pageModule = contentModules[contentModules.length - 1]; + if (navigation.dest.search && !!isSamePath(trackUrl, prevUrl)) { + trackUrl.search = navigation.dest.search; + } + let shouldForcePrevUrl = false; + let shouldForceUrl = false; + let shouldForceParams = false; + if (!isSamePath(trackUrl, prevUrl)) { + if (_hasStoreEffects(routeLocation2, 'prevUrl')) { + shouldForcePrevUrl = true; + } + routeLocationTarget2.prevUrl = prevUrl; + } + if (routeLocationTarget2.url !== trackUrl) { + if (_hasStoreEffects(routeLocation2, 'url')) { + shouldForceUrl = true; + } + routeLocationTarget2.url = trackUrl; + } + if (routeLocationTarget2.params !== $params$) { + if (_hasStoreEffects(routeLocation2, 'params')) { + shouldForceParams = true; + } + routeLocationTarget2.params = $params$; + } + routeInternal2.untrackedValue = { + type: navType, + dest: trackUrl, + }; + const resolvedHead = resolveHead( + clientPageData, + routeLocation2, + contentModules, + locale, + serverHead2 + ); + content2.headings = pageModule.headings; + content2.menu = $menu$; + contentInternal2.untrackedValue = noSerialize(contentModules); + documentHead2.links = resolvedHead.links; + documentHead2.meta = resolvedHead.meta; + documentHead2.styles = resolvedHead.styles; + documentHead2.scripts = resolvedHead.scripts; + documentHead2.title = resolvedHead.title; + documentHead2.frontmatter = resolvedHead.frontmatter; + if (isBrowser) { + let scrollState; + if (navType === 'popstate') { + scrollState = getScrollHistory(); + } + const scroller = await getScroller2(); + if ( + (navigation.scroll && + (!navigation.forceReload || !isSamePath(trackUrl, prevUrl)) && + (navType === 'link' || navType === 'popstate')) || + (navType === 'form' && !isSamePath(trackUrl, prevUrl)) + ) { + document.__q_scroll_restore__ = () => + restoreScroll(navType, trackUrl, prevUrl, scroller, scrollState); + } + const loaders = clientPageData?.loaders; + if (loaders) { + for (const [key, value] of Object.entries(loaders)) { + const signal = loaderState2[key]; + const awaitedValue = await value; + loadersObject2[key] = awaitedValue; + if (!signal) { + loaderState2[key] = createLoaderSignal( + loadersObject2, + key, + trackUrl, + DEFAULT_LOADERS_SERIALIZATION_STRATEGY, + container2 + ); + } else { + signal.invalidate(); + } + } + } + CLIENT_DATA_CACHE.clear(); + if (!window._qRouterSPA) { + window._qRouterSPA = true; + history.scrollRestoration = 'manual'; + window.addEventListener('popstate', () => { + window._qRouterScrollEnabled = false; + clearTimeout(window._qRouterScrollDebounce); + goto2(location.href, { + type: 'popstate', + }); + }); + window.removeEventListener('popstate', window._qRouterInitPopstate); + window._qRouterInitPopstate = void 0; + if (!window._qRouterHistoryPatch) { + window._qRouterHistoryPatch = true; + const pushState = history.pushState; + const replaceState2 = history.replaceState; + const prepareState = (state) => { + if (state === null || typeof state === 'undefined') { + state = {}; + } else if (state?.constructor !== Object) { + state = { + _data: state, + }; + if (isDev) { + console.warn( + 'In a Qwik SPA context, `history.state` is used to store scroll state. Direct calls to `pushState()` and `replaceState()` must supply an actual Object type. We need to be able to automatically attach the scroll state to your state object. A new state object has been created, your data has been moved to: `history.state._data`' + ); + } + } + state._qRouterScroll = state._qRouterScroll || currentScrollState(scroller); + return state; + }; + history.pushState = (state, title, url2) => { + state = prepareState(state); + return pushState.call(history, state, title, url2); + }; + history.replaceState = (state, title, url2) => { + state = prepareState(state); + return replaceState2.call(history, state, title, url2); + }; + } + document.addEventListener('click', (event) => { + if (event.defaultPrevented) { + return; + } + const target = event.target.closest('a[href]'); + if (target && !target.hasAttribute('preventdefault:click')) { + const href = target.getAttribute('href'); + const prev = new URL(location.href); + const dest = new URL(href, prev); + if (isSameOrigin(dest, prev) && isSamePath(dest, prev)) { + event.preventDefault(); + if (!dest.hash && !dest.href.endsWith('#')) { + if (dest.href !== prev.href) { + history.pushState(null, '', dest); + } + window._qRouterScrollEnabled = false; + clearTimeout(window._qRouterScrollDebounce); + saveScrollHistory({ + ...currentScrollState(scroller), + x: 0, + y: 0, + }); + location.reload(); + return; + } + goto2(target.getAttribute('href')); + } + } + }); + document.removeEventListener('click', window._qRouterInitAnchors); + window._qRouterInitAnchors = void 0; + if (!window.navigation) { + document.addEventListener( + 'visibilitychange', + () => { + if ( + (window._qRouterScrollEnabled || window._qCityScrollEnabled) && + document.visibilityState === 'hidden' + ) { + if (window._qCityScrollEnabled) { + console.warn( + '"_qCityScrollEnabled" is deprecated. Use "_qRouterScrollEnabled" instead.' + ); + } + const scrollState2 = currentScrollState(scroller); + saveScrollHistory(scrollState2); + } + }, + { + passive: true, + } + ); + document.removeEventListener('visibilitychange', window._qRouterInitVisibility); + window._qRouterInitVisibility = void 0; + } + window.addEventListener( + 'scroll', + () => { + if (!window._qRouterScrollEnabled && !window._qCityScrollEnabled) { + return; + } + clearTimeout(window._qRouterScrollDebounce); + window._qRouterScrollDebounce = setTimeout(() => { + const scrollState2 = currentScrollState(scroller); + saveScrollHistory(scrollState2); + window._qRouterScrollDebounce = void 0; + }, 200); + }, + { + passive: true, + } + ); + removeEventListener('scroll', window._qRouterInitScroll); + window._qRouterInitScroll = void 0; + spaInit.resolve(); + } + if (navType !== 'popstate') { + window._qRouterScrollEnabled = false; + clearTimeout(window._qRouterScrollDebounce); + if (!navigation.historyUpdated) { + const scrollState2 = currentScrollState(scroller); + saveScrollHistory(scrollState2); + } + } + const navigate = () => { + if (navigation.historyUpdated) { + const currentPath = location.pathname + location.search + location.hash; + const nextPath = toPath(trackUrl); + if (currentPath !== nextPath) { + history.replaceState(history.state, '', nextPath); + } + } else { + clientNavigate(window, navType, prevUrl, trackUrl, replaceState); + } + contentInternal2.trigger(); + return _waitUntilRendered(container2); + }; + const _waitNextPage = () => { + if (isServer || props2?.viewTransition === false) { + return navigate(); + } else { + const viewTransition = startViewTransition({ + update: navigate, + types: ['qwik-navigation'], + }); + if (!viewTransition) { + return Promise.resolve(); + } + return viewTransition.ready; + } + }; + _waitNextPage() + .catch((err) => { + navigate(); + if (err instanceof DOMException && err.name === 'TimeoutError') { + throw new Error( + 'View transition timed out. This can happen if you have "disableCache" in your browser devtools enabled.' + ); + } + throw err; + }) + .finally(() => { + container2.element.setAttribute?.(Q_ROUTE, $routeName$); + const scrollState2 = currentScrollState(scroller); + saveScrollHistory(scrollState2); + window._qRouterScrollEnabled = true; + if (isBrowser) { + callRestoreScrollOnDocument(); + } + if (shouldForcePrevUrl) { + forceStoreEffects(routeLocation2, 'prevUrl'); + } + if (shouldForceUrl) { + forceStoreEffects(routeLocation2, 'url'); + } + if (shouldForceParams) { + forceStoreEffects(routeLocation2, 'params'); + } + routeLocation2.isNavigating = false; + navResolver2.r?.(); + }); + } + }, + 'useQwikRouter_useTask_XpalYii770E', + [ + actionState, + content, + contentInternal, + documentHead, + env, + getScroller, + goto, + httpStatus, + loaderState, + loadersObject, + navResolver, + props, + routeInternal, + routeLocation, + routeLocationTarget, + serverHead, + ] + ), + // We should only wait for navigation to complete on the server + { + deferUpdates: isServer, + } + ); +}; +const QwikRouterProvider = /* @__PURE__ */ componentQrl( + /* @__PURE__ */ inlinedQrl((props) => { + useQwikRouter(props); + return /* @__PURE__ */ _jsxSorted(Slot, null, null, null, 3, '5y_0'); + }, 'QwikRouterProvider_component_6Kjfa79mqlY') +); +const QwikCityProvider = QwikRouterProvider; +const useQwikMockRouter = (props) => { + const urlEnv = props.url ?? 'http://localhost/'; + const url = new URL(urlEnv); + const routeLocation = useStore( + { + url, + params: props.params ?? {}, + isNavigating: false, + prevUrl: void 0, + }, + { + deep: false, + } + ); + const container = _getContextContainer(); + const loadersObject = {}; + const loadersState = {}; + if (props.loaders) { + for (const { loader, data } of props.loaders) { + const id = loader.__id; + loadersObject[id] = data; + loadersState[id] = createLoaderSignal( + loadersObject, + id, + url, + DEFAULT_LOADERS_SERIALIZATION_STRATEGY, + container + ); + } + } + const goto = + props.goto ?? + /* @__PURE__ */ inlinedQrl(async () => { + console.warn('QwikRouterMockProvider: goto not provided'); + }, 'useQwikMockRouter_goto_aViHFxQ1a3s'); + const documentHead = useStore(createDocumentHead, { + deep: false, + }); + const content = useStore( + { + headings: void 0, + menu: void 0, + }, + { + deep: false, + } + ); + const contentInternal = useSignal(); + const actionState = useSignal(); + const httpStatus = useSignal({ + status: 200, + message: '', + }); + useContextProvider(ContentContext, content); + useContextProvider(ContentInternalContext, contentInternal); + useContextProvider(DocumentHeadContext, documentHead); + useContextProvider(HttpStatusContext, httpStatus); + useContextProvider(RouteLocationContext, routeLocation); + useContextProvider(RouteNavigateContext, goto); + useContextProvider(RouteStateContext, loadersState); + useContextProvider(RouteActionContext, actionState); + const actionsMocks = props.actions?.reduce((acc, { action, handler }) => { + acc[action.__id] = handler; + return acc; + }, {}); + useTaskQrl( + /* @__PURE__ */ inlinedQrl( + async ({ track }) => { + const actionState2 = _captures[0], + actionsMocks2 = _captures[1]; + const action = track(actionState2); + if (!action?.resolve) { + return; + } + const mock = actionsMocks2?.[action.id]; + if (mock) { + const actionResult = await mock(action.data); + action.resolve(actionResult); + } + }, + 'useQwikMockRouter_useTask_tXTLR4tzCy0', + [actionState, actionsMocks] + ) + ); +}; +const QwikRouterMockProvider = /* @__PURE__ */ componentQrl( + /* @__PURE__ */ inlinedQrl((props) => { + useQwikMockRouter(props); + return /* @__PURE__ */ _jsxSorted(Slot, null, null, null, 3, '5y_1'); + }, 'QwikRouterMockProvider_component_IN4dVpT0x74') +); +const QwikCityMockProvider = QwikRouterMockProvider; + +const handleRouterPopstate = (nav, event) => { + const href = event.detail?.href; + if (href) { + return nav(href, { + type: 'popstate', + }); + } +}; +const RouterOutlet = /* @__PURE__ */ componentQrl( + /* @__PURE__ */ inlinedQrl(() => { + const serverData = useServerData('containerAttributes'); + if (!serverData) { + throw new Error('PrefetchServiceWorker component must be rendered on the server.'); + } + const internalContext = useContext(ContentInternalContext); + const nav = useNavigate(); + const contents = internalContext.value; + if (contents && contents.length > 0) { + const contentsLen = contents.length; + let cmp = null; + for (let i = contentsLen - 1; i >= 0; i--) { + if (contents[i].default) { + cmp = _jsxSorted(contents[i].default, null, null, cmp, 1, 'Fn_0'); + } + } + return /* @__PURE__ */ _jsxSorted( + Fragment, + null, + null, + [ + cmp, + !__EXPERIMENTAL__.noSPA && + /* @__PURE__ */ _jsxSorted( + 'script', + { + 'q-d:qinit': _qrlSync(() => { + ((w, h) => { + if (!w._qcs) { + w._qcs = true; + const s = h.state?._qRouterScroll; + if (s) { + h.scrollRestoration = 'manual'; + w.scrollTo(s.x, s.y); + } + document.dispatchEvent(new Event('qcinit')); + } + })(window, history); + }, '()=>{((w,h)=>{if(!w._qcs){w._qcs=!0;const s=h.state?._qRouterScroll;if(s){h.scrollRestoration="manual";w.scrollTo(s.x,s.y);}document.dispatchEvent(new Event("qcinit"));}})(window,history);}'), + 'q-d:qrouterpopstate': /* @__PURE__ */ inlinedQrl( + (event, _1, nav2) => handleRouterPopstate(nav2, event), + 'RouterOutlet_component_Fragment_script_q_d_qrouterpopstate_aRL6GvFyews' + ), + 'q:p': nav, + }, + { + 'q-d:qcinit': spaInit, + }, + null, + 6, + 'Fn_1' + ), + ], + 1, + 'Fn_2' + ); + } + return SkipRender; + }, 'RouterOutlet_component_QwONcWD5gIg') +); + +const getValue = (obj) => obj.value; +const validatorQrl = (validator) => { + if (isServer) { + return { + validate: validator, + }; + } + return void 0; +}; +const validator$ = /* @__PURE__ */ implicit$FirstArg(validatorQrl); +const flattenValibotIssues = (issues) => { + return issues.reduce((acc, issue) => { + if (issue.path) { + const hasArrayType = issue.path.some((path) => path.type === 'array'); + if (hasArrayType) { + const keySuffix = issue.expected === 'Array' ? '[]' : ''; + const key = + issue.path + .map((item) => (item.type === 'array' ? '*' : item.key)) + .join('.') + .replace(/\.\*/g, '[]') + keySuffix; + acc[key] = acc[key] || []; + if (Array.isArray(acc[key])) { + acc[key].push(issue.message); + } + return acc; + } else { + acc[issue.path.map((item) => item.key).join('.')] = issue.message; + } + } + return acc; + }, {}); +}; +const valibotQrl = (qrl) => { + if (!__EXPERIMENTAL__.valibot) { + throw new Error( + 'Valibot is an experimental feature and is not enabled. Please enable the feature flag by adding `experimental: ["valibot"]` to your qwikVite plugin options.' + ); + } + if (isServer) { + return { + __brand: 'valibot', + async validate(ev, inputData) { + const schema = await qrl + .resolve() + .then((obj) => (typeof obj === 'function' ? obj(ev) : obj)); + const data = inputData ?? (await ev.parseBody()); + const result = await v.safeParseAsync(schema, data); + if (result.success) { + return { + success: true, + data: result.output, + }; + } else { + if (isDev) { + console.error('ERROR: Valibot validation failed', result.issues); + } + return { + success: false, + status: 400, + error: { + formErrors: v.flatten(result.issues).root ?? [], + fieldErrors: flattenValibotIssues(result.issues), + }, + }; + } + }, + }; + } + return void 0; +}; +const valibot$ = /* @__PURE__ */ implicit$FirstArg(valibotQrl); +const flattenZodIssues = (issues) => { + issues = Array.isArray(issues) ? issues : [issues]; + return issues.reduce((acc, issue) => { + const isExpectingArray = 'expected' in issue && issue.expected === 'array'; + const hasArrayType = issue.path.some((path) => typeof path === 'number') || isExpectingArray; + if (hasArrayType) { + const keySuffix = 'expected' in issue && issue.expected === 'array' ? '[]' : ''; + const key = + issue.path + .map((path) => (typeof path === 'number' ? '*' : path)) + .join('.') + .replace(/\.\*/g, '[]') + keySuffix; + acc[key] = acc[key] || []; + if (Array.isArray(acc[key])) { + acc[key].push(issue.message); + } + return acc; + } else { + acc[issue.path.join('.')] = issue.message; + } + return acc; + }, {}); +}; +const zodQrl = (qrl) => { + if (isServer) { + return { + __brand: 'zod', + async validate(ev, inputData) { + const schema = await qrl.resolve().then((obj) => { + if (typeof obj === 'function') { + obj = obj(z, ev); + } + if (obj instanceof z.Schema) { + return obj; + } else { + return z.object(obj); + } + }); + const data = inputData ?? (await ev.parseBody()); + const result = await withLocale(ev.locale(), () => schema.safeParseAsync(data)); + if (result.success) { + return result; + } else { + if (isDev) { + console.error('ERROR: Zod validation failed', result.error.issues); + } + return { + success: false, + status: 400, + error: { + formErrors: result.error.flatten().formErrors, + fieldErrors: flattenZodIssues(result.error.issues), + }, + }; + } + }, + }; + } + return void 0; +}; +const zod$ = /* @__PURE__ */ implicit$FirstArg(zodQrl); +const getValidators = (rest, qrl) => { + let id; + let serializationStrategy = DEFAULT_LOADERS_SERIALIZATION_STRATEGY; + const validators = []; + if (rest.length === 1) { + const options = rest[0]; + if (options && typeof options === 'object') { + if ('validate' in options) { + validators.push(options); + } else { + id = options.id; + if (options.serializationStrategy) { + serializationStrategy = options.serializationStrategy; + } + if (options.validation) { + validators.push(...options.validation); + } + } + } + } else if (rest.length > 1) { + validators.push(...rest.filter((v2) => !!v2)); + } + if (typeof id === 'string') { + if (isDev) { + if (!/^[\w/.-]+$/.test(id)) { + throw new Error(`Invalid id: ${id}, id can only contain [a-zA-Z0-9_.-]`); + } + } + id = `id_${id}`; + } else { + id = qrl.getHash(); + } + return { + validators: validators.reverse(), + id, + serializationStrategy, + }; +}; +const routeActionQrl = (actionQrl, ...rest) => { + const { id, validators } = getValidators(rest, actionQrl); + function action() { + const loc = useLocation(); + const currentAction = useAction(); + const initialState = { + actionPath: `?${QACTION_KEY}=${id}`, + submitted: false, + isRunning: false, + status: void 0, + value: void 0, + formData: void 0, + }; + const state = useStore(() => { + const value = currentAction.value; + if (value && value?.id === id) { + const data = value.data; + if (data instanceof FormData) { + initialState.formData = data; + } + if (value.output) { + const { status, result } = value.output; + initialState.status = status; + initialState.value = result; + } + } + return initialState; + }); + const submit = /* @__PURE__ */ inlinedQrl( + (input = {}) => { + const currentAction2 = _captures[0], + id2 = _captures[1], + loc2 = _captures[2], + state2 = _captures[3]; + if (isServer) { + throw new Error(`Actions can not be invoked within the server during SSR. +Action.run() can only be called on the browser, for example when a user clicks a button, or submits a form.`); + } + let data; + let form; + if (input instanceof SubmitEvent) { + form = input.target; + data = new FormData(form); + if ( + (input.submitter instanceof HTMLInputElement || + input.submitter instanceof HTMLButtonElement) && + input.submitter.name + ) { + if (input.submitter.name) { + data.append(input.submitter.name, input.submitter.value); + } + } + } else { + data = input; + } + return new Promise((resolve) => { + if (data instanceof FormData) { + state2.formData = data; + } + state2.submitted = true; + state2.isRunning = true; + loc2.isNavigating = true; + currentAction2.value = { + data, + id: id2, + resolve: noSerialize(resolve), + }; + }).then((_rawProps) => { + state2.isRunning = false; + state2.status = _rawProps.status; + state2.value = _rawProps.result; + if (form) { + if (form.getAttribute('data-spa-reset') === 'true') { + form.reset(); + } + const detail = { + status: _rawProps.status, + value: _rawProps.result, + }; + form.dispatchEvent( + new CustomEvent('submitcompleted', { + bubbles: false, + cancelable: false, + composed: false, + detail, + }) + ); + } + return { + status: _rawProps.status, + value: _rawProps.result, + }; + }); + }, + 'routeActionQrl_action_submit_YuS5bpdQ360', + [currentAction, id, loc, state] + ); + initialState.submit = submit; + return state; + } + action.__brand = 'server_action'; + action.__validators = validators; + action.__qrl = actionQrl; + action.__id = id; + Object.freeze(action); + return action; +}; +const globalActionQrl = (actionQrl, ...rest) => { + const action = routeActionQrl(actionQrl, ...rest); + if (isServer) { + if (typeof globalThis._qwikActionsMap === 'undefined') { + globalThis._qwikActionsMap = /* @__PURE__ */ new Map(); + } + globalThis._qwikActionsMap.set(action.__id, action); + } + return action; +}; +const routeAction$ = /* @__PURE__ */ implicit$FirstArg(routeActionQrl); +const globalAction$ = /* @__PURE__ */ implicit$FirstArg(globalActionQrl); +const routeLoaderQrl = (loaderQrl, ...rest) => { + const { id, validators, serializationStrategy } = getValidators(rest, loaderQrl); + function loader() { + const state = _resolveContextWithoutSequentialScope(RouteStateContext); + if (!(id in state)) { + throw new Error(`routeLoader$ "${loaderQrl.getSymbol()}" was invoked in a route where it was not declared. + This is because the routeLoader$ was not exported in a 'layout.tsx' or 'index.tsx' file of the existing route. + For more information check: https://qwik.dev/docs/route-loader/ + + If your are managing reusable logic or a library it is essential that this function is re-exported from within 'layout.tsx' or 'index.tsx file of the existing route otherwise it will not run or throw exception. + For more information check: https://qwik.dev/docs/re-exporting-loaders/`); + } + const loaderData = state[id]; + untrack(getValue, loaderData); + return loaderData; + } + loader.__brand = 'server_loader'; + loader.__qrl = loaderQrl; + loader.__validators = validators; + loader.__id = id; + loader.__serializationStrategy = serializationStrategy; + loader.__expires = -1; + Object.freeze(loader); + return loader; +}; +const routeLoader$ = /* @__PURE__ */ implicit$FirstArg(routeLoaderQrl); +const deserializeStream = async function* (stream, abortSignal) { + const reader = stream.getReader(); + try { + let buffer = ''; + const decoder = new TextDecoder(); + while (!abortSignal?.aborted) { + const result = await reader.read(); + if (result.done) { + break; + } + buffer += decoder.decode(result.value, { + stream: true, + }); + const lines = buffer.split(/\n/); + buffer = lines.pop(); + for (const line of lines) { + const deserializedData = _deserialize(line); + yield deserializedData; + } + } + } finally { + reader.releaseLock(); + } +}; +const serverQrl = (qrl, options) => { + if (isServer) { + const captured = qrl.getCaptured(); + if (captured && captured.length > 0 && !_getContextHostElement()) { + throw new Error('For security reasons, we cannot serialize QRLs that capture lexical scope.'); + } + } + const method = options?.method?.toUpperCase?.() || 'POST'; + const headers = options?.headers || {}; + const origin = options?.origin || ''; + const fetchOptions = options?.fetchOptions || {}; + return /* @__PURE__ */ inlinedQrl( + async function (...args) { + const fetchOptions2 = _captures[0], + headers2 = _captures[1], + method2 = _captures[2], + origin2 = _captures[3], + qrl2 = _captures[4]; + const abortSignal = args.length > 0 && args[0] instanceof AbortSignal ? args.shift() : void 0; + if (isServer) { + let requestEvent = _asyncRequestStore?.getStore(); + if (!requestEvent) { + const contexts = [useQwikRouterEnv()?.ev, this, _getContextEvent()]; + requestEvent = contexts.find( + (v2) => + v2 && + Object.prototype.hasOwnProperty.call(v2, 'sharedMap') && + Object.prototype.hasOwnProperty.call(v2, 'cookie') + ); + } + return qrl2.apply(requestEvent, args); + } else { + let filteredArgs = args.map((arg) => { + if (arg instanceof SubmitEvent && arg.target instanceof HTMLFormElement) { + return new FormData(arg.target); + } else if (arg instanceof Event) { + return null; + } else if (arg instanceof Node) { + return null; + } + return arg; + }); + if (!filteredArgs.length) { + filteredArgs = void 0; + } + const qrlHash = qrl2.getHash(); + let query = ''; + const config = { + ...fetchOptions2, + method: method2, + headers: { + ...headers2, + 'Content-Type': 'application/qwik-json', + Accept: 'application/json, application/qwik-json, text/qwik-json-stream, text/plain', + // Required so we don't call accidentally + 'X-QRL': qrlHash, + }, + signal: abortSignal, + }; + const captured = qrl2.getCaptured(); + let toSend = [filteredArgs]; + if (captured?.length) { + toSend = [filteredArgs, ...captured]; + } else { + toSend = filteredArgs ? [filteredArgs] : []; + } + const body = await _serialize(toSend); + if (method2 === 'GET') { + query += `&${QDATA_KEY}=${encodeURIComponent(body)}`; + } else { + config.body = body; + } + const res = await fetch(`${origin2}?${QFN_KEY}=${qrlHash}${query}`, config); + const contentType = res.headers.get('Content-Type'); + if (res.ok && contentType === 'text/qwik-json-stream' && res.body) { + return (async function* () { + try { + for await (const result of deserializeStream(res.body, abortSignal)) { + yield result; + } + } finally { + if (!abortSignal?.aborted) { + await res.body.cancel(); + } + } + })(); + } else if (contentType === 'application/qwik-json') { + const str = await res.text(); + const obj = _deserialize(str); + if (res.status >= 400) { + throw obj; + } + return obj; + } else if (contentType === 'application/json') { + const obj = await res.json(); + if (res.status >= 400) { + throw obj; + } + return obj; + } else if (contentType === 'text/plain' || contentType === 'text/html') { + const str = await res.text(); + if (res.status >= 400) { + throw str; + } + return str; + } + } + }, + 'serverQrl_w03grD0Ag68', + [fetchOptions, headers, method, origin, qrl] + ); +}; +const server$ = /* @__PURE__ */ implicit$FirstArg(serverQrl); + +const ServiceWorkerRegister = (props) => + /* @__PURE__ */ _jsxSorted( + 'script', + { + nonce: _wrapProp(props, 'nonce'), + }, + { + type: 'module', + dangerouslySetInnerHTML: swRegister, + }, + null, + 3, + '1x_0' + ); + +const _hf0 = (p0) => !p0.reloadDocument; +const _hf0_str = '!p0.reloadDocument'; +const _hf1 = (p0) => (p0.spaReset ? 'true' : void 0); +const _hf1_str = 'p0.spaReset?"true":undefined'; +const GetForm = /* @__PURE__ */ componentQrl( + /* @__PURE__ */ inlinedQrl((_rawProps) => { + const rest = _restProps(_rawProps, ['action', 'spaReset', 'reloadDocument', 'onSubmit$']); + const nav = useNavigate(); + return /* @__PURE__ */ _jsxSplit( + 'form', + { + action: 'get', + 'preventdefault:submit': _fnSignal(_hf0, [_rawProps], _hf0_str), + 'data-spa-reset': _fnSignal(_hf1, [_rawProps], _hf1_str), + ..._getVarProps(rest), + ..._getConstProps(rest), + 'q-e:submit': [ + ...(Array.isArray(_rawProps.onSubmit$) ? _rawProps.onSubmit$ : [_rawProps.onSubmit$]), + /* @__PURE__ */ inlinedQrl( + async (_evt, form) => { + const nav2 = _captures[0]; + const formData = new FormData(form); + const params = new URLSearchParams(); + formData.forEach((value, key) => { + if (typeof value === 'string') { + params.append(key, value); + } + }); + await nav2('?' + params.toString(), { + type: 'form', + forceReload: true, + }); + }, + 'GetForm_component_form_q_e_submit_r3dkP9d2cF8', + [nav] + ), + /* @__PURE__ */ inlinedQrl((_evt, form) => { + if (form.getAttribute('data-spa-reset') === 'true') { + form.reset(); + } + form.dispatchEvent( + new CustomEvent('submitcompleted', { + bubbles: false, + cancelable: false, + composed: false, + detail: { + status: 200, + }, + }) + ); + }, 'GetForm_component_form_q_e_submit_1_cuYklZAOHrA'), + ], + }, + null, + /* @__PURE__ */ _jsxSorted(Slot, null, null, null, 3, 'Q4_0'), + 0, + 'Q4_1' + ); + }, 'GetForm_component_2U5Z2Z8ryc0') +); +const Form = ({ action, spaReset, reloadDocument, onSubmit$, ...rest }, key) => { + if (action) { + const isArrayApi = Array.isArray(onSubmit$); + if (isArrayApi) { + return _jsxSplit( + 'form', + { + ..._getVarProps(rest), + ..._getConstProps(rest), + action: _wrapProp(action, 'actionPath'), + 'preventdefault:submit': !reloadDocument, + 'q-e:submit': [ + ...onSubmit$, + // action.submit "submitcompleted" event for onSubmitCompleted$ events + !reloadDocument + ? /* @__PURE__ */ inlinedQrl( + (evt) => { + const action2 = _captures[0]; + if (!action2.submitted) { + return action2.submit(evt); + } + }, + 'Form_form_q_e_submit_6i0Jq5q8JFg', + [action] + ) + : void 0, + ], + ['data-spa-reset']: spaReset ? 'true' : void 0, + }, + { + method: 'post', + }, + null, + 0, + key + ); + } + return _jsxSplit( + 'form', + { + ..._getVarProps(rest), + ..._getConstProps(rest), + action: _wrapProp(action, 'actionPath'), + 'preventdefault:submit': !reloadDocument, + 'q-e:submit': [ + // Since v2, this fires before the action is executed so it can be prevented + onSubmit$, + // action.submit "submitcompleted" event for onSubmitCompleted$ events + !reloadDocument ? action.submit : void 0, + ], + ['data-spa-reset']: spaReset ? 'true' : void 0, + }, + { + method: 'post', + }, + null, + 0, + key + ); + } else { + return /* @__PURE__ */ _jsxSplit( + GetForm, + { + spaReset, + reloadDocument, + onSubmit$, + ...rest, + }, + null, + null, + 0, + key + ); + } +}; + +const untypedAppUrl = function appUrl(route, params, paramsPrefix = '') { + const path = route.split('/'); + for (let i = 0; i < path.length; i++) { + const segment = path[i]; + if (segment.startsWith('[') && segment.endsWith(']')) { + const isSpread = segment.startsWith('[...'); + const key = segment.substring(segment.startsWith('[...') ? 4 : 1, segment.length - 1); + const value = params ? params[paramsPrefix + key] || params[key] : ''; + path[i] = isSpread ? value : encodeURIComponent(value); + } + if (segment.startsWith('(') && segment.endsWith(')')) { + path.splice(i, 1); + } + } + let url = path.join('/'); + let baseURL = '/'; + if (baseURL) { + if (!baseURL.endsWith('/')) { + baseURL += '/'; + } + while (url.startsWith('/')) { + url = url.substring(1); + } + url = baseURL + url; + } + return url; +}; +function omitProps(obj, keys) { + const omittedObj = {}; + for (const key in obj) { + if (!key.startsWith('param:') && !keys.includes(key)) { + omittedObj[key] = obj[key]; + } + } + return omittedObj; +} + +const createRenderer = (getOptions) => { + return (opts) => { + const { jsx, options } = getOptions(opts); + const isStatic = opts.serverData.renderMode === 'static'; + const renderOptions = isStatic + ? { + ...options, + streaming: { + ...options.streaming, + outOfOrder: false, + }, + } + : options; + return renderToStream(jsx, renderOptions); + }; +}; + +const DocumentHeadTags = /* @__PURE__ */ componentQrl( + /* @__PURE__ */ inlinedQrl((props) => { + let head = useDocumentHead(); + if (props) { + head = { + ...head, + ...props, + }; + } + return /* @__PURE__ */ _jsxSorted( + Fragment, + null, + null, + [ + head.title && /* @__PURE__ */ _jsxSorted('title', null, null, head.title, 1, 'r5_0'), + head.meta.map((m) => + /* @__PURE__ */ _jsxSplit( + 'meta', + { + ..._getVarProps(m), + }, + _getConstProps(m), + null, + 0, + 'r5_1' + ) + ), + head.links.map((l) => + /* @__PURE__ */ _jsxSplit( + 'link', + { + ..._getVarProps(l), + }, + _getConstProps(l), + null, + 0, + 'r5_2' + ) + ), + head.styles.map((s) => { + const props2 = s.props || s; + return /* @__PURE__ */ createElement('style', { + ...props2, + dangerouslySetInnerHTML: s.style || props2.dangerouslySetInnerHTML, + key: s.key, + }); + }), + head.scripts.map((s) => { + const props2 = s.props || s; + return /* @__PURE__ */ createElement('script', { + ...props2, + dangerouslySetInnerHTML: s.script || props2.dangerouslySetInnerHTML, + key: s.key, + }); + }), + ], + 1, + 'r5_3' + ); + }, 'DocumentHeadTags_component_9CrWYOoCpgY') +); + +export { + DocumentHeadTags, + ErrorBoundary, + Form, + Link, + QWIK_CITY_SCROLLER, + QWIK_ROUTER_SCROLLER, + QwikCityMockProvider, + QwikCityProvider, + QwikRouterMockProvider, + QwikRouterProvider, + RouterOutlet, + ServiceWorkerRegister, + createRenderer, + globalAction$, + globalActionQrl, + omitProps, + routeAction$, + routeActionQrl, + routeLoader$, + routeLoaderQrl, + server$, + serverQrl, + untypedAppUrl, + useDocumentHead, + useLocation, + useNavigate, + useQwikRouter, + valibot$, + valibotQrl, + validator$, + validatorQrl, + zod$, + zodQrl, +}; diff --git a/packages/ts-optimizer/tests/hashing/naming.test.ts b/packages/ts-optimizer/tests/hashing/naming.test.ts new file mode 100644 index 00000000000..9da4dbdac5a --- /dev/null +++ b/packages/ts-optimizer/tests/hashing/naming.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { escapeSymbol, buildDisplayName, buildSymbolName } from '../../src/hashing/naming.js'; +import { mkDisplayName } from '../../src/optimizer/types/brands.js'; +import { parseSnapshot } from '../../src/testing/snapshot-parser.js'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { SNAP_DIR } from '../rust-snapshots.js'; + +describe('escapeSymbol', () => { + it('strips trailing $ from component names', () => { + expect(escapeSymbol('Foo_component$')).toBe('Foo_component'); + }); + + it('returns empty string for all non-alnum', () => { + expect(escapeSymbol('$')).toBe(''); + }); + + it('trims leading/trailing non-alnum and squashes consecutive separators', () => { + expect(escapeSymbol('___abc___def___')).toBe('abc_def'); + }); + + it('strips $ from event handler names', () => { + expect(escapeSymbol('onClick$')).toBe('onClick'); + }); + + it('converts dots to underscores', () => { + expect(escapeSymbol('a.b.c')).toBe('a_b_c'); + }); + + it('preserves leading digits (digits are alnum)', () => { + expect(escapeSymbol('123abc')).toBe('123abc'); + }); +}); + +describe('buildDisplayName', () => { + it('builds display name from single context entry', () => { + expect(buildDisplayName('test.tsx', ['renderHeader1'])).toBe('test.tsx_renderHeader1'); + }); + + it('builds display name from multi-level context stack', () => { + expect(buildDisplayName('test.tsx', ['renderHeader1', 'div', 'onClick$'])).toBe( + 'test.tsx_renderHeader1_div_onClick' + ); + }); + + it('builds display name for component context', () => { + expect(buildDisplayName('test.tsx', ['renderHeader2', 'component$'])).toBe( + 'test.tsx_renderHeader2_component' + ); + }); + + it('uses s_ prefix for empty context stack', () => { + expect(buildDisplayName('test.tsx', [])).toBe('test.tsx_s_'); + }); +}); + +describe('buildSymbolName', () => { + it('produces correct symbol name for renderHeader1_div_onClick', () => { + expect( + buildSymbolName(mkDisplayName('test.tsx_renderHeader1_div_onClick'), undefined, 'test.tsx') + ).toBe('renderHeader1_div_onClick_USi8k1jUb40'); + }); + + it('produces correct symbol name for renderHeader1', () => { + expect(buildSymbolName(mkDisplayName('test.tsx_renderHeader1'), undefined, 'test.tsx')).toBe( + 'renderHeader1_jMxQsjbyDss' + ); + }); + + it('produces correct symbol name for renderHeader2_component', () => { + expect( + buildSymbolName(mkDisplayName('test.tsx_renderHeader2_component'), undefined, 'test.tsx') + ).toBe('renderHeader2_component_Ay6ibkfFYsw'); + }); + + it('matches all symbol names across the snapshot corpus', () => { + const snapFiles = readdirSync(SNAP_DIR).filter((f) => f.endsWith('.snap')); + expect(snapFiles.length).toBeGreaterThanOrEqual(210); + + let totalNames = 0; + let skipped = 0; + const mismatches: Array<{ + file: string; + expected: string; + actual: string; + origin: string; + displayName: string; + }> = []; + + const KNOWN_EDGE_CASE_FILES = new Set([ + 'qwik_core__test__example_build_server.snap', + // lib-mode naming and prod-minified s_ symbols need transform + // context this recomputation lacks; convergence covers them. + 'qwik_core__test__example_qwik_router_client.snap', + 'qwik_core__test__worker_qrl_segment_prod_snapshot.snap', + 'qwik_core__test__example_capture_imports.snap', + 'qwik_core__test__example_prod_node.snap', + 'qwik_core__test__example_qwik_react.snap', + 'qwik_core__test__example_strip_server_code.snap', + 'qwik_core__test__relative_paths.snap', + 'qwik_core__test__should_preserve_non_ident_explicit_captures.snap', + ]); + + for (const file of snapFiles) { + const content = readFileSync(join(SNAP_DIR, file), 'utf-8'); + const parsed = parseSnapshot(content); + + for (const segment of parsed.segments) { + if (!segment.metadata) { + continue; + } + const meta = segment.metadata; + + const lastSlash = meta.origin.lastIndexOf('/'); + const basename = lastSlash >= 0 ? meta.origin.slice(lastSlash + 1) : meta.origin; + const prefix = basename + '_'; + + if (!meta.displayName.startsWith(prefix)) { + skipped++; + continue; + } + + if (meta.hash === meta.name) { + skipped++; + continue; + } + + if (meta.loc[0] === 0 && meta.loc[1] === 0) { + skipped++; + continue; + } + + const computed = buildSymbolName(mkDisplayName(meta.displayName), undefined, meta.origin); + totalNames++; + + if (computed !== meta.name) { + if (KNOWN_EDGE_CASE_FILES.has(file)) { + skipped++; + continue; + } + mismatches.push({ + file, + expected: meta.name, + actual: computed, + origin: meta.origin, + displayName: meta.displayName, + }); + } + } + } + + console.log(`Total names tested: ${totalNames}, skipped edge cases: ${skipped}`); + if (mismatches.length > 0) { + console.log(`Mismatches:`, JSON.stringify(mismatches, null, 2)); + } + expect(mismatches).toHaveLength(0); + expect(totalNames).toBeGreaterThan(350); + }); +}); diff --git a/packages/ts-optimizer/tests/hashing/siphash.test.ts b/packages/ts-optimizer/tests/hashing/siphash.test.ts new file mode 100644 index 00000000000..2a6b5756b05 --- /dev/null +++ b/packages/ts-optimizer/tests/hashing/siphash.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { qwikHash } from '../../src/hashing/siphash.js'; +import { parseSnapshot } from '../../src/testing/snapshot-parser.js'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { SNAP_DIR } from '../rust-snapshots.js'; + +describe('qwikHash', () => { + it('produces correct hash for renderHeader1_div_onClick', () => { + expect(qwikHash(undefined, 'test.tsx', 'renderHeader1_div_onClick')).toBe('USi8k1jUb40'); + }); + + it('produces correct hash for renderHeader1', () => { + expect(qwikHash(undefined, 'test.tsx', 'renderHeader1')).toBe('jMxQsjbyDss'); + }); + + it('produces correct hash for renderHeader2_component', () => { + expect(qwikHash(undefined, 'test.tsx', 'renderHeader2_component')).toBe('Ay6ibkfFYsw'); + }); + + it('output is exactly 11 characters of [A-Za-z0-9]', () => { + const hash = qwikHash(undefined, 'test.tsx', 'renderHeader1'); + expect(hash).toHaveLength(11); + expect(hash).toMatch(/^[A-Za-z0-9]+$/); + expect(hash).not.toMatch(/[-_]/); + }); + + it('matches all hashes across the snapshot corpus', () => { + const snapFiles = readdirSync(SNAP_DIR).filter((f) => f.endsWith('.snap')); + expect(snapFiles.length).toBeGreaterThanOrEqual(210); + + let totalHashes = 0; + let skipped = 0; + const mismatches: Array<{ + file: string; + name: string; + expected: string; + actual: string; + origin: string; + displayName: string; + }> = []; + + const KNOWN_EDGE_CASE_FILES = new Set([ + 'qwik_core__test__example_build_server.snap', + // lib-mode naming and prod-minified s_ symbols need transform + // context this recomputation lacks; convergence covers them. + 'qwik_core__test__example_qwik_router_client.snap', + 'qwik_core__test__worker_qrl_segment_prod_snapshot.snap', + 'qwik_core__test__example_capture_imports.snap', + 'qwik_core__test__example_prod_node.snap', + 'qwik_core__test__example_qwik_react.snap', + 'qwik_core__test__example_strip_server_code.snap', + 'qwik_core__test__relative_paths.snap', + 'qwik_core__test__should_preserve_non_ident_explicit_captures.snap', + ]); + + for (const file of snapFiles) { + const content = readFileSync(join(SNAP_DIR, file), 'utf-8'); + const parsed = parseSnapshot(content); + + for (const segment of parsed.segments) { + if (!segment.metadata) { + continue; + } + const meta = segment.metadata; + + const lastSlash = meta.origin.lastIndexOf('/'); + const basename = lastSlash >= 0 ? meta.origin.slice(lastSlash + 1) : meta.origin; + const prefix = basename + '_'; + + if (!meta.displayName.startsWith(prefix)) { + skipped++; + continue; + } + + const contextPortion = meta.displayName.slice(prefix.length); + + if (meta.hash === meta.name) { + skipped++; + continue; + } + + if (meta.loc[0] === 0 && meta.loc[1] === 0) { + skipped++; + continue; + } + + const computed = qwikHash(undefined, meta.origin, contextPortion); + totalHashes++; + + if (computed !== meta.hash) { + if (KNOWN_EDGE_CASE_FILES.has(file)) { + skipped++; + continue; + } + mismatches.push({ + file, + name: meta.name, + expected: meta.hash, + actual: computed, + origin: meta.origin, + displayName: meta.displayName, + }); + } + } + } + + console.log(`Total hashes tested: ${totalHashes}, skipped edge cases: ${skipped}`); + if (mismatches.length > 0) { + console.log(`Mismatches:`, JSON.stringify(mismatches, null, 2)); + } + expect(mismatches).toHaveLength(0); + expect(totalHashes).toBeGreaterThan(350); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/capture-analysis.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/capture-analysis.test.ts new file mode 100644 index 00000000000..7b3042312ba --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/capture-analysis.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect } from 'vitest'; +import { parseSync } from 'oxc-parser'; +import { walk } from 'oxc-walker'; +import type { AstNode, AstProgram, AstFunction, AstMaybeNode } from '../../../src/ast-types.js'; +import { + analyzeCaptures, + collectScopeIdentifiers, + type CaptureAnalysisResult, +} from '../../../src/optimizer/analysis/capture-analysis.js'; +import { computeClosureFreeIdentifiers } from '../../../src/optimizer/analysis/closure-free-identifiers.js'; + +interface DollarArgFacts { + argNode: AstFunction; + parentScopeIds: Set; + importedNames: Set; + freeIds: readonly string[]; +} + +function asClosureArg(node: AstNode): AstFunction | null { + if (node.type !== 'CallExpression') { + return null; + } + const { callee } = node; + if (callee.type !== 'Identifier' || !callee.name.endsWith('$')) { + return null; + } + const arg = node.arguments[0]; + if (arg?.type === 'ArrowFunctionExpression' || arg?.type === 'FunctionExpression') { + return arg; + } + return null; +} + +function collectDollarArgs(program: AstProgram): AstFunction[] { + const args: AstFunction[] = []; + walk(program, { + enter(node: AstNode) { + const arg = asClosureArg(node); + if (arg) { + args.push(arg); + } + }, + }); + return args; +} + +function collectImportedNames(program: AstProgram): Set { + const names = new Set(); + for (const node of program.body) { + if (node.type === 'ImportDeclaration') { + for (const spec of node.specifiers) { + names.add(spec.local.name); + } + } + } + return names; +} + +function collectParentScopeIds(program: AstProgram, argNode: AstFunction): Set { + const ids = new Set(); + walk(program, { + enter(node: AstNode) { + if (node === argNode) { + this.skip(); + return; + } + if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') { + ids.add(node.id.name); + } + if ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' || + node.type === 'FunctionDeclaration' + ) { + for (const param of node.params) { + collectBindingNames(param, ids); + } + } + }, + }); + return ids; +} + +function buildDollarArgFacts(program: AstProgram, argNode: AstFunction): DollarArgFacts { + const freeIds = + computeClosureFreeIdentifiers(program, new Map([['t', argNode]])).get(argNode) ?? []; + return { + argNode, + parentScopeIds: collectParentScopeIds(program, argNode), + importedNames: collectImportedNames(program), + freeIds, + }; +} + +function findDollarArg(source: string): DollarArgFacts { + const { program } = parseSync('test.tsx', source); + const argNode = collectDollarArgs(program)[0]; + if (!argNode) { + throw new Error('No $() call argument found in source'); + } + return buildDollarArgFacts(program, argNode); +} + +function findNthDollarArg(source: string, n: number): DollarArgFacts { + const { program } = parseSync('test.tsx', source); + const argNodes = collectDollarArgs(program); + if (argNodes.length <= n) { + throw new Error(`Only found ${argNodes.length} $() calls, wanted index ${n}`); + } + return buildDollarArgFacts(program, argNodes[n]); +} + +function collectBindingNames(node: AstMaybeNode, names: Set): void { + if (!node) { + return; + } + if (node.type === 'Identifier') { + names.add(node.name); + return; + } + if (node.type === 'ObjectPattern') { + for (const prop of node.properties) { + if (prop.type === 'RestElement') { + collectBindingNames(prop.argument, names); + } else { + collectBindingNames(prop.value, names); + } + } + return; + } + if (node.type === 'ArrayPattern') { + for (const elem of node.elements) { + collectBindingNames(elem, names); + } + return; + } + if (node.type === 'RestElement') { + collectBindingNames(node.argument, names); + return; + } + if (node.type === 'AssignmentPattern') { + collectBindingNames(node.left, names); + return; + } +} + +describe('analyzeCaptures', () => { + it('Test 1: simple capture - detects variable from enclosing scope', () => { + const source = ` + import { component$, useStore, $ } from '@qwik.dev/core'; + export const App = component$(() => { + const s = useStore({}); + return $(() => { s.count++ }); + }); + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findNthDollarArg(source, 1); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captures).toBe(true); + expect(result.captureNames).toEqual(['s']); + }); + + it('Test 2: multiple captures sorted alphabetically', () => { + const source = ` + import { $ } from '@qwik.dev/core'; + function test() { + const zebra = 1; + const alpha = 2; + const mid = 3; + $(() => { zebra; alpha; mid; }); + } + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findDollarArg(source); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captures).toBe(true); + expect(result.captureNames).toEqual(['alpha', 'mid', 'zebra']); + }); + + it('Test 3: globals excluded - console is not captured', () => { + const source = ` + import { $ } from '@qwik.dev/core'; + function test() { + const x = 1; + $(() => { console.log(x); }); + } + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findDollarArg(source); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captures).toBe(true); + expect(result.captureNames).toEqual(['x']); + expect(result.captureNames).not.toContain('console'); + }); + + it('Test 4: import bindings excluded - only local vars captured', () => { + const source = ` + import { $, useStore } from '@qwik.dev/core'; + function test() { + const state = useStore({}); + $(() => { useStore; state; }); + } + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findDollarArg(source); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captures).toBe(true); + expect(result.captureNames).toEqual(['state']); + expect(result.captureNames).not.toContain('useStore'); + }); + + it('Test 5: var hoisting - var-declared variable IS captured', () => { + const source = ` + import { $ } from '@qwik.dev/core'; + function test() { + var x = 1; + $(() => { x++; }); + } + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findDollarArg(source); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captures).toBe(true); + expect(result.captureNames).toEqual(['x']); + }); + + it('Test 6: destructured parameters produce individual captures', () => { + const source = ` + import { $ } from '@qwik.dev/core'; + const fn = ({count, name}) => { + $(() => { count; name; }); + }; + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findDollarArg(source); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captures).toBe(true); + expect(result.captureNames).toEqual(['count', 'name']); + }); + + it('Test 7: paramNames extraction - arrow with single param', () => { + const source = ` + import { $ } from '@qwik.dev/core'; + $((props) => { props.value; }); + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findDollarArg(source); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.paramNames).toEqual(['props']); + }); + + it('Test 8: no captures for module-level refs - returns empty', () => { + const source = ` + import { $ } from '@qwik.dev/core'; + const moduleVar = 42; + $(() => { moduleVar; }); + `; + + const { argNode, freeIds } = findDollarArg(source); + const emptyParentScope = new Set(); + const result = analyzeCaptures(argNode, emptyParentScope, freeIds); + + expect(result.captures).toBe(false); + expect(result.captureNames).toEqual([]); + }); + + it('Test 9: function own parameters are NOT captured', () => { + const source = ` + import { $ } from '@qwik.dev/core'; + $((event) => { event.target; }); + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findDollarArg(source); + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captureNames).not.toContain('event'); + expect(result.captures).toBe(false); + }); + + it('Test 10: inner-scope binding shadowing a top-level import IS captured', () => { + const source = ` + import { qrl } from '@qwik.dev/core/what'; + function host() { + const qrl = 23; + $(() => { qrl; }); + } + `; + + const { argNode, parentScopeIds, importedNames, freeIds } = findNthDollarArg(source, 0); + expect(importedNames.has('qrl')).toBe(true); + expect(parentScopeIds.has('qrl')).toBe(true); + + const result = analyzeCaptures(argNode, parentScopeIds, freeIds); + + expect(result.captures).toBe(true); + expect(result.captureNames).toEqual(['qrl']); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/closure-free-identifiers.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/closure-free-identifiers.test.ts new file mode 100644 index 00000000000..54a80b3f52e --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/closure-free-identifiers.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseSync } from 'oxc-parser'; +import { walk, getUndeclaredIdentifiersInFunction } from 'oxc-walker'; +import { computeClosureFreeIdentifiers } from '../../../src/optimizer/analysis/closure-free-identifiers.js'; +import { parseSnapshot } from '../../../src/testing/snapshot-parser.js'; +import { RAW_TRANSFER_PARSER_OPTIONS } from '../../../src/ast-types.js'; +import type { AstFunction, AstNode, AstProgram } from '../../../src/ast-types.js'; +import { SNAP_DIR } from '../../rust-snapshots.js'; + +function collectFunctionNodes(program: AstProgram): Map { + const nodes = new Map(); + let i = 0; + walk(program, { + enter(node) { + const n = node as AstNode; + if ( + n.type === 'ArrowFunctionExpression' || + n.type === 'FunctionExpression' || + n.type === 'FunctionDeclaration' + ) { + nodes.set(`fn_${i++}`, n as AstFunction); + } + }, + }); + return nodes; +} + +function collectComputedKeyNames(fn: AstFunction): Set { + const names = new Set(); + walk(fn as AstNode, { + enter(node, parent) { + const n = node as AstNode; + const p = parent as AstNode | null; + if (n.type !== 'Identifier' || p === null) { + return; + } + if (p.type === 'MemberExpression') { + if (p.computed === true && p.property === n) { + names.add(n.name); + } + } else if ( + p.type === 'Property' || + p.type === 'MethodDefinition' || + p.type === 'PropertyDefinition' || + p.type === 'AccessorProperty' + ) { + if (p.computed === true && p.key === n) { + names.add(n.name); + } + } + }, + }); + return names; +} + +// `as T`, `x!`, `x satisfies T` and friends hold a real expression next to the +// type, so only their type child starts a type position. +const TS_NODES_HOLDING_A_VALUE = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSInstantiationExpression', + 'TSTypeAssertion', + 'TSEnumDeclaration', + 'TSEnumMember', + 'TSModuleDeclaration', + 'TSModuleBlock', + 'TSParameterProperty', + 'TSExportAssignment', + 'TSImportEqualsDeclaration', +]); + +/** + * Names that only ever appear in a type position (`props: Stuff`, `useSignal>`). The + * fused walk reports them; the oxc-walker oracle doesn't. Inert in the pipeline — `transpileTs` + * strips types before this analysis runs, and the migration and capture gates downstream drop them + * even when it doesn't — so the difference stays a test-only exclusion rather than a TS-grammar + * exception list baked into the production walk. + */ +function collectTypePositionNames(fn: AstFunction): Set { + const names = new Set(); + walk(fn as AstNode, { + enter(node) { + const n = node as AstNode; + if (!n.type.startsWith('TS') || TS_NODES_HOLDING_A_VALUE.has(n.type)) { + return; + } + walk(n, { + enter(inner) { + const i = inner as AstNode; + if (i.type === 'Identifier') { + names.add(i.name); + } + }, + }); + }, + }); + return names; +} + +function collectJsxTagNames(fn: AstFunction): Set { + const names = new Set(); + walk(fn as AstNode, { + enter(node, parent) { + const n = node as AstNode; + const p = parent as AstNode | null; + if (n.type !== 'JSXIdentifier' || p === null) { + return; + } + if (p.type === 'JSXAttribute' || p.type === 'JSXNamespacedName') { + return; + } + if (p.type === 'JSXMemberExpression' && (p as { property?: unknown }).property === n) { + return; + } + if (/^[A-Z]/.test(n.name)) { + names.add(n.name); + } + }, + }); + return names; +} + +function diffAgainstLegacy(source: string, filename: string): string[] { + const parsed = parseSync(filename, source, RAW_TRANSFER_PARSER_OPTIONS); + if (!parsed.program || parsed.errors?.length) { + return []; + } + const program = parsed.program as AstProgram; + const nodes = collectFunctionNodes(program); + const fused = computeClosureFreeIdentifiers(program, nodes); + + const mismatches: string[] = []; + for (const [key, fn] of nodes) { + let legacy: string[]; + try { + legacy = getUndeclaredIdentifiersInFunction(fn); + } catch { + continue; + } + const ours = fused.get(fn) ?? []; + const computedKeyNames = collectComputedKeyNames(fn); + // The fused walk intentionally counts capitalized JSX tags as free + // references (they capture like any binding); the oxc-walker oracle + // doesn't know JSX tags, so exclude that known difference. + const jsxTagNames = collectJsxTagNames(fn); + // A name used in a type position is dropped from both sides: the fused walk + // also sees it earlier there than the oracle sees it at its value use, so + // its ordering can't be compared either. + const typePositionNames = collectTypePositionNames(fn); + const legacySet = new Set(legacy); + const filtered = ours.filter( + (n) => + !typePositionNames.has(n) && + (legacySet.has(n) || (!computedKeyNames.has(n) && !jsxTagNames.has(n))) + ); + const expected = legacy.filter((n) => !typePositionNames.has(n)); + if (JSON.stringify(filtered) !== JSON.stringify(expected)) { + mismatches.push( + `${key} @ ${fn.start}: fused=${JSON.stringify(ours)} legacy=${JSON.stringify(legacy)}` + ); + } + } + return mismatches; +} + +describe('computeClosureFreeIdentifiers parity with per-closure analysis', () => { + it('matches on handcrafted scope shapes', () => { + const source = ` +import { component$, $ } from '@qwik.dev/core'; +const moduleVar = 1; +function moduleFn() { return moduleVar; } +class ModuleClass {} + +export const A = component$(({ prop }) => { + const local = 2; + const shadow = 3; + for (let i = 0; i < 3; i++) { + register($((ev) => use(local, prop, i, moduleVar, unknownGlobal))); + } + const inner = $(() => { + const shadowed = (shadow) => shadow + local; + return shadowed(moduleFn()) + new ModuleClass().x; + }); + const named = $(function self() { return self() + local; }); + const arrowInBlock = () => { { const blockScoped = 4; } return blockScoped; }; + return inner; +}); + +export const lateRef = $(() => laterDeclared + 1); +const laterDeclared = 5; +`; + expect(diffAgainstLegacy(source, 'handcrafted.tsx')).toEqual([]); + }); + + it('keeps first-free-occurrence order when a name resolves internal at one scope and free at another', () => { + const source = ` +export const B = $(() => { + use(firstFree); + { + let dual = 1; + touch(dual); + } + return dual + secondFree; +}); +const dual = 9; +`; + expect(diffAgainstLegacy(source, 'handcrafted.tsx')).toEqual([]); + + const parsed = parseSync('handcrafted.tsx', source, RAW_TRANSFER_PARSER_OPTIONS); + const program = parsed.program as AstProgram; + const nodes = collectFunctionNodes(program); + const fused = computeClosureFreeIdentifiers(program, nodes); + const closure = [...nodes.values()][0]; + expect(fused.get(closure)).toEqual(['use', 'firstFree', 'touch', 'dual', 'secondFree']); + }); + + it('matches across the full snapshot fixture corpus', () => { + const snapFiles = readdirSync(SNAP_DIR).filter((f) => f.endsWith('.snap')); + expect(snapFiles.length).toBeGreaterThan(100); + + const failures: string[] = []; + let checkedFns = 0; + for (const snapFile of snapFiles) { + const content = readFileSync(join(SNAP_DIR, snapFile), 'utf-8'); + const parsed = parseSnapshot(content); + if (!parsed.input) { + continue; + } + const mismatches = diffAgainstLegacy(parsed.input, 'test.tsx'); + checkedFns += 1; + for (const m of mismatches) { + failures.push(`${snapFile}: ${m}`); + } + } + expect(checkedFns).toBeGreaterThan(100); + expect(failures).toEqual([]); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/computed-key-block-scope-capture.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/computed-key-block-scope-capture.test.ts new file mode 100644 index 00000000000..9e0fb5e4999 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/computed-key-block-scope-capture.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function captures(body: string): string { + const code = `import { useComputed$ } from '@qwik.dev/core'; +export function h(props) { +${body} + return null; +}`; + const result = transformModule({ + input: [{ path: mkFilePath('h.tsx'), code: mkSourceText(code) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'hoist' }, + minify: 'simplify', + transpileTs: true, + isServer: true, + mode: 'dev', + }); + const parent = (result.modules.find((m) => m.kind === 'parent') ?? result.modules[0]!).code; + const w = parent.match(/\.w\(\[([^\]]*)\]\)/); + return w ? w[1].replace(/\s+/g, ' ').trim() : ''; +} + +describe('computed member/key identifiers are captured', () => { + it('captures a variable used as a computed member key', () => { + expect( + captures(` const k = props.k;\n const c = useComputed$(() => props.rec[k]);`) + ).toContain('k'); + }); + + it('does not capture a non-computed member property name', () => { + expect(captures(` const c = useComputed$(() => props.rec.value);`)).toBe('props'); + }); +}); + +describe('block- and loop-scoped variables are captured', () => { + it('captures a for-of loop binding', () => { + expect( + captures(` for (const k of props.ks) {\n const c = useComputed$(() => k + 1);\n }`) + ).toContain('k'); + }); + + it('captures a const declared in a loop body', () => { + expect( + captures( + ` for (const key of props.ks) {\n const name = key;\n const c = useComputed$(() => name);\n }` + ) + ).toContain('name'); + }); + + it('captures a const declared in a plain block', () => { + expect( + captures(` {\n const z = props.a;\n const c = useComputed$(() => z);\n }`) + ).toContain('z'); + }); + + it('captures both a loop-body const AND a function-scope const used as a computed key (qds useBindings shape)', () => { + const w = captures( + ` const propsRecord = props;\n for (const key of props.ks) {\n const keyName = key;\n const c = useComputed$(() => propsRecord[keyName]);\n }` + ); + expect(w).toContain('keyName'); + expect(w).toContain('propsRecord'); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/destructure-inlining.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/destructure-inlining.test.ts new file mode 100644 index 00000000000..58fbb00dd62 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/destructure-inlining.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function transformDefault(source: string, extra: Record = {}) { + return transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(source) }], + srcDir: mkFilePath('.'), + mode: 'test', + transpileTs: true, + transpileJsx: true, + ...extra, + } as Parameters[0]); +} + +function findComponentBodySegment(result: ReturnType) { + return result.modules.find((m) => m.kind === 'segment' && m.segment.ctxName === 'component$'); +} + +describe('JSX tag-name rewrite for const {X} = props destructures', () => { + describe('Positive: JSXIdentifier in tag position rewrites to props.X', () => { + it('self-closing tag: `const {Model} = props; ` → `_jsxSorted(props.Model, …)`', () => { + const result = transformDefault(` +import { component$ } from '@qwik.dev/core'; +export const App = component$((props) => { + const {Model} = props; + return ; +}); +`); + const body = findComponentBodySegment(result); + if (body?.kind !== 'segment') { + throw new Error('App component-body segment missing'); + } + expect(body.code).not.toMatch(/const\s*\{\s*Model\s*\}\s*=\s*props\s*;/); + expect(body.code).toMatch(/_jsxSorted\(\s*props\.Model\s*,/); + expect(body.code).not.toMatch(/_jsxSorted\(\s*Model\s*,/); + }); + + it('paired tag: `child` → `child` (both opening + closing rewritten)', () => { + const result = transformDefault(` +import { component$ } from '@qwik.dev/core'; +export const App = component$((props) => { + const {Wrapper} = props; + return hi; +}); +`); + const body = findComponentBodySegment(result); + if (body?.kind !== 'segment') { + throw new Error('App component-body segment missing'); + } + expect(body.code).toMatch(/_jsxSorted\(\s*props\.Wrapper\s*,/); + }); + + it('mixed: regular Identifier ref + JSX tag ref + decl removal in same body', () => { + const result = transformDefault(` +import { component$ } from '@qwik.dev/core'; +export const App = component$((props) => { + const {Model, count} = props; + return ; +}); +`); + const body = findComponentBodySegment(result); + if (body?.kind !== 'segment') { + throw new Error('App component-body segment missing'); + } + expect(body.code).not.toMatch(/const\s*\{\s*Model\s*,\s*count\s*\}\s*=\s*props\s*;/); + expect(body.code).toMatch(/_jsxSorted\(\s*props\.Model\s*,/); + expect(body.code).toMatch(/_wrapProp\(\s*props\s*,\s*"count"\s*\)/); + expect(body.code).not.toMatch(/"data-n":\s*count\b/); + }); + }); + + describe('Negative scope: non-reference JSXIdentifier positions are not rewritten', () => { + it('JSX attribute name is NOT a reference — preserved literally', () => { + const result = transformDefault(` +import { component$ } from '@qwik.dev/core'; +export const App = component$((props) => { + const {model} = props; + return
; +}); +`); + const body = findComponentBodySegment(result); + if (body?.kind !== 'segment') { + throw new Error('App component-body segment missing'); + } + expect(body.code).toMatch(/\bmodel:\s*_wrapProp\(\s*props\s*,\s*"model"\s*\)/); + expect(body.code).not.toMatch(/"props\.model":/); + expect(body.code).not.toMatch(/\bprops\.model\s*:/); + }); + }); + + describe('Negative scope: no body destructure → no rewrite', () => { + it('`` without a `const {X} = props` decl → tag stays as `X` (untouched)', () => { + const result = transformDefault(` +import { component$ } from '@qwik.dev/core'; +import { X } from './x'; +export const App = component$(() => { + return ; +}); +`); + const body = findComponentBodySegment(result); + if (body?.kind !== 'segment') { + throw new Error('App component-body segment missing'); + } + expect(body.code).toMatch(/_jsxSorted\(\s*X\s*,/); + expect(body.code).not.toMatch(/_jsxSorted\(\s*props\.X\s*,/); + }); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/jsx-tag-captures.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/jsx-tag-captures.test.ts new file mode 100644 index 00000000000..4f9df9b1c0c --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/jsx-tag-captures.test.ts @@ -0,0 +1,43 @@ +import { it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +it('a sibling component referenced as a JSX tag is captured', () => { + // Core unit specs define sibling component$ consts inside it(); the hoisted + // Counter body must capture Display like any other function-scoped binding + // (rust: `const Display = _captures[0]`). + const code = ` +import { component$, useSignal } from '@qwik.dev/core'; +export function setup(render: any) { + const log: string[] = []; + const Display = component$((props: { dValue: number }) => { + log.push('Display'); + return Count: {props.dValue}!; + }); + const Counter = component$((props: { initial: number }) => { + log.push('Counter'); + const count = useSignal(props.initial); + return ( + + ); + }); + return render(); +} +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + mode: 'dev', + isServer: true, + entryStrategy: { type: 'hoist' }, + }); + const allCode = result.modules.map((m) => m.code).join('\n'); + // Counter's qrl carries both captures, in code-unit sort order. + expect(allCode).toMatch(/Counter_component_[A-Za-z0-9]+\.w\(\[\s*Display,\s*log\s*\]\)/); + // The hoisted body unpacks Display from captures instead of a free ref. + expect(allCode).toMatch(/const Display = _captures\[0\], log = _captures\[1\]/); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/label-identifiers.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/label-identifiers.test.ts new file mode 100644 index 00000000000..c5b837db904 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/label-identifiers.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { parseSync } from 'oxc-parser'; +import { walk } from 'oxc-walker'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { computeClosureFreeIdentifiers } from '../../../src/optimizer/analysis/closure-free-identifiers.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; +import { RAW_TRANSFER_PARSER_OPTIONS } from '../../../src/ast-types.js'; +import type { AstFunction, AstNode, AstProgram } from '../../../src/ast-types.js'; + +function freeIdentifiersOfFirstArrow(source: string): readonly string[] { + const parsed = parseSync('test.tsx', source, RAW_TRANSFER_PARSER_OPTIONS); + const program = parsed.program as AstProgram; + let arrow: AstFunction | null = null; + walk(program, { + enter(node) { + if (arrow === null && (node as AstNode).type === 'ArrowFunctionExpression') { + arrow = node as AstFunction; + } + }, + }); + if (arrow === null) { + throw new Error('no arrow function found'); + } + return computeClosureFreeIdentifiers(program, new Map([['fn', arrow]])).get(arrow) ?? []; +} + +function segmentCode(source: string): string { + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(source) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'segment' }, + transpileTs: true, + transpileJsx: true, + }); + const segment = result.modules.find((m) => m.kind === 'segment'); + if (!segment) { + throw new Error('segment module not found'); + } + return segment.code; +} + +// Labels live in their own namespace, so `label:` and `break label` never read a +// binding. Counting them as free identifiers drags same-named module-level +// declarations into the segment. +describe('statement labels are not free identifiers', () => { + it('omits a labeled block and its break target from the free identifiers', () => { + const free = freeIdentifiersOfFirstArrow(` +const outside = 1; +const fn = () => { + loop: for (const n of outside) { + if (n) { + break loop; + } + continue loop; + } +}; +`); + expect(free).toEqual(['outside']); + }); + + it('does not migrate a module-level variable that shares a label name', () => { + const code = segmentCode(` +import { formAction$ } from 'forms'; +const label = 'module-level'; +export const action = formAction$((data) => { + label: { + break label; + } + return {}; +}); +`); + expect(code).not.toContain('module-level'); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/lexical-scope-captures.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/lexical-scope-captures.test.ts new file mode 100644 index 00000000000..db4218c455b --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/lexical-scope-captures.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import type { + TransformModule, + SegmentMetadataInternal, +} from '../../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function findSegment(modules: readonly TransformModule[]): TransformModule { + const seg = modules.find((m) => m.kind === 'segment'); + if (!seg) { + throw new Error('expected a segment module in output'); + } + return seg; +} + +function findParent(modules: readonly TransformModule[]): TransformModule { + const parent = modules.find((m) => m.kind === 'parent'); + if (!parent) { + throw new Error('expected a parent module in output'); + } + return parent; +} + +function segmentMeta(m: TransformModule): SegmentMetadataInternal { + if (m.kind !== 'segment') { + throw new Error('expected segment kind'); + } + return m.segment as SegmentMetadataInternal; +} + +function transform(src: string) { + return transformModule({ + input: [{ code: mkSourceText(src), path: mkFilePath('test.tsx') }], + srcDir: mkFilePath('/tmp'), + entryStrategy: { type: 'smart' }, + sourceMaps: false, + mode: 'prod', + transpileJsx: false, + }); +} + +describe('Bug 3 — lexical scope chain for captures', () => { + it('module-level arrow with marker call captures both outer param and outer const', () => { + const src = ` +import { useVisibleTask$, useContext } from '@qwik.dev/core'; +export const usePreventNavigateQrl = (fn) => { + const registerPreventNav = useContext(X); + useVisibleTask$(() => registerPreventNav(fn)); +}; +`; + const result = transform(src); + const seg = findSegment(result.modules); + const meta = segmentMeta(seg); + + expect(meta.captures).toBe(true); + expect(meta.captureNames).toEqual(['fn', 'registerPreventNav']); + + expect(seg.code).toContain('_captures[0]'); + expect(seg.code).toContain('_captures[1]'); + + const parent = findParent(result.modules); + expect(parent.code).toMatch(/\.w\(\[\s*fn,\s*registerPreventNav\s*\]\)/); + }); + + it('module-level function declaration with marker call captures from its scope', () => { + const src = ` +import { useTask$ } from '@qwik.dev/core'; +export function setup(initial) { + const helper = initial * 2; + useTask$(() => helper); +} +`; + const result = transform(src); + const meta = segmentMeta(findSegment(result.modules)); + expect(meta.captureNames).toEqual(['helper']); + expect(meta.captures).toBe(true); + }); + + it('intermediate non-marker function between two markers contributes its scope', () => { + const src = ` +import { component$, useTask$ } from '@qwik.dev/core'; +export const C = component$((props) => { + const f = (x) => { + useTask$(() => props.foo + x); + }; + f(42); +}); +`; + const result = transform(src); + const segs = result.modules.filter((m) => m.kind === 'segment'); + const inner = segs.find((m) => m.kind === 'segment' && m.segment.ctxName === 'useTask$'); + if (!inner) { + throw new Error('expected a useTask$ segment'); + } + const meta = segmentMeta(inner); + expect(meta.captures).toBe(true); + expect(meta.captureNames).toEqual(['props', 'x']); + }); + + it('component$ baseline still works (no regression)', () => { + const src = ` +import { component$, useVisibleTask$, useContext } from '@qwik.dev/core'; +export const C = component$((props) => { + const fn = props.fn; + const registerPreventNav = useContext(X); + useVisibleTask$(() => registerPreventNav(fn)); +}); +`; + const result = transform(src); + const segs = result.modules.filter((m) => m.kind === 'segment'); + const innerTask = segs.find( + (m) => m.kind === 'segment' && m.segment.ctxName === 'useVisibleTask$' + ); + if (!innerTask) { + throw new Error('expected a useVisibleTask$ segment'); + } + expect(segmentMeta(innerTask).captureNames).toEqual(['fn', 'registerPreventNav']); + }); + + it('negative scope: closure that references nothing outside still has no captures', () => { + const src = ` +import { useTask$ } from '@qwik.dev/core'; +export const fn1 = () => { + useTask$(() => 42); +}; +`; + const result = transform(src); + const meta = segmentMeta(findSegment(result.modules)); + expect(meta.captures).toBe(false); + expect(meta.captureNames).toBeUndefined(); + }); + + it('negative scope: sibling functions do not contribute their scope', () => { + const src = ` +import { useTask$ } from '@qwik.dev/core'; +export const target = (a) => { + useTask$(() => a + 1); +}; +export const other = (y) => y * 2; +`; + const result = transform(src); + const meta = segmentMeta(findSegment(result.modules)); + expect(meta.captures).toBe(true); + expect(meta.captureNames).toEqual(['a']); + expect(meta.captureNames).not.toContain('y'); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/module-gather-walk.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/module-gather-walk.test.ts new file mode 100644 index 00000000000..42cfcd5d5a2 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/module-gather-walk.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseSync } from 'oxc-parser'; +import { walk } from 'oxc-walker'; +import { gatherModuleFacts } from '../../../src/optimizer/analysis/module-gather-walk.js'; +import { collectScopeAwareBindings } from '../../../src/optimizer/jsx/jsx.js'; +import { buildClosureLexicalScopes } from '../../../src/optimizer/analysis/capture-analysis.js'; +import { computeSegmentUsage } from '../../../src/optimizer/analysis/variable-migration.js'; +import { + buildExtractionLoopMap, + collectAllScopeEntries, +} from '../../../src/optimizer/jsx/event-capture-promotion.js'; +import { + detectPassivePreventdefaultConflicts, + emitPassiveConflictDiagnostics, +} from '../../../src/optimizer/diagnostics/diagnostic-detection.js'; +import type { ExtractionResult } from '../../../src/optimizer/extraction/extract.js'; +import type { Diagnostic } from '../../../src/optimizer/types/types.js'; +import { parseSnapshot } from '../../../src/testing/snapshot-parser.js'; +import { RAW_TRANSFER_PARSER_OPTIONS } from '../../../src/ast-types.js'; +import type { AstFunction, AstNode, AstProgram } from '../../../src/ast-types.js'; +import { SNAP_DIR } from '../../rust-snapshots.js'; + +interface SyntheticExtraction { + symbolName: string; + argStart: number; + argEnd: number; + callStart: number; + callEnd: number; +} + +function collectFunctionNodes(program: AstProgram): Map { + const nodes = new Map(); + let i = 0; + walk(program, { + enter(node) { + const n = node as AstNode; + if ( + n.type === 'ArrowFunctionExpression' || + n.type === 'FunctionExpression' || + n.type === 'FunctionDeclaration' + ) { + nodes.set(`fn_${i++}`, n as AstFunction); + } + }, + }); + return nodes; +} + +function syntheticExtractions(nodes: Map): SyntheticExtraction[] { + const out: SyntheticExtraction[] = []; + for (const [sym, fn] of nodes) { + out.push({ + symbolName: sym, + argStart: fn.start, + argEnd: fn.end, + callStart: fn.start, + callEnd: fn.end, + }); + } + return out; +} + +function mapOfSetsToPlain(m: ReadonlyMap>): Record { + const out: Record = {}; + for (const [k, v] of m) { + out[k] = [...v].sort(); + } + return out; +} + +function loopMapToComparable( + m: ReadonlyMap< + string, + Array<{ + type: string; + iterVars: string[]; + loopNode: unknown; + loopBodyStart: number; + loopBodyEnd: number; + }> + >, + nodeIds: Map +): Record>> { + const out: Record>> = {}; + for (const [k, stack] of m) { + out[k] = stack.map((lc) => { + if (!nodeIds.has(lc.loopNode)) { + nodeIds.set(lc.loopNode, nodeIds.size); + } + return { + type: lc.type, + iterVars: lc.iterVars, + loopBodyStart: lc.loopBodyStart, + loopBodyEnd: lc.loopBodyEnd, + loopNode: nodeIds.get(lc.loopNode), + }; + }); + } + return out; +} + +function diffFixture(source: string, filename: string): string[] { + const parsed = parseSync(filename, source, RAW_TRANSFER_PARSER_OPTIONS); + if (!parsed.program || parsed.errors?.length) { + return []; + } + const program = parsed.program as AstProgram; + const nodes = collectFunctionNodes(program); + const extractions = syntheticExtractions(nodes); + + const facts = gatherModuleFacts({ + program, + closureNodes: nodes, + usageExtractions: extractions, + loopExtractions: extractions, + repairedCode: source, + scopeEntries: true, + passiveConflicts: true, + scopeBindings: true, + }); + + const mismatches: string[] = []; + const check = (label: string, fused: unknown, oracle: unknown): void => { + const a = JSON.stringify(fused); + const b = JSON.stringify(oracle); + if (a !== b) { + mismatches.push(`${label}: fused=${a} oracle=${b}`); + } + }; + + const oracleLex = buildClosureLexicalScopes(program, nodes); + const fusedLex = new Map>(); + for (const [sym, fn] of nodes) { + const union = facts.closureLexicalScopes.get(fn); + if (union) { + fusedLex.set(sym, union); + } + } + check('lexicalScopes', mapOfSetsToPlain(fusedLex), mapOfSetsToPlain(oracleLex)); + + const oracleLoop = buildExtractionLoopMap( + program, + extractions as unknown as ExtractionResult[], + source + ); + const nodeIds = new Map(); + check( + 'extractionLoopMap', + loopMapToComparable(facts.extractionLoopMap, nodeIds), + loopMapToComparable(oracleLoop.extractionLoopMap, nodeIds) + ); + check( + 'loopBodyVarDecls', + Object.fromEntries(facts.loopBodyVarDecls), + Object.fromEntries(oracleLoop.loopBodyVarDecls) + ); + + check('allScopeEntries', facts.allScopeEntries, collectAllScopeEntries(program)); + + const oracleUsage = computeSegmentUsage(program, extractions); + check( + 'segmentUsage', + mapOfSetsToPlain(facts.segmentUsage), + mapOfSetsToPlain(oracleUsage.segmentUsage) + ); + check('rootUsage', [...facts.rootUsage].sort(), [...oracleUsage.rootUsage].sort()); + + const oracleBindings = collectScopeAwareBindings(program); + const fusedBindings = facts.scopeAwareBindings; + if (!fusedBindings) { + mismatches.push('scopeBindings: projection enabled but no result returned'); + } else { + check( + 'allLocalNames', + [...fusedBindings.allLocalNames].sort(), + [...oracleBindings.allLocalNames].sort() + ); + const classifyDiffs: string[] = []; + walk(program, { + enter(node) { + const n = node as AstNode; + if (n.type === 'Identifier' || n.type === 'JSXIdentifier') { + const fused = fusedBindings.bindings.classify(n.name, n.start); + const oracle = oracleBindings.bindings.classify(n.name, n.start); + if (fused !== oracle) { + classifyDiffs.push(`${n.name}@${n.start}: fused=${fused} oracle=${oracle}`); + } + } + }, + }); + check('scopeBindings.classify', classifyDiffs, []); + } + + const oracleDiags: Diagnostic[] = []; + detectPassivePreventdefaultConflicts(program, filename, source, oracleDiags); + const fusedDiags: Diagnostic[] = []; + emitPassiveConflictDiagnostics(facts.passiveConflicts, filename, source, fusedDiags); + check('passiveConflicts', fusedDiags, oracleDiags); + + return mismatches; +} + +function syntheticManyExtractionSource(count: number): string { + const lines: string[] = ['const shared = 1;']; + for (let i = 0; i < count; i++) { + lines.push( + `export const c${i} = $(() => { const l${i} = ${i}; return g${i} + l${i} + shared; });` + ); + } + return lines.join('\n'); +} + +describe('gatherModuleFacts parity with the per-fact walks', () => { + it('matches on a handcrafted kitchen-sink module', () => { + const source = ` +import { component$, $ } from '@qwik.dev/core'; +const moduleVar = 1; +export const helper = (x) => x + moduleVar; + +export const App = component$(({ prop }) => { + const items = [1, 2, 3]; + let counter = 0; + while (counter < 3) { + const local = counter; + register($(() => use(local, prop))); + counter++; + } + for (const item of items) { + items.map((inner) => $(() => inner + item)); + } + for (let i = 0; i < 2; i++) { + const inLoop = i * 2; + register($(() => inLoop)); + } + return ( +
counter}> + {hoisted()} +
+ ); +}); + +function hoisted() { return laterDecl; } +const laterDecl = 7; +`; + expect(diffFixture(source, 'test.tsx')).toEqual([]); + + const parsed = parseSync('test.tsx', source, RAW_TRANSFER_PARSER_OPTIONS); + const program = parsed.program as AstProgram; + const nodes = collectFunctionNodes(program); + const extractions = syntheticExtractions(nodes); + const facts = gatherModuleFacts({ + program, + closureNodes: nodes, + usageExtractions: extractions, + loopExtractions: extractions, + repairedCode: source, + scopeEntries: true, + passiveConflicts: true, + scopeBindings: true, + }); + expect(facts.passiveConflicts.length).toBe(1); + expect(facts.passiveConflicts[0].eventName).toBe('scroll'); + expect(facts.extractionLoopMap.size).toBeGreaterThan(0); + expect(facts.loopBodyVarDecls.size).toBeGreaterThan(0); + expect(facts.allScopeEntries.some((e) => e.type === 'for-loop')).toBe(true); + expect(facts.closureLexicalScopes.size).toBe(nodes.size); + expect(facts.rootUsage.size).toBeGreaterThan(0); + expect([...facts.closureFreeIdentifiers.values()].some((names) => names.length > 0)).toBe(true); + const sb = facts.scopeAwareBindings; + expect(sb).toBeDefined(); + expect(sb!.allLocalNames.has('moduleVar')).toBe(true); + expect(sb!.bindings.classify('item', source.indexOf('inner + item') + 'inner + '.length)).toBe( + 'const' + ); + expect(sb!.bindings.classify('counter', source.indexOf('counter++'))).toBe('var'); + }); + + it('matches the oracle on a synthetic many-extraction module', () => { + expect(diffFixture(syntheticManyExtractionSource(200), 'test.tsx')).toEqual([]); + }); + + it('matches across the full snapshot fixture corpus', () => { + const snapFiles = readdirSync(SNAP_DIR).filter((f) => f.endsWith('.snap')); + expect(snapFiles.length).toBeGreaterThan(100); + + const failures: string[] = []; + let checkedFixtures = 0; + for (const snapFile of snapFiles) { + const content = readFileSync(join(SNAP_DIR, snapFile), 'utf-8'); + const parsed = parseSnapshot(content); + if (!parsed.input) { + continue; + } + const mismatches = diffFixture(parsed.input, 'test.tsx'); + checkedFixtures += 1; + for (const m of mismatches) { + failures.push(`${snapFile}: ${m}`); + } + } + expect(checkedFixtures).toBeGreaterThan(100); + expect(failures).toEqual([]); + }); +}); + +describe('segment-usage projection bounded behavior', () => { + it('does work proportional to visits + extractions, not visits × extractions', () => { + const source = syntheticManyExtractionSource(200); + const parsed = parseSync('test.tsx', source, RAW_TRANSFER_PARSER_OPTIONS); + const program = parsed.program as AstProgram; + const nodes = collectFunctionNodes(program); + + let reads = 0; + const counted = syntheticExtractions(nodes).map( + (ext) => + new Proxy(ext, { + get(target, prop, receiver) { + reads++; + return Reflect.get(target, prop, receiver); + }, + }) + ); + const facts = gatherModuleFacts({ program, usageExtractions: counted }); + + expect(facts.segmentUsage.size).toBe(counted.length); + const perSegment = [...facts.segmentUsage.values()]; + expect(perSegment.every((used) => used.has('shared'))).toBe(true); + expect(perSegment.some((used) => used.has('l0'))).toBe(false); + expect(facts.rootUsage.has('shared')).toBe(false); + + expect(reads).toBeLessThan(100_000); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/moved-decl-jsx-rewrite.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/moved-decl-jsx-rewrite.test.ts new file mode 100644 index 00000000000..27caa02bf95 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/moved-decl-jsx-rewrite.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import type { TransformModule } from '../../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function findSegmentByCtx( + result: { modules: readonly TransformModule[] }, + ctxName: string +): TransformModule { + const m = result.modules.find((mod) => mod.kind === 'segment' && mod.segment.ctxName === ctxName); + if (!m) { + throw new Error(`segment with ctxName=${ctxName} not found`); + } + return m; +} + +describe('moved-decl JSX carries Qwik rewrite into the segment file', () => { + it('pure-spread JSX in a moved helper compiles to `_jsxSplit` (not React `_jsx`)', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; + +function Hola(props: any) { + return
; +} + +export default component$(() => { + return +
1
+
2
+
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + }); + + const seg = findSegmentByCtx(result, 'component$'); + expect(seg.code).toMatch( + /_jsxSplit\("div", \{ \.\.\._getVarProps\(props\) \}, _getConstProps\(props\)/ + ); + expect(seg.code).not.toMatch(/_jsx\("div", \{ \.\.\.props \}\)/); + expect(seg.code).not.toMatch(/from ["']react\/jsx-runtime["']/); + }); + + it('Qwik core helpers used by the moved decl get imported into the segment', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +function Hola(props: any) { return
; } +export default component$(() => ); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + }); + + const seg = findSegmentByCtx(result, 'component$'); + expect(seg.code).toMatch(/import \{ _jsxSplit \} from ["']@qwik\.dev\/core["']/); + expect(seg.code).toMatch(/import \{ _getVarProps \} from ["']@qwik\.dev\/core["']/); + expect(seg.code).toMatch(/import \{ _getConstProps \} from ["']@qwik\.dev\/core["']/); + }); + + it('non-spread JSX in a moved helper still gets rewritten (positive coverage)', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +function Greet({ name }: any) { return Hello {name}; } +export default component$(() => ); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + }); + + const seg = findSegmentByCtx(result, 'component$'); + expect(seg.code).toMatch(/_jsxSorted\("span"/); + expect(seg.code).not.toMatch(/from ["']react\/jsx-runtime["']/); + }); + + it('fixtures without moved decls keep current behavior (negative scope)', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export default component$(() =>
hello
); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + }); + + const seg = findSegmentByCtx(result, 'component$'); + expect(seg.code).toMatch(/_jsxSorted\("div"/); + expect(seg.code).not.toMatch(/from ["']react\/jsx-runtime["']/); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/scope-query-tracker.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/scope-query-tracker.test.ts new file mode 100644 index 00000000000..e85979c0703 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/scope-query-tracker.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseSync } from 'oxc-parser'; +import { walk } from 'oxc-walker'; +import type { ScopeTrackerNode } from 'oxc-walker'; +import { ScopeQueryTracker } from '../../../src/optimizer/analysis/scope-query-tracker.js'; +import { parseSnapshot } from '../../../src/testing/snapshot-parser.js'; +import { RAW_TRANSFER_PARSER_OPTIONS } from '../../../src/ast-types.js'; +import type { AstNode, AstProgram } from '../../../src/ast-types.js'; +import { SNAP_DIR } from '../../rust-snapshots.js'; + +interface ReplayRecord { + readonly name: string; + readonly scopeKey: string; + readonly decl: ScopeTrackerNode | null; +} + +interface FixtureResult { + readonly mismatches: string[]; + readonly records: number; + readonly resolved: number; +} + +function checkFixture(source: string, filename: string): FixtureResult { + const parsed = parseSync(filename, source, RAW_TRANSFER_PARSER_OPTIONS); + if (!parsed.program || parsed.errors?.length) { + return { mismatches: [], records: 0, resolved: 0 }; + } + const program = parsed.program as AstProgram; + + const tracker = new ScopeQueryTracker({ preserveExitedScopes: true }); + walk(program, { scopeTracker: tracker }); + tracker.freeze(); + + const records: ReplayRecord[] = []; + walk(program, { + scopeTracker: tracker, + enter(node) { + const n = node as AstNode; + if (n.type !== 'Identifier') { + return; + } + records.push({ + name: n.name, + scopeKey: tracker.getCurrentScope(), + decl: tracker.getDeclaration(n.name), + }); + }, + }); + + const mismatches: string[] = []; + let resolved = 0; + for (const { name, scopeKey, decl } of records) { + if (decl !== null) { + resolved++; + } + const fromScope = tracker.getDeclarationFromScope(name, scopeKey); + if (fromScope !== decl) { + mismatches.push( + `${name} @ scope "${scopeKey}": replay=${decl?.type ?? 'null'} ` + + `fromScope=${fromScope?.type ?? 'null'}` + ); + } + } + return { mismatches, records: records.length, resolved }; +} + +describe('ScopeQueryTracker.getDeclarationFromScope equivalence', () => { + it('matches replay-time getDeclaration under shadowing', () => { + const source = ` +const x = 1; +function outer(x) { + const y = x; + { + let x = 2; + use(x, y); + } + return x; +} +use(x); +`; + const result = checkFixture(source, 'test.ts'); + expect(result.mismatches).toEqual([]); + expect(result.resolved).toBeGreaterThan(0); + }); + + it('matches for hoisted function declarations referenced before their decl', () => { + const source = ` +function caller() { + return hoisted() + alsoHoisted(); +} +function hoisted() { + return 1; +} +{ + function alsoHoisted() { return 2; } +} +`; + const result = checkFixture(source, 'test.ts'); + expect(result.mismatches).toEqual([]); + expect(result.resolved).toBeGreaterThan(0); + }); + + it('matches for FunctionExpression own-name (id-holding outer scope)', () => { + const source = ` +const f = function g(n) { + if (n === 0) return 1; + return n * g(n - 1); +}; +const g = 'shadows-nothing-outside'; +use(f, g); +`; + const result = checkFixture(source, 'test.ts'); + expect(result.mismatches).toEqual([]); + expect(result.resolved).toBeGreaterThan(0); + }); + + it('matches for catch params and for-loop head bindings', () => { + const source = ` +const e = 'outer'; +try { + risky(); +} catch (e) { + report(e); +} +for (const e of list) { + use(e); +} +for (let i = 0; i < 3; i++) { + use(i, e); +} +use(e); +`; + const result = checkFixture(source, 'test.ts'); + expect(result.mismatches).toEqual([]); + expect(result.resolved).toBeGreaterThan(0); + }); + + it('matches across every snapshot fixture input', () => { + const snapFiles = readdirSync(SNAP_DIR).filter((f) => f.endsWith('.snap')); + const failures: string[] = []; + let checkedFixtures = 0; + let totalRecords = 0; + let totalResolved = 0; + for (const snapFile of snapFiles) { + const content = readFileSync(join(SNAP_DIR, snapFile), 'utf-8'); + const parsed = parseSnapshot(content); + if (!parsed.input) { + continue; + } + const result = checkFixture(parsed.input, 'test.tsx'); + checkedFixtures += 1; + totalRecords += result.records; + totalResolved += result.resolved; + for (const m of result.mismatches) { + failures.push(`${snapFile}: ${m}`); + } + } + expect(checkedFixtures).toBeGreaterThan(100); + expect(totalRecords).toBeGreaterThan(1000); + expect(totalResolved).toBeGreaterThan(500); + expect(failures).toEqual([]); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/analysis/variable-migration.test.ts b/packages/ts-optimizer/tests/optimizer/analysis/variable-migration.test.ts new file mode 100644 index 00000000000..1d09425576b --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/analysis/variable-migration.test.ts @@ -0,0 +1,543 @@ +import { describe, it, expect } from 'vitest'; +import { parseSync } from 'oxc-parser'; +import { + analyzeMigration, + autoExportedNames, + collectModuleLevelDecls, + computeSegmentUsage, + filterInlineStrategyMigrations, + MIG_REASON, + type MigrationDecision, + type ModuleLevelDecl, +} from '../../../src/optimizer/analysis/variable-migration.js'; + +function makeDecl(overrides: Partial & { name: string }): ModuleLevelDecl { + return { + declStart: 0, + declEnd: 0, + declText: '', + isExported: false, + isDirectlyExported: false, + hasSideEffects: false, + isPartOfSharedDestructuring: false, + kind: 'const', + hasRouterMarkerInit: false, + ...overrides, + }; +} + +describe('analyzeMigration', () => { + it('Test 1: single-use safe variable => move', () => { + const decls = [makeDecl({ name: 'helperFn' })]; + const segmentUsage = new Map([['seg1', new Set(['helperFn'])]]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('move'); + expect(decisions[0].varName).toBe('helperFn'); + expect(decisions[0].targetSegment).toBe('seg1'); + }); + + it('Test 2: shared variable used by two segments => reexport', () => { + const decls = [makeDecl({ name: 'SHARED' })]; + const segmentUsage = new Map([ + ['seg1', new Set(['SHARED'])], + ['seg2', new Set(['SHARED'])], + ]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('reexport'); + expect(decisions[0].varName).toBe('SHARED'); + expect(decisions[0].targetSegment).toBeUndefined(); + }); + + it('Test 3: exported variable used by segment => reexport (MIG-03)', () => { + const decls = [makeDecl({ name: 'publicHelper', isExported: true })]; + const segmentUsage = new Map([['seg1', new Set(['publicHelper'])]]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('reexport'); + expect(decisions[0].varName).toBe('publicHelper'); + }); + + it('Test 4: side-effect declaration used by one segment => reexport (MIG-04)', () => { + const decls = [makeDecl({ name: 'state', hasSideEffects: true })]; + const segmentUsage = new Map([['seg1', new Set(['state'])]]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('reexport'); + expect(decisions[0].varName).toBe('state'); + }); + + it('Test 5: safe literals are eligible for move if single-use', () => { + const decls = [ + makeDecl({ name: 'COUNT' }), + makeDecl({ name: 'NAME' }), + makeDecl({ name: 'FN' }), + ]; + const segmentUsage = new Map([ + ['seg1', new Set(['COUNT'])], + ['seg2', new Set(['NAME'])], + ['seg3', new Set(['FN'])], + ]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(3); + for (const d of decisions) { + expect(d.action).toBe('move'); + } + expect(decisions[0].targetSegment).toBe('seg1'); + expect(decisions[1].targetSegment).toBe('seg2'); + expect(decisions[2].targetSegment).toBe('seg3'); + }); + + it('Test 6: destructuring with shared bindings => reexport (MIG-05)', () => { + const decls = [makeDecl({ name: 'a', isPartOfSharedDestructuring: true })]; + const segmentUsage = new Map([['seg1', new Set(['a'])]]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('reexport'); + expect(decisions[0].varName).toBe('a'); + }); + + it('Test 6a: shared destructure all flowing to one segment => move (MIG-05a)', () => { + const decls = [ + makeDecl({ name: 'a', isPartOfSharedDestructuring: true, declStart: 10, declEnd: 30 }), + makeDecl({ name: 'b', isPartOfSharedDestructuring: true, declStart: 10, declEnd: 30 }), + ]; + const segmentUsage = new Map([['seg1', new Set(['a', 'b'])]]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(2); + expect(decisions[0].action).toBe('move'); + expect(decisions[0].targetSegment).toBe('seg1'); + expect(decisions[1].action).toBe('move'); + expect(decisions[1].targetSegment).toBe('seg1'); + }); + + it('Test 6b: shared destructure split across segments => reexport', () => { + const decls = [ + makeDecl({ name: 'a', isPartOfSharedDestructuring: true, declStart: 10, declEnd: 30 }), + makeDecl({ name: 'b', isPartOfSharedDestructuring: true, declStart: 10, declEnd: 30 }), + ]; + const segmentUsage = new Map([ + ['seg1', new Set(['a'])], + ['seg2', new Set(['b'])], + ]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions[0].action).toBe('reexport'); + expect(decisions[1].action).toBe('reexport'); + }); + + it('Test 6c: shared destructure with one binding used by root => reexport', () => { + const decls = [ + makeDecl({ name: 'a', isPartOfSharedDestructuring: true, declStart: 10, declEnd: 30 }), + makeDecl({ name: 'b', isPartOfSharedDestructuring: true, declStart: 10, declEnd: 30 }), + ]; + const segmentUsage = new Map([['seg1', new Set(['a'])]]); + const rootUsage = new Set(['b']); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(2); + expect(decisions[0].action).toBe('reexport'); + expect(decisions[0].varName).toBe('a'); + expect(decisions[1].action).toBe('keep'); + expect(decisions[1].varName).toBe('b'); + }); + + it('Test 7: pure variable not used anywhere => drop', () => { + const decls = [makeDecl({ name: 'unused' })]; + const segmentUsage = new Map>(); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('drop'); + expect(decisions[0].varName).toBe('unused'); + }); + + it('Test 7b: side-effectful variable not used anywhere => keep', () => { + const decls = [makeDecl({ name: 'unused', hasSideEffects: true })]; + const segmentUsage = new Map>(); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('keep'); + expect(decisions[0].varName).toBe('unused'); + }); + + it('Test 8: variable used by root AND a segment => reexport', () => { + const decls = [makeDecl({ name: 'config' })]; + const segmentUsage = new Map([['seg1', new Set(['config'])]]); + const rootUsage = new Set(['config']); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('reexport'); + expect(decisions[0].varName).toBe('config'); + }); + + it('Test 9: arrow function expression => safe, eligible for move', () => { + const decls = [makeDecl({ name: 'arrowFn', hasSideEffects: false })]; + const segmentUsage = new Map([['seg1', new Set(['arrowFn'])]]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('move'); + }); + + it('Test 10: function call as initializer => has side effects, not eligible for move', () => { + const decls = [makeDecl({ name: 'cfg', hasSideEffects: true })]; + const segmentUsage = new Map([['seg1', new Set(['cfg'])]]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('reexport'); + }); + + it('exported variable NOT used by any segment => keep', () => { + const decls = [makeDecl({ name: 'publicApi', isExported: true })]; + const segmentUsage = new Map>(); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe('keep'); + }); +}); + +describe('collectModuleLevelDecls', () => { + function parse(code: string) { + return parseSync('test.tsx', code).program; + } + + it('collects const declarations with literal initializers as safe', () => { + const program = parse('const COUNT = 42;\nconst NAME = "hello";'); + const decls = collectModuleLevelDecls(program, 'const COUNT = 42;\nconst NAME = "hello";'); + + expect(decls).toHaveLength(2); + expect(decls[0].name).toBe('COUNT'); + expect(decls[0].hasSideEffects).toBe(false); + expect(decls[0].kind).toBe('const'); + expect(decls[1].name).toBe('NAME'); + expect(decls[1].hasSideEffects).toBe(false); + }); + + it('detects arrow function as safe (no side effects)', () => { + const code = 'const fn = (x) => x + 1;'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(1); + expect(decls[0].name).toBe('fn'); + expect(decls[0].hasSideEffects).toBe(false); + }); + + it('detects function call as having side effects', () => { + const code = 'const state = createConfig();'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(1); + expect(decls[0].name).toBe('state'); + expect(decls[0].hasSideEffects).toBe(true); + }); + + it('detects exported declarations', () => { + const code = 'export const helper = () => {};'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(1); + expect(decls[0].name).toBe('helper'); + expect(decls[0].isExported).toBe(true); + }); + + it('detects destructuring with multiple bindings', () => { + const code = 'const { a, b } = obj;'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(2); + expect(decls[0].name).toBe('a'); + expect(decls[0].isPartOfSharedDestructuring).toBe(true); + expect(decls[1].name).toBe('b'); + expect(decls[1].isPartOfSharedDestructuring).toBe(true); + }); + + it('handles FunctionDeclaration', () => { + const code = 'function greet(name) { return "hi " + name; }'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(1); + expect(decls[0].name).toBe('greet'); + expect(decls[0].hasSideEffects).toBe(false); + expect(decls[0].kind).toBe('function'); + }); + + it('treats Math.random() call as side effect', () => { + const code = 'const id = Math.random();'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(1); + expect(decls[0].hasSideEffects).toBe(true); + }); + + it('treats object/array literals with only safe values as safe', () => { + const code = 'const obj = { x: 1, y: "two" };\nconst arr = [1, 2, 3];'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(2); + expect(decls[0].hasSideEffects).toBe(false); + expect(decls[1].hasSideEffects).toBe(false); + }); + + it('treats object with call expression value as having side effects', () => { + const code = 'const obj = { x: getVal() };'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(1); + expect(decls[0].hasSideEffects).toBe(true); + }); + + it('collects single-binding destructuring as NOT shared', () => { + const code = 'const { a } = obj;'; + const program = parse(code); + const decls = collectModuleLevelDecls(program, code); + + expect(decls).toHaveLength(1); + expect(decls[0].name).toBe('a'); + expect(decls[0].isPartOfSharedDestructuring).toBe(false); + }); +}); + +describe('computeSegmentUsage', () => { + function parse(code: string) { + return parseSync('test.tsx', code).program; + } + + it('attributes identifiers inside extraction range to segments', () => { + const code = 'const x = 1;\ncomponent$(() => { return x; });'; + const program = parse(code); + + const arrowStart = code.indexOf('() =>'); + const arrowEnd = code.lastIndexOf(')'); + + const { segmentUsage, rootUsage } = computeSegmentUsage(program, [ + { symbolName: 'seg1', argStart: arrowStart, argEnd: arrowEnd }, + ]); + + expect(segmentUsage.get('seg1')?.has('x')).toBe(true); + expect(rootUsage.has('x')).toBe(false); + }); + + it('attributes identifiers outside all ranges to root', () => { + const code = 'const y = foo();\ncomponent$(() => { return 42; });'; + const program = parse(code); + + const arrowStart = code.indexOf('() =>'); + const arrowEnd = code.lastIndexOf(')'); + + const { rootUsage } = computeSegmentUsage(program, [ + { symbolName: 'seg1', argStart: arrowStart, argEnd: arrowEnd }, + ]); + + expect(rootUsage.has('foo')).toBe(true); + }); + + it('does not count property-position identifiers as usage', () => { + const code = [ + "const startVT = (p) => { if ('x' in document) document.startVT(p); };", + 'const cfg = { startVT: 1 };', + 'component$(() => { startVT(cfg); });', + ].join('\n'); + const program = parse(code); + + const arrowStart = code.indexOf('() => { startVT'); + const arrowEnd = code.length - 2; + + const { segmentUsage, rootUsage } = computeSegmentUsage(program, [ + { symbolName: 'seg1', argStart: arrowStart, argEnd: arrowEnd }, + ]); + + expect(segmentUsage.get('seg1')?.has('startVT')).toBe(true); + expect(rootUsage.has('startVT')).toBe(false); + expect(segmentUsage.get('seg1')?.has('cfg')).toBe(true); + }); +}); + +describe('analyzeMigration MIG-06 (moved-decl dependencies)', () => { + function declsFor(code: string): { + program: ReturnType['program']; + decls: ModuleLevelDecl[]; + } { + const program = parseSync('test.tsx', code).program; + const decls = collectModuleLevelDecls(program, code); + return { program, decls }; + } + + it('flips an un-exported keep dependency of a moved decl to reexport', () => { + const code = [ + "const SETTINGS = { mode: 'mock' };", + 'const useHelper = (props) => { return SETTINGS.mode + props.x; };', + 'console.log(SETTINGS.mode);', + ].join('\n'); + const { program, decls } = declsFor(code); + + const segmentUsage = new Map([['segA', new Set(['useHelper'])]]); + const rootUsage = new Set(['SETTINGS']); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage, program); + + const byName = new Map(decisions.map((d) => [d.varName, d])); + expect(byName.get('useHelper')?.action).toBe('move'); + expect(byName.get('SETTINGS')?.action).toBe('reexport'); + expect(byName.get('SETTINGS')?.reason).toContain('MIG-06'); + }); + + it('moves a keep dependency used only by movers to the same segment into it', () => { + const code = [ + "const SETTINGS = { mode: 'mock' };", + 'const useHelper = (props) => { return SETTINGS.mode + props.x; };', + ].join('\n'); + const { program, decls } = declsFor(code); + + const segmentUsage = new Map([['segA', new Set(['useHelper'])]]); + const rootUsage = new Set(['SETTINGS']); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage, program); + + const byName = new Map(decisions.map((d) => [d.varName, d])); + expect(byName.get('useHelper')?.action).toBe('move'); + expect(byName.get('SETTINGS')?.action).toBe('move'); + expect(byName.get('SETTINGS')?.targetSegment).toBe('segA'); + expect(byName.get('SETTINGS')?.reason).toContain('MIG-06a'); + }); + + it('leaves exported dependencies alone (plain import path handles them)', () => { + const code = [ + 'export const API = { url: 1 };', + 'const useHelper = (props) => { return API.url + props.x; };', + ].join('\n'); + const { program, decls } = declsFor(code); + + const segmentUsage = new Map([['segA', new Set(['useHelper'])]]); + const rootUsage = new Set(['API']); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage, program); + + const byName = new Map(decisions.map((d) => [d.varName, d])); + expect(byName.get('useHelper')?.action).toBe('move'); + expect(byName.get('API')?.action).toBe('keep'); + }); + + it('demotes a dependency moving to a different segment to reexport', () => { + const code = ['const shared = () => 1;', 'const helperA = () => shared();'].join('\n'); + const { program, decls } = declsFor(code); + + const segmentUsage = new Map([ + ['segA', new Set(['helperA'])], + ['segB', new Set(['shared'])], + ]); + const rootUsage = new Set(); + + const decisions = analyzeMigration(decls, segmentUsage, rootUsage, program); + + const byName = new Map(decisions.map((d) => [d.varName, d])); + expect(byName.get('helperA')?.action).toBe('move'); + expect(byName.get('shared')?.action).toBe('reexport'); + expect(byName.get('shared')?.reason).toContain('MIG-06'); + }); + + it('does not flip dependencies of decls that stay in the parent', () => { + const code = ['const SETTINGS = { mode: 1 };', 'const stays = () => SETTINGS.mode;'].join('\n'); + const { program, decls } = declsFor(code); + + const decisions = analyzeMigration(decls, new Map(), new Set(['SETTINGS', 'stays']), program); + + const byName = new Map(decisions.map((d) => [d.varName, d])); + expect(byName.get('stays')?.action).toBe('keep'); + expect(byName.get('SETTINGS')?.action).toBe('keep'); + }); +}); + +describe('filterInlineStrategyMigrations', () => { + const decisions: MigrationDecision[] = [ + { action: 'reexport', varName: 'exported', reason: MIG_REASON.REEXPORT_EXPORTED }, + { action: 'reexport', varName: 'dualUse', reason: MIG_REASON.REEXPORT_DUAL_USE }, + { action: 'reexport', varName: 'multiSegment', reason: MIG_REASON.REEXPORT_MULTI_SEGMENT }, + { action: 'reexport', varName: 'sideEffect', reason: MIG_REASON.REEXPORT_SIDE_EFFECTS }, + { + action: 'reexport', + varName: 'sharedDestructure', + reason: MIG_REASON.REEXPORT_SHARED_DESTRUCTURE, + }, + { action: 'reexport', varName: 'movedDep', reason: MIG_REASON.REEXPORT_MOVED_DECL_DEP }, + { action: 'move', varName: 'mover', reason: MIG_REASON.MOVE_SINGLE_SEGMENT }, + { action: 'keep', varName: 'kept', reason: MIG_REASON.KEEP_UNUSED }, + ]; + + it('keeps only the exported (MIG-03) and multi-segment/dual-use (MIG-02) reexports', () => { + const result = filterInlineStrategyMigrations(decisions); + expect(result.map((d) => d.varName)).toEqual(['exported', 'dualUse', 'multiSegment']); + }); + + it('drops the side-effect (MIG-04) reexport for a decl consumed by an inline segment', () => { + const result = filterInlineStrategyMigrations(decisions); + expect(result.some((d) => d.varName === 'sideEffect')).toBe(false); + }); + + it('drops every move (which would delete a still-referenced in-parent decl)', () => { + const result = filterInlineStrategyMigrations(decisions); + expect(result.some((d) => d.action === 'move')).toBe(false); + }); +}); + +describe('autoExportedNames', () => { + it('sorts private exports by name', () => { + expect( + autoExportedNames( + [ + { action: 'reexport', varName: 'scrollState', reason: MIG_REASON.REEXPORT_MULTI_SEGMENT }, + { action: 'reexport', varName: 'saveScroll', reason: MIG_REASON.REEXPORT_MULTI_SEGMENT }, + ], + [] + ) + ).toEqual(['saveScroll', 'scrollState']); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/ast/guards.test.ts b/packages/ts-optimizer/tests/optimizer/ast/guards.test.ts new file mode 100644 index 00000000000..0db84e773c6 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/ast/guards.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import { forEachAstChild, someAstChild } from '../../../src/optimizer/ast/guards.js'; +import type { AstCompatNode, AstNode } from '../../../src/ast-types.js'; + +function mkNode(type: string, extra: Record = {}): AstCompatNode { + return { type, start: 0, end: 0, ...extra }; +} + +describe('someAstChild', () => { + it('returns false when node is null/undefined', () => { + expect(someAstChild(null, () => true)).toBe(false); + expect(someAstChild(undefined, () => true)).toBe(false); + }); + + it('returns false when node has no AST-node children', () => { + const node = mkNode('Foo', { name: 'x', value: 42 }); + expect(someAstChild(node, () => true)).toBe(false); + }); + + it('returns true on first matching direct child', () => { + const child = mkNode('Bar'); + const node = mkNode('Foo', { body: child }); + let calls = 0; + const result = someAstChild(node, () => { + calls++; + return true; + }); + expect(result).toBe(true); + expect(calls).toBe(1); + }); + + it('returns true on first matching array element', () => { + const a = mkNode('A'); + const b = mkNode('B'); + const node = mkNode('Foo', { items: [a, b] }); + const seen: string[] = []; + const result = someAstChild(node, (child) => { + seen.push(child.type); + return (child.type as string) === 'B'; + }); + expect(result).toBe(true); + expect(seen).toEqual(['A', 'B']); + }); + + it('short-circuits — does not visit later children after first match', () => { + const a = mkNode('A'); + const b = mkNode('B'); + const c = mkNode('C'); + const node = mkNode('Foo', { items: [a, b, c] }); + const seen: string[] = []; + someAstChild(node, (child) => { + seen.push(child.type); + return (child.type as string) === 'B'; + }); + expect(seen).toEqual(['A', 'B']); + expect(seen).not.toContain('C'); + }); + + it('returns false when no child matches', () => { + const node = mkNode('Foo', { a: mkNode('A'), b: mkNode('B') }); + let calls = 0; + const result = someAstChild(node, () => { + calls++; + return false; + }); + expect(result).toBe(false); + expect(calls).toBe(2); + }); + + it('skips meta keys (type/start/end/loc/range) by default', () => { + const node = mkNode('Foo', { + type: 'Foo', + start: 0, + end: 10, + loc: [0, 10], + range: [0, 10], + }); + expect(someAstChild(node, () => true)).toBe(false); + }); + + it('passes correct key + parent to predicate', () => { + const child = mkNode('Bar'); + const parent = mkNode('Foo', { body: child }); + let capturedKey: string | null = null; + let capturedParent: AstNode | null = null; + someAstChild(parent, (c, key, p) => { + capturedKey = key; + capturedParent = p; + return true; + }); + expect(capturedKey).toBe('body'); + expect(capturedParent).toBe(parent); + }); +}); + +describe('forEachAstChild', () => { + it('visits all AST-node children', () => { + const a = mkNode('A'); + const b = mkNode('B'); + const node = mkNode('Foo', { left: a, right: b }); + const seen: string[] = []; + forEachAstChild(node, (child) => { + seen.push(child.type); + }); + expect(seen.sort()).toEqual(['A', 'B']); + }); + + it('visits array elements when they are AST nodes', () => { + const items = [mkNode('A'), mkNode('B'), mkNode('C')]; + const node = mkNode('Foo', { items }); + const seen: string[] = []; + forEachAstChild(node, (child) => { + seen.push(child.type); + }); + expect(seen).toEqual(['A', 'B', 'C']); + }); + + it('skips non-object values', () => { + const node = mkNode('Foo', { count: 5, name: 'x', flag: true }); + const seen: string[] = []; + forEachAstChild(node, (child) => { + seen.push(child.type); + }); + expect(seen).toEqual([]); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/ast/identifier-name.test.ts b/packages/ts-optimizer/tests/optimizer/ast/identifier-name.test.ts new file mode 100644 index 00000000000..765d2d86afe --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/ast/identifier-name.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { + buildPropertyAccessor, + isSimpleIdentifierName, +} from '../../../src/optimizer/ast/identifier-name.js'; + +describe('identifier-name', () => { + it('detects plain identifier property names', () => { + expect(isSimpleIdentifierName('count')).toBe(true); + expect(isSimpleIdentifierName('_count2')).toBe(true); + expect(isSimpleIdentifierName('$value')).toBe(true); + + expect(isSimpleIdentifierName('bind:value')).toBe(false); + expect(isSimpleIdentifierName('kebab-case')).toBe(false); + expect(isSimpleIdentifierName('1count')).toBe(false); + }); + + it('builds dot or bracket accessors as needed', () => { + expect(buildPropertyAccessor('_rawProps', 'count')).toBe('_rawProps.count'); + expect(buildPropertyAccessor('_rawProps', 'bind:value')).toBe('_rawProps["bind:value"]'); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/ast/walk-with-protocol.test.ts b/packages/ts-optimizer/tests/optimizer/ast/walk-with-protocol.test.ts new file mode 100644 index 00000000000..040664450c2 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/ast/walk-with-protocol.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import MagicString from 'magic-string'; +import { parseWithRawTransfer } from '../../../src/optimizer/ast/parse.js'; +import { walkWithProtocol } from '../../../src/optimizer/ast/walk-with-protocol.js'; +import { walk } from 'oxc-walker'; +import type { AstNode, AstProgram } from '../../../src/ast-types.js'; + +function parse(source: string): AstProgram { + return parseWithRawTransfer('test.tsx', source).program; +} + +describe('walkWithProtocol — behavioural parity with raw walk', () => { + it('visits the same node types in the same order as raw walk', () => { + const source = `const x = 1; function f(a) { return a + 1; }`; + const program = parse(source); + + const rawOrder: string[] = []; + walk(program, { + enter(node) { + rawOrder.push(`enter:${node.type}`); + }, + leave(node) { + rawOrder.push(`leave:${node.type}`); + }, + }); + + const wrappedOrder: string[] = []; + walkWithProtocol( + parse(source), + {}, + {}, + { + enter(node) { + wrappedOrder.push(`enter:${node.type}`); + }, + leave(node) { + wrappedOrder.push(`leave:${node.type}`); + }, + } + ); + + expect(wrappedOrder).toEqual(rawOrder); + }); + + it('produces enter-before-children-before-leave for nested nodes', () => { + const source = `const x = { a: 1 };`; + const trace: string[] = []; + + walkWithProtocol( + parse(source), + {}, + {}, + { + enter(node) { + trace.push(`enter:${node.type}`); + }, + leave(node) { + trace.push(`leave:${node.type}`); + }, + } + ); + + const objEnter = trace.indexOf('enter:ObjectExpression'); + const propEnter = trace.indexOf('enter:Property'); + const propLeave = trace.indexOf('leave:Property'); + const objLeave = trace.indexOf('leave:ObjectExpression'); + expect(objEnter).toBeGreaterThanOrEqual(0); + expect(propEnter).toBeGreaterThan(objEnter); + expect(propLeave).toBeGreaterThan(propEnter); + expect(objLeave).toBeGreaterThan(propLeave); + }); + + it('preserves this.skip() — children of skipped nodes are not visited', () => { + const source = `const x = { a: 1, b: { c: 2 } };`; + const visited: string[] = []; + + walkWithProtocol( + parse(source), + {}, + {}, + { + enter(node) { + visited.push(node.type); + if (node.type === 'ObjectExpression') { + this.skip(); + } + }, + leave() { + /* no-op */ + }, + } + ); + + expect(visited).toContain('ObjectExpression'); + expect(visited).not.toContain('Property'); + }); + + it('threads the parent argument identically to raw walk', () => { + const source = `const x = 1;`; + const parentPairs: Array<[string, string | null]> = []; + + walkWithProtocol( + parse(source), + {}, + {}, + { + enter(node, parent) { + parentPairs.push([node.type, parent?.type ?? null]); + }, + leave() {}, + } + ); + + expect(parentPairs).toContainEqual(['Program', null]); + expect(parentPairs).toContainEqual(['VariableDeclaration', 'Program']); + expect(parentPairs).toContainEqual(['VariableDeclarator', 'VariableDeclaration']); + }); +}); + +describe('walkWithProtocol — context delivery', () => { + it('delivers enterCtx to enter, exitCtx to leave', () => { + interface E { + source: string; + readonly enterMark: string; + } + interface X extends E { + readonly exitMark: string; + } + + const enterCtx: E = { source: 'src', enterMark: 'E' }; + const exitCtx: X = { ...enterCtx, exitMark: 'X' }; + + const seen: string[] = []; + walkWithProtocol(parse(`const x = 1;`), enterCtx, exitCtx, { + enter(_node, _parent, ctx) { + seen.push(`enter:${ctx.enterMark}`); + }, + leave(_node, _parent, ctx) { + seen.push(`leave:${ctx.enterMark}|${ctx.exitMark}`); + }, + }); + + expect(seen.filter((s) => s.startsWith('enter:'))).toContain('enter:E'); + expect(seen.filter((s) => s.startsWith('leave:'))).toContain('leave:E|X'); + }); + + it('allows enter and leave to share underlying state via closure', () => { + interface E { + recordEnter(t: string): void; + } + interface X extends E { + drain(): string[]; + } + + const buffer: string[] = []; + const enterCtx: E = { recordEnter: (t) => buffer.push(t) }; + const exitCtx: X = { ...enterCtx, drain: () => buffer.splice(0) }; + + const drained: string[] = []; + walkWithProtocol(parse(`const x = 1;`), enterCtx, exitCtx, { + enter(node, _parent, ctx) { + ctx.recordEnter(node.type); + }, + leave(node, _parent, ctx) { + if (node.type === 'Program') { + drained.push(...ctx.drain()); + } + }, + }); + + expect(drained).toContain('Program'); + expect(drained).toContain('VariableDeclaration'); + }); +}); + +function _typeOnlyProtocolEnforcement(): void { + interface E { + readonly source: string; + } + interface X extends E { + readonly s: MagicString; + } + + const enterCtx: E = { source: 'src' }; + const exitCtx: X = { ...enterCtx, s: new MagicString('src') }; + + walkWithProtocol({} as AstProgram, enterCtx, exitCtx, { + enter(_node, _parent, ctx) { + // @ts-expect-error - `s` only exists on ExitContext + ctx.s.overwrite(0, 1, 'y'); + + void ctx.source; + }, + leave(_node, _parent, ctx) { + void ctx.s; + void ctx.source; + }, + }); +} + +describe('walkWithProtocol — compile-time enforcement', () => { + it('keeps the type-only assertions reachable for tsc', () => { + expect(typeof _typeOnlyProtocolEnforcement).toBe('function'); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/convergence.test.ts b/packages/ts-optimizer/tests/optimizer/convergence.test.ts new file mode 100644 index 00000000000..92032ec0185 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/convergence.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { isDeepStrictEqual } from 'node:util'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseSnapshot } from '../../src/testing/snapshot-parser.js'; +import { compareAst } from '../../src/testing/ast-compare.js'; +import { compareMetadata } from '../../src/testing/metadata-compare.js'; +import { transformModule } from '../../src/optimizer/transform/index.js'; +import type { SegmentMetadataInternal, TransformOutput } from '../../src/optimizer/types/types.js'; +import type { SegmentMetadata } from '../../src/testing/snapshot-parser.js'; +import { getSnapshotFiles } from '../../src/testing/batch-runner.js'; +import { getSnapshotTransformOptions } from './snapshot-options.js'; +import { SNAP_DIR } from '../rust-snapshots.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TS_OUTPUT_DIR = join(__dirname, '../../ts-output'); +const DERIVED_DIAGNOSTIC_POSITION_KEYS = new Set(['startLine', 'startCol', 'endLine', 'endCol']); + +mkdirSync(TS_OUTPUT_DIR, { recursive: true }); + +function formatSnapshot( + input: string | null, + inputs: ReadonlyArray<{ path: string; code: string }>, + result: TransformOutput +): string { + const lines: string[] = []; + if (input !== null) { + lines.push('==INPUT==\n', input); + } else { + for (const namedInput of inputs) { + lines.push(`==INPUT ${namedInput.path}==\n\n`, namedInput.code, '\n'); + } + } + + for (const mod of result.modules) { + const isEntry = mod.kind === 'segment'; + const header = isEntry + ? `\n============================= ${mod.path} (ENTRY POINT)==\n` + : `\n============================= ${mod.path} ==\n`; + lines.push(header); + lines.push(mod.code); + } + + const diag = JSON.stringify(result.diagnostics, null, 2); + lines.push(`\n== DIAGNOSTICS ==\n\n${diag}\n`); + return lines.join(''); +} + +function getTestName(snapFilename: string): string { + return snapFilename.replace('qwik_core__test__', '').replace('.snap', ''); +} + +function normalizeDiagnostics(diagnostics: unknown): unknown { + return JSON.parse( + JSON.stringify(diagnostics, (key, value) => + DERIVED_DIAGNOSTIC_POSITION_KEYS.has(key) ? undefined : value + ) + ); +} + +describe('convergence: all snapshots', () => { + const allFiles = getSnapshotFiles(SNAP_DIR); + const parityDifferences: Array<{ snapshot: string; module: string; difference: string }> = []; + + const results = { + total: 0, + fullPass: 0, + parentOnlyFail: 0, + segmentOnlyFail: 0, + fullFail: 0, + noInput: 0, + error: 0, + }; + + for (const snapFile of allFiles) { + const testName = getTestName(snapFile); + const fullPath = join(SNAP_DIR, snapFile); + + it(`${testName}`, () => { + results.total++; + + const content = readFileSync(fullPath, 'utf-8'); + const parsed = parseSnapshot(content); + + if (!parsed.input && parsed.inputs.length === 0) { + results.noInput++; + return; + } + + const options = getSnapshotTransformOptions( + testName, + parsed.input ?? parsed.inputs[0].code, + parsed.inputs.length > 0 ? parsed.inputs : undefined + ); + + let result; + try { + result = transformModule(options); + } catch (err) { + results.error++; + throw new Error(`transformModule() threw for ${testName}: ${err}`); + } + + writeFileSync( + join(TS_OUTPUT_DIR, snapFile), + formatSnapshot(parsed.input, parsed.inputs, result) + ); + + let parentMatches = true; + let segmentsMatch = true; + const actualParents = result.modules.filter((module) => module.kind === 'parent'); + const actualSegments = result.modules.filter((module) => module.kind === 'segment'); + + if (actualParents.length !== parsed.parentModules.length) { + parentMatches = false; + parityDifferences.push({ + snapshot: testName, + module: 'parent', + difference: `module count: expected ${parsed.parentModules.length}, received ${actualParents.length}`, + }); + } + + for (let index = 0; index < parsed.parentModules.length; index++) { + const expectedParent = parsed.parentModules[index]; + const actualParent = actualParents[index]; + + if (!actualParent) { + parentMatches = false; + } else { + if (actualParent.path !== expectedParent.filename) { + parentMatches = false; + parityDifferences.push({ + snapshot: testName, + module: 'parent', + difference: `path: expected ${expectedParent.filename}, received ${actualParent.path}`, + }); + } + const parseFilename = expectedParent.filename || 'test.tsx'; + const astResult = compareAst(expectedParent.code, actualParent.code, parseFilename); + parentMatches &&= astResult.match; + if (astResult.difference) { + parityDifferences.push({ + snapshot: testName, + module: 'parent', + difference: astResult.difference, + }); + } + } + } + + const expectedSegmentNames = new Set(); + for (const expectedSeg of parsed.segments) { + if (!expectedSeg.metadata) { + segmentsMatch = false; + parityDifferences.push({ + snapshot: testName, + module: expectedSeg.filename, + difference: 'missing expected segment metadata', + }); + continue; + } + expectedSegmentNames.add(expectedSeg.metadata.name); + + const actualSeg = actualSegments.find( + (module) => module.segment.name === expectedSeg.metadata!.name + ); + + if (!actualSeg) { + segmentsMatch = false; + parityDifferences.push({ + snapshot: testName, + module: expectedSeg.metadata.name, + difference: 'missing actual segment', + }); + continue; + } + + const parseFilename = expectedSeg.filename || 'test.tsx'; + const astResult = compareAst(expectedSeg.code, actualSeg.code, parseFilename); + if (!astResult.match) { + segmentsMatch = false; + parityDifferences.push({ + snapshot: testName, + module: expectedSeg.metadata.name, + difference: astResult.difference ?? 'unknown AST difference', + }); + } + + const metadataResult = compareMetadata( + expectedSeg.metadata, + actualSeg.segment as SegmentMetadataInternal as unknown as SegmentMetadata + ); + if (!metadataResult.match) { + segmentsMatch = false; + for (const mismatch of metadataResult.mismatches) { + parityDifferences.push({ + snapshot: testName, + module: expectedSeg.metadata.name, + difference: `metadata.${mismatch.field}: expected ${JSON.stringify(mismatch.expected)}, received ${JSON.stringify(mismatch.actual)}`, + }); + } + } + } + + for (const actualSeg of actualSegments) { + if (!expectedSegmentNames.has(actualSeg.segment.name)) { + segmentsMatch = false; + parityDifferences.push({ + snapshot: testName, + module: actualSeg.segment.name, + difference: 'unexpected actual segment', + }); + } + } + + if ( + !isDeepStrictEqual( + normalizeDiagnostics(parsed.diagnostics), + normalizeDiagnostics(result.diagnostics) + ) + ) { + parentMatches = false; + parityDifferences.push({ + snapshot: testName, + module: 'diagnostics', + difference: `expected ${JSON.stringify(parsed.diagnostics)}, received ${JSON.stringify(result.diagnostics)}`, + }); + } + + if (parentMatches && segmentsMatch) { + results.fullPass++; + } else if (!parentMatches && segmentsMatch) { + results.parentOnlyFail++; + } else if (parentMatches && !segmentsMatch) { + results.segmentOnlyFail++; + } else { + results.fullFail++; + } + }); + } + + it('convergence summary', () => { + writeFileSync( + join(TS_OUTPUT_DIR, 'parity-differences.json'), + JSON.stringify(parityDifferences, null, 2) + '\n' + ); + console.log('\n=== CONVERGENCE SUMMARY ==='); + console.log(`Total: ${results.total}`); + console.log(`Full pass: ${results.fullPass}`); + console.log(`Parent-only fail: ${results.parentOnlyFail}`); + console.log(`Segment-only fail:${results.segmentOnlyFail}`); + console.log(`Full fail: ${results.fullFail}`); + console.log(`No input: ${results.noInput}`); + console.log(`Error/throw: ${results.error}`); + console.log( + `Pass rate: ${results.total > 0 ? ((results.fullPass / results.total) * 100).toFixed(1) : 0}%` + ); + console.log('===========================\n'); + + expect(results.total).toBe(allFiles.length); + expect(results.noInput).toBe(0); + expect(results.error).toBe(0); + expect(parityDifferences).toEqual([]); + expect(results.fullPass).toBe(results.total); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/diagnostics/diagnostics.test.ts b/packages/ts-optimizer/tests/optimizer/diagnostics/diagnostics.test.ts new file mode 100644 index 00000000000..e046e7ecf17 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/diagnostics/diagnostics.test.ts @@ -0,0 +1,379 @@ +import { describe, it, expect } from 'vitest'; +import { + emitC02, + emitC05, + emitPassiveConflictWarning, + parseDisableDirectives, + filterSuppressedDiagnostics, +} from '../../../src/optimizer/diagnostics/diagnostics.js'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import type { Diagnostic } from '../../../src/optimizer/types/types.js'; +import { + mkByteOffset, + mkColumnNumber, + mkFilePath, + mkLineNumber, + mkSourceText, +} from '../../../src/optimizer/types/brands.js'; + +describe('emitC02', () => { + it('produces error with code C02 for a function reference', () => { + const diag = emitC02('hola', 'test.tsx'); + expect(diag.category).toBe('error'); + expect(diag.code).toBe('C02'); + expect(diag.file).toBe('test.tsx'); + expect(diag.message).toBe( + "Reference to identifier 'hola' can not be used inside a Qrl($) scope because it's a function" + ); + expect(diag.highlights).toBeNull(); + expect(diag.suggestions).toBeNull(); + expect(diag.scope).toBe('optimizer'); + }); + + it('produces error with code C02 for a class reference', () => { + const diag = emitC02('Thing', 'test.tsx'); + expect(diag.category).toBe('error'); + expect(diag.code).toBe('C02'); + expect(diag.message).toBe( + "Reference to identifier 'Thing' can not be used inside a Qrl($) scope because it's a function" + ); + expect(diag.highlights).toBeNull(); + expect(diag.scope).toBe('optimizer'); + }); + + it('does not emit for uncaptured identifiers (caller responsibility)', () => { + const diag = emitC02('Other', 'test.tsx'); + expect(diag.code).toBe('C02'); + }); +}); + +describe('parseDisableDirectives', () => { + it('parses single code directive', () => { + const source = `// line 1 +/* @qwik-disable-next-line C05 */ +useMemo$(() => {});`; + const directives = parseDisableDirectives(source); + expect(directives.get(3)?.has('C05')).toBe(true); + }); + + it('parses multiple comma-separated codes', () => { + const source = `// line 1 +/* @qwik-disable-next-line C05, preventdefault-passive-check */ +someLine();`; + const directives = parseDisableDirectives(source); + expect(directives.get(3)?.has('C05')).toBe(true); + expect(directives.get(3)?.has('preventdefault-passive-check')).toBe(true); + }); + + it('parses JSX comment form', () => { + const source = `
+{/* @qwik-disable-next-line preventdefault-passive-check */} +
`; + const directives = parseDisableDirectives(source); + expect(directives.get(3)?.has('preventdefault-passive-check')).toBe(true); + }); + + it('returns empty map for source without directives', () => { + const directives = parseDisableDirectives('const x = 1;\nconst y = 2;'); + expect(directives.size).toBe(0); + }); +}); + +describe('filterSuppressedDiagnostics', () => { + it('removes diagnostics matching directive codes', () => { + const diags: Diagnostic[] = [ + { + category: 'error', + code: 'C03', + file: 'test.tsx', + message: 'test', + highlights: [ + { + lo: mkByteOffset(0), + hi: mkByteOffset(10), + startLine: mkLineNumber(3), + startCol: mkColumnNumber(1), + endLine: mkLineNumber(3), + endCol: mkColumnNumber(10), + }, + ], + suggestions: null, + scope: 'optimizer', + }, + ]; + const directives = new Map>(); + directives.set(3, new Set(['C03'])); + const result = filterSuppressedDiagnostics(diags, directives); + expect(result).toHaveLength(0); + }); + + it('keeps diagnostics not matching directive codes', () => { + const diags: Diagnostic[] = [emitC02('hola', 'test.tsx')]; + const directives = new Map>(); + directives.set(5, new Set(['C05'])); + const result = filterSuppressedDiagnostics(diags, directives); + expect(result).toHaveLength(1); + }); + + it('suppresses only the NEXT line, not subsequent lines', () => { + const diags: Diagnostic[] = [ + { + category: 'error', + code: 'C03', + file: 'test.tsx', + message: 'uses local values (a)', + highlights: [ + { + lo: mkByteOffset(20), + hi: mkByteOffset(30), + startLine: mkLineNumber(3), + startCol: mkColumnNumber(1), + endLine: mkLineNumber(3), + endCol: mkColumnNumber(10), + }, + ], + suggestions: null, + scope: 'optimizer', + }, + { + category: 'error', + code: 'C03', + file: 'test.tsx', + message: 'uses local values (b)', + highlights: [ + { + lo: mkByteOffset(40), + hi: mkByteOffset(50), + startLine: mkLineNumber(4), + startCol: mkColumnNumber(1), + endLine: mkLineNumber(4), + endCol: mkColumnNumber(10), + }, + ], + suggestions: null, + scope: 'optimizer', + }, + ]; + const directives = new Map>(); + directives.set(3, new Set(['C03'])); + const result = filterSuppressedDiagnostics(diags, directives); + expect(result).toHaveLength(1); + expect(result[0].message).toContain('b'); + }); + + it('handles diagnostics with null highlights (uses no line matching)', () => { + const diags: Diagnostic[] = [emitC02('hola', 'test.tsx')]; + const directives = new Map>(); + directives.set(5, new Set(['C02'])); + const result = filterSuppressedDiagnostics(diags, directives); + expect(result).toHaveLength(1); + }); +}); + +describe('emitC05', () => { + it('produces error with code C05 for missing Qrl export', () => { + const diag = emitC05('useMemo$', 'useMemoQrl', 'test.tsx', { + lo: mkByteOffset(241), + hi: mkByteOffset(249), + startLine: mkLineNumber(11), + startCol: mkColumnNumber(5), + endLine: mkLineNumber(11), + endCol: mkColumnNumber(12), + }); + expect(diag.category).toBe('error'); + expect(diag.code).toBe('C05'); + expect(diag.message).toBe( + "Found 'useMemo$' but did not find the corresponding 'useMemoQrl' exported in the same file. Please check that it is exported and spelled correctly" + ); + expect(diag.highlights).toEqual([ + { lo: 241, hi: 249, startLine: 11, startCol: 5, endLine: 11, endCol: 12 }, + ]); + expect(diag.scope).toBe('optimizer'); + }); +}); + +describe('emitPassiveConflictWarning', () => { + it('produces warning for passive + preventdefault conflict', () => { + const diag = emitPassiveConflictWarning('click', 'test.tsx'); + expect(diag.category).toBe('warning'); + expect(diag.code).toBe('preventdefault-passive-check'); + expect(diag.message).toBe( + 'preventdefault:click has no effect when passive:click is also set; passive event listeners cannot call preventDefault()' + ); + expect(diag.scope).toBe('optimizer'); + }); +}); + +describe('transform pipeline diagnostics', () => { + it('emits C02 for function/class captures in example_capturing_fn_class', () => { + const input = ` +import { $, component$ } from '@qwik.dev/core'; + +export const App = component$(() => { +\tfunction hola() { +\t\tconsole.log('hola'); +\t} +\tclass Thing {} +\tclass Other {} + +\treturn $(() => { +\t\thola(); +\t\tnew Thing(); +\t\treturn ( +\t\t\t
+\t\t) +\t}); +}) +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + expect(result.modules.length).toBeGreaterThan(0); + expect(result.modules[0].code).toBeTruthy(); + + const c02Diags = result.diagnostics.filter((d) => d.code === 'C02'); + expect(c02Diags.length).toBe(2); + + expect(c02Diags.map((d) => d.message)).toEqual([ + "Reference to identifier 'Thing' can not be used inside a Qrl($) scope because it's a function", + "Reference to identifier 'hola' can not be used inside a Qrl($) scope because it's a function", + ]); + expect(c02Diags.every((d) => d.highlights === null)).toBe(true); + }); + + it('emits C05 for missing custom inlined Qrl export', () => { + const input = ` +import { component$ as Component, $ as onRender, useStore, wrap, useEffect } from '@qwik.dev/core'; + +export const useMemo$ = (qrt) => { +\tuseEffect(qrt); +}; + +export const App = component$((props) => { +\tconst state = useStore({count: 0}); +\tuseMemo$(() => { +\t\tconsole.log(state.count); +\t}); +\treturn $(() => ( +\t\t
{state.count}
+\t)); +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + expect(result.modules.length).toBeGreaterThan(0); + + const c05Diags = result.diagnostics.filter((d) => d.code === 'C05'); + expect(c05Diags.length).toBe(1); + expect(c05Diags[0].message).toContain("Found 'useMemo$'"); + expect(c05Diags[0].message).toContain("'useMemoQrl'"); + }); + + it('emits preventdefault-passive-check warning', () => { + const input = ` +\t\timport { component$ } from '@qwik.dev/core'; + +\t\tconst PassiveOnlyComponent = component$(() => { +\t\t\treturn ( +\t\t\t\t
+\t\t\t\t\t +\t\t\t\t
+\t\t\t); +\t\t}); +\t\t`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + const passiveDiags = result.diagnostics.filter( + (d) => d.code === 'preventdefault-passive-check' + ); + expect(passiveDiags.length).toBe(1); + expect(passiveDiags[0].category).toBe('warning'); + expect(passiveDiags[0].message).toContain('preventdefault:click'); + expect(passiveDiags[0].message).toContain('passive:click'); + const highlight = passiveDiags[0].highlights?.[0]; + expect(highlight).toBeDefined(); + expect(input.slice(highlight!.lo, highlight!.hi)).toContain(''); + }); + + it('suppresses diagnostics with @qwik-disable-next-line', () => { + const input = ` +\t\timport { component$ } from '@qwik.dev/core'; + +\t\tconst PassiveOnlyComponent = component$(() => { +\t\t\treturn ( +\t\t\t\t
+\t\t\t\t\t{/* @qwik-disable-next-line preventdefault-passive-check */} +\t\t\t\t\t +\t\t\t\t
+\t\t\t); +\t\t}); +\t\t`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + const passiveDiags = result.diagnostics.filter( + (d) => d.code === 'preventdefault-passive-check' + ); + expect(passiveDiags.length).toBe(0); + }); + + it('suppresses C05 with @qwik-disable-next-line', () => { + const input = ` +\t\timport { component$, useEffect } from '@qwik.dev/core'; + +\t\texport const useMemo$ = (qrt) => { +\t\t\tuseEffect(qrt); +\t\t}; + +\t\texport const App = component$(() => { +\t\t\t/* @qwik-disable-next-line C05 */ +\t\t\tuseMemo$(() => { +\t\t\t\tconsole.log('suppressed'); +\t\t\t}); +\t\t\treturn
; +\t\t}); +\t\t`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + const c05Diags = result.diagnostics.filter((d) => d.code === 'C05'); + expect(c05Diags.length).toBe(0); + }); + + it('produces code output even when diagnostics are present', () => { + const input = ` +import { $, component$ } from '@qwik.dev/core'; + +export const App = component$(() => { +\tfunction hola() { console.log('hi'); } +\treturn $(() => { hola(); return
; }); +}) +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + expect(result.diagnostics.length).toBeGreaterThan(0); + + expect(result.modules.length).toBeGreaterThan(0); + expect(result.modules[0].code.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/diagnostics/source-loc.test.ts b/packages/ts-optimizer/tests/optimizer/diagnostics/source-loc.test.ts new file mode 100644 index 00000000000..ca302dffc10 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/diagnostics/source-loc.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { computeLineColFromOffset } from '../../../src/optimizer/diagnostics/source-loc.js'; + +describe('source-loc', () => { + it('computes 1-based line and column positions', () => { + expect(computeLineColFromOffset('alpha\nbeta\ngamma', 0)).toEqual([1, 1]); + expect(computeLineColFromOffset('alpha\nbeta\ngamma', 6)).toEqual([2, 1]); + expect(computeLineColFromOffset('alpha\nbeta\ngamma', 9)).toEqual([2, 4]); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/edit/blank-non-code-parity.test.ts b/packages/ts-optimizer/tests/optimizer/edit/blank-non-code-parity.test.ts new file mode 100644 index 00000000000..0ee018bbddc --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/edit/blank-non-code-parity.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { + blankNonCode, + skipStringLiteralForward, +} from '../../../src/optimizer/edit/text-scanning.js'; +import { parseSnapshot } from '../../../src/testing/snapshot-parser.js'; +import { SNAP_DIR } from '../../rust-snapshots.js'; + +/** + * The original char-array implementation, kept as a differential oracle: `blankNonCode` underpins + * every position-based scanner, so a faster version has to be byte-identical, not merely similar. + */ +function blankNonCodeReference(text: string): string { + const chars = text.split(''); + const blank = (from: number, to: number): void => { + for (let k = from; k < to && k < chars.length; k++) { + if (chars[k] !== '\n') { + chars[k] = ' '; + } + } + }; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"' || ch === "'" || ch === '`') { + const close = skipStringLiteralForward(text, i); + blank(i + 1, close); + i = close; + } else if (ch === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + const stop = end < 0 ? text.length : end + 2; + blank(i, stop); + i = stop - 1; + } else if (ch === '/' && text[i + 1] === '/') { + const nl = text.indexOf('\n', i); + const stop = nl < 0 ? text.length : nl; + blank(i, stop); + i = stop - 1; + } + } + return chars.join(''); +} + +const EDGE_CASES = [ + '', + 'const a = 1;', + 'const s = "hi";', + "const s = 'a\\'b';", + 'const t = `a${b}c`;', + 'const t = `a${ "in" + `${nested}` }c`;', + '// line\nconst a = 1;', + '/* block */ const a = 1;', + '/* multi\nline */ const a = 1;', + '/* unterminated', + '// unterminated', + 'const s = "unterminated', + 'const s = `un${terminated', + 'a /= b; const s = "/*";', + 'const re = "//not a comment";', + '`${`${`${deep}`}`}`', + '"\\\\" + notAString', + '\n\n// after blank lines\n', +]; + +describe('blankNonCode matches the reference implementation', () => { + it('agrees on edge cases', () => { + for (const input of EDGE_CASES) { + expect(blankNonCode(input), JSON.stringify(input)).toBe(blankNonCodeReference(input)); + } + }); + + it('agrees across the snapshot fixture corpus', () => { + const snapFiles = readdirSync(SNAP_DIR).filter((f) => f.endsWith('.snap')); + expect(snapFiles.length).toBeGreaterThan(100); + let checked = 0; + for (const snapFile of snapFiles) { + const parsed = parseSnapshot(readFileSync(join(SNAP_DIR, snapFile), 'utf-8')); + if (!parsed.input) { + continue; + } + expect(blankNonCode(parsed.input), snapFile).toBe(blankNonCodeReference(parsed.input)); + checked++; + } + expect(checked).toBeGreaterThan(100); + }); + + it('preserves length and newline positions', () => { + for (const input of EDGE_CASES) { + const out = blankNonCode(input); + expect(out.length).toBe(input.length); + for (let i = 0; i < input.length; i++) { + if (input[i] === '\n') { + expect(out[i]).toBe('\n'); + } + } + } + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/edit/range-replace.test.ts b/packages/ts-optimizer/tests/optimizer/edit/range-replace.test.ts new file mode 100644 index 00000000000..90e815e4b36 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/edit/range-replace.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from 'vitest'; +import { parseSync } from 'oxc-parser'; +import { + collectRangeReplacements, + expressionNeedsParens, + isReplaceableIdentifierPosition, + type RangeReplacement, + type RangeReplacementCollector, +} from '../../../src/optimizer/edit/range-replace.js'; +import { applyReplacements } from '../../../src/optimizer/edit/range-replace.js'; +import type { AstNode, AstParentNode } from '../../../src/ast-types.js'; + +function parseExpr(src: string): AstNode { + const wrapped = `const __e__ = ${src};`; + const parsed = parseSync('__expr__.ts', wrapped); + const decl = (parsed.program?.body?.[0] as { declarations?: Array<{ init?: AstNode }> }) + ?.declarations?.[0]; + if (!decl?.init) { + throw new Error('parse failed'); + } + return decl.init; +} + +describe('collectRangeReplacements', () => { + it('returns an empty list when no collectors are registered', () => { + const expr = parseExpr('foo + bar'); + const out = collectRangeReplacements(expr, expr.start, 'foo + bar', []); + expect(out).toEqual([]); + }); + + it('runs every collector at every node', () => { + const expr = parseExpr('1 + 2'); + const visits: Array = []; + const trace: RangeReplacementCollector = (n) => { + visits.push(n.type); + return null; + }; + collectRangeReplacements(expr, expr.start, '1 + 2', [trace, trace]); + expect(visits.length).toBe(6); + expect(visits.filter((t) => t === 'BinaryExpression').length).toBe(2); + expect(visits.filter((t) => t === 'Literal').length).toBe(4); + }); + + it('threads parentKey + parentNode to the collector', () => { + const expr = parseExpr('a + b'); + const captured: Array<{ name?: string; parentKey?: string; parentType?: string }> = []; + const captureIdents: RangeReplacementCollector = (n, ctx) => { + if (n.type === 'Identifier') { + captured.push({ + name: n.name, + parentKey: ctx.parentKey, + parentType: ctx.parentNode?.type, + }); + } + return null; + }; + collectRangeReplacements(expr, expr.start, 'a + b', [captureIdents]); + expect(captured).toEqual([ + { name: 'a', parentKey: 'left', parentType: 'BinaryExpression' }, + { name: 'b', parentKey: 'right', parentType: 'BinaryExpression' }, + ]); + }); + + it('skipSubtree from a collector suppresses recursion for all collectors', () => { + const expr = parseExpr('1 + 2'); + const visitsA: Array = []; + const visitsB: Array = []; + const skipOuter: RangeReplacementCollector = (n) => { + visitsA.push(n.type); + if (n.type === 'BinaryExpression') { + return { replacements: [], skipSubtree: true }; + } + return null; + }; + const trace: RangeReplacementCollector = (n) => { + visitsB.push(n.type); + return null; + }; + collectRangeReplacements(expr, expr.start, '1 + 2', [skipOuter, trace]); + expect(visitsA).toEqual(['BinaryExpression']); + expect(visitsB).toEqual(['BinaryExpression']); + }); + + it('skipSubtree triggered by ANY collector suppresses recursion for ALL', () => { + const expr = parseExpr('1 + 2'); + const childVisitsA: Array = []; + const childVisitsB: Array = []; + const skipAtBinary: RangeReplacementCollector = (n) => { + if (n.type === 'BinaryExpression') { + return { replacements: [], skipSubtree: true }; + } + return null; + }; + const traceA: RangeReplacementCollector = (n) => { + if (n.type === 'Literal') { + childVisitsA.push(String(n.value)); + } + return null; + }; + const traceB: RangeReplacementCollector = (n) => { + if (n.type === 'Literal') { + childVisitsB.push(String(n.value)); + } + return null; + }; + collectRangeReplacements(expr, expr.start, '1 + 2', [skipAtBinary, traceA, traceB]); + expect(childVisitsA).toEqual([]); + expect(childVisitsB).toEqual([]); + }); + + it('collects replacements from multiple collectors disjointly', () => { + const expr = parseExpr('a + b'); + const text = 'a + b'; + const renameIdents: RangeReplacementCollector = (n, ctx) => { + if (n.type === 'Identifier' && (n.name === 'a' || n.name === 'b')) { + return { + replacements: [ + { + start: n.start - ctx.exprStart, + end: n.end - ctx.exprStart, + replacement: n.name === 'a' ? 'p0' : 'p1', + }, + ], + }; + } + return null; + }; + const out = collectRangeReplacements(expr, expr.start, text, [renameIdents]); + expect(out.length).toBe(2); + expect(applyReplacements(text, out)).toBe('p0 + p1'); + }); + + it('does not call collectors on null/undefined nodes', () => { + const visits: string[] = []; + const trace: RangeReplacementCollector = (n) => { + visits.push(n.type); + return null; + }; + collectRangeReplacements(null, 0, '', [trace]); + expect(visits).toEqual([]); + }); +}); + +describe('isReplaceableIdentifierPosition', () => { + it('returns false for property keys', () => { + const parent = { type: 'Property' } as AstParentNode; + expect(isReplaceableIdentifierPosition('key', parent)).toBe(false); + }); + + it('returns false for non-computed member access property', () => { + const parent = { type: 'MemberExpression', computed: false } as unknown as AstParentNode; + expect(isReplaceableIdentifierPosition('property', parent)).toBe(false); + }); + + it('returns TRUE for computed member access property (rewrite is safe)', () => { + const parent = { type: 'MemberExpression', computed: true } as unknown as AstParentNode; + expect(isReplaceableIdentifierPosition('property', parent)).toBe(true); + }); + + it('returns false for function parameter position', () => { + expect(isReplaceableIdentifierPosition('params', undefined)).toBe(false); + }); + + it('returns false for declarator id', () => { + const parent = { type: 'VariableDeclarator' } as AstParentNode; + expect(isReplaceableIdentifierPosition('id', parent)).toBe(false); + }); + + it('returns true for shorthand property value (caller handles shorthand emit)', () => { + const parent = { type: 'Property', shorthand: true } as unknown as AstParentNode; + expect(isReplaceableIdentifierPosition('value', parent)).toBe(true); + }); + + it('returns true for binary operand position (the common reference case)', () => { + const parent = { type: 'BinaryExpression' } as AstParentNode; + expect(isReplaceableIdentifierPosition('left', parent)).toBe(true); + expect(isReplaceableIdentifierPosition('right', parent)).toBe(true); + }); + + it('returns true at root (no parent)', () => { + expect(isReplaceableIdentifierPosition(undefined, undefined)).toBe(true); + }); +}); + +describe('expressionNeedsParens', () => { + it('returns true for BinaryExpression operand (any side)', () => { + const parent = { type: 'BinaryExpression' } as AstParentNode; + expect(expressionNeedsParens('left', parent)).toBe(true); + expect(expressionNeedsParens('right', parent)).toBe(true); + }); + + it('returns true for LogicalExpression operand (?? + ||/&& mixing is a syntax error)', () => { + const parent = { type: 'LogicalExpression' } as AstParentNode; + expect(expressionNeedsParens('left', parent)).toBe(true); + expect(expressionNeedsParens('right', parent)).toBe(true); + }); + + it('returns true for UnaryExpression argument', () => { + const parent = { type: 'UnaryExpression' } as AstParentNode; + expect(expressionNeedsParens('argument', parent)).toBe(true); + }); + + it('returns true for UpdateExpression argument', () => { + const parent = { type: 'UpdateExpression' } as AstParentNode; + expect(expressionNeedsParens('argument', parent)).toBe(true); + }); + + it('returns true for MemberExpression object position only', () => { + const parent = { type: 'MemberExpression' } as AstParentNode; + expect(expressionNeedsParens('object', parent)).toBe(true); + expect(expressionNeedsParens('property', parent)).toBe(false); + }); + + it('returns true for TaggedTemplateExpression tag', () => { + const parent = { type: 'TaggedTemplateExpression' } as AstParentNode; + expect(expressionNeedsParens('tag', parent)).toBe(true); + }); + + it('returns true for CallExpression / NewExpression callee', () => { + const callParent = { type: 'CallExpression' } as AstParentNode; + const newParent = { type: 'NewExpression' } as AstParentNode; + expect(expressionNeedsParens('callee', callParent)).toBe(true); + expect(expressionNeedsParens('callee', newParent)).toBe(true); + }); + + it('returns false for CallExpression / NewExpression arguments', () => { + const parent = { type: 'CallExpression' } as AstParentNode; + expect(expressionNeedsParens('arguments', parent)).toBe(false); + }); + + it('returns false for ObjectExpression Property value position', () => { + const parent = { type: 'Property' } as AstParentNode; + expect(expressionNeedsParens('value', parent)).toBe(false); + }); + + it('returns false for ArrayExpression element position', () => { + const parent = { type: 'ArrayExpression' } as AstParentNode; + expect(expressionNeedsParens('elements', parent)).toBe(false); + }); + + it('returns false for ConditionalExpression branches (?: lower precedence than ??)', () => { + const parent = { type: 'ConditionalExpression' } as AstParentNode; + expect(expressionNeedsParens('test', parent)).toBe(false); + expect(expressionNeedsParens('consequent', parent)).toBe(false); + expect(expressionNeedsParens('alternate', parent)).toBe(false); + }); + + it('returns false for AssignmentExpression right side', () => { + const parent = { type: 'AssignmentExpression' } as AstParentNode; + expect(expressionNeedsParens('right', parent)).toBe(false); + }); + + it('returns false for ReturnStatement argument', () => { + const parent = { type: 'ReturnStatement' } as AstParentNode; + expect(expressionNeedsParens('argument', parent)).toBe(false); + }); + + it('returns false for VariableDeclarator init', () => { + const parent = { type: 'VariableDeclarator' } as AstParentNode; + expect(expressionNeedsParens('init', parent)).toBe(false); + }); + + it('returns false for JSXExpressionContainer expression', () => { + const parent = { type: 'JSXExpressionContainer' } as AstParentNode; + expect(expressionNeedsParens('expression', parent)).toBe(false); + }); + + it('returns false for TemplateLiteral expression slot', () => { + const parent = { type: 'TemplateLiteral' } as AstParentNode; + expect(expressionNeedsParens('expressions', parent)).toBe(false); + }); + + it('returns false for ExpressionStatement', () => { + const parent = { type: 'ExpressionStatement' } as AstParentNode; + expect(expressionNeedsParens('expression', parent)).toBe(false); + }); + + it('returns false for ParenthesizedExpression (already inside parens)', () => { + const parent = { type: 'ParenthesizedExpression' } as AstParentNode; + expect(expressionNeedsParens('expression', parent)).toBe(false); + }); + + it('returns false at root (no parent)', () => { + expect(expressionNeedsParens(undefined, undefined)).toBe(false); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/edit/strip-types.test.ts b/packages/ts-optimizer/tests/optimizer/edit/strip-types.test.ts new file mode 100644 index 00000000000..777076aee17 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/edit/strip-types.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest'; +import { stripTypeScript } from '../../../src/optimizer/edit/strip-types.js'; + +it('emits exported TypeScript enums with Rust-compatible var bindings', () => { + const output = stripTypeScript( + 'test.ts', + 'export enum Thing { A, B }', + { typescript: { onlyRemoveTypeImports: true } }, + 'test' + ); + + expect(output).toContain('export var Thing ='); +}); diff --git a/packages/ts-optimizer/tests/optimizer/edit/text-scanning.test.ts b/packages/ts-optimizer/tests/optimizer/edit/text-scanning.test.ts new file mode 100644 index 00000000000..b4db0fa0147 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/edit/text-scanning.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { skipStringLiteralForward } from '../../../src/optimizer/edit/text-scanning.js'; +import { replaceOutsideStrings } from '../../../src/optimizer/edit/identifier-boundary.js'; + +describe('skipStringLiteralForward', () => { + it('finds the closing backtick past a "}" inside an interpolated string', () => { + const text = '`${f("}")}` rest'; + expect(skipStringLiteralForward(text, 0)).toBe(text.indexOf('` rest')); + }); + + it('handles nested template literals inside an interpolation', () => { + const text = '`a${`b${c}`}d` rest'; + expect(skipStringLiteralForward(text, 0)).toBe(text.indexOf('` rest')); + }); + + it('ignores an unbalanced "{" inside an interpolated string', () => { + const text = '`${"{"}` rest'; + expect(skipStringLiteralForward(text, 0)).toBe(text.indexOf('` rest')); + }); +}); + +describe('replaceOutsideStrings', () => { + const pattern = /(? { + const text = 'const s = `state: Status.Active is ${Status.Active}`;'; + expect(replaceOutsideStrings(text, pattern, '1')).toBe( + 'const s = `state: Status.Active is ${1}`;' + ); + }); + + it('recurses into nested templates in interpolations', () => { + const text = 'const s = `${cond ? `${Status.Active}` : "Status.Active"}`;'; + expect(replaceOutsideStrings(text, pattern, '1')).toBe( + 'const s = `${cond ? `${1}` : "Status.Active"}`;' + ); + }); + + it('still protects plain strings and comments', () => { + const text = 'use(Status.Active); // Status.Active\nconst t = "Status.Active";'; + expect(replaceOutsideStrings(text, pattern, '1')).toBe( + 'use(1); // Status.Active\nconst t = "Status.Active";' + ); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/edit/transform-session.test.ts b/packages/ts-optimizer/tests/optimizer/edit/transform-session.test.ts new file mode 100644 index 00000000000..70423068046 Binary files /dev/null and b/packages/ts-optimizer/tests/optimizer/edit/transform-session.test.ts differ diff --git a/packages/ts-optimizer/tests/optimizer/extraction/context-stack.test.ts b/packages/ts-optimizer/tests/optimizer/extraction/context-stack.test.ts new file mode 100644 index 00000000000..ccd463268b4 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/extraction/context-stack.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { ContextStack } from '../../../src/optimizer/extraction/context-stack.js'; + +describe('ContextStack', () => { + it('empty stack produces "s_" display name suffix', () => { + const ctx = new ContextStack('test.tsx', 'test.tsx'); + expect(ctx.getDisplayName()).toBe('test.tsx_s_'); + }); + + it('push("Header") then push("component$") produces correct display name', () => { + const ctx = new ContextStack('test.tsx', 'test.tsx'); + ctx.push('Header'); + ctx.push('component$'); + expect(ctx.getDisplayName()).toBe('test.tsx_Header_component'); + }); + + it('nested context produces full display name chain', () => { + const ctx = new ContextStack('test.tsx', 'test.tsx'); + ctx.push('Header'); + ctx.push('component$'); + ctx.push('div'); + ctx.push('onClick$'); + expect(ctx.getDisplayName()).toBe('test.tsx_Header_component_div_onClick'); + }); + + it('pop() removes last context', () => { + const ctx = new ContextStack('test.tsx', 'test.tsx'); + ctx.push('Header'); + ctx.push('component$'); + ctx.pop(); + expect(ctx.getDisplayName()).toBe('test.tsx_Header'); + }); + + it('getSymbolName() produces "{contextPortion}_{hash}" format', () => { + const ctx = new ContextStack('test.tsx', 'test.tsx'); + ctx.push('Header'); + ctx.push('component$'); + const symbolName = ctx.getSymbolName(); + expect(symbolName).toMatch(/^Header_component_[A-Za-z0-9]+$/); + }); + + it('default export stem extraction: "[[...slug]].tsx" -> pushes "slug"', () => { + const ctx = new ContextStack('[[...slug]].tsx', '[[...slug]].tsx'); + ctx.pushDefaultExport(); + expect(ctx.getContextStack()).toEqual(['slug']); + }); + + it('default export stem extraction: "[id].tsx" -> pushes "id"', () => { + const ctx = new ContextStack('[id].tsx', '[id].tsx'); + ctx.pushDefaultExport(); + expect(ctx.getContextStack()).toEqual(['id']); + }); + + it('depth property tracks stack size', () => { + const ctx = new ContextStack('test.tsx', 'test.tsx'); + expect(ctx.depth).toBe(0); + ctx.push('Header'); + expect(ctx.depth).toBe(1); + ctx.push('component$'); + expect(ctx.depth).toBe(2); + ctx.pop(); + expect(ctx.depth).toBe(1); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/extraction/extract.test.ts b/packages/ts-optimizer/tests/optimizer/extraction/extract.test.ts new file mode 100644 index 00000000000..d2d7c7afc72 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/extraction/extract.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect } from 'vitest'; +import { + extractSegments, + type ExtractionResult, +} from '../../../src/optimizer/extraction/extract.js'; +import { generateSegmentCode } from '../../../src/optimizer/segment/segment-codegen.js'; +import { + mkBodyText, + mkByteOffset, + mkCanonicalFilename, + mkCtxName, + mkDisplayName, + mkHash, + mkOrigin, + mkSymbolName, +} from '../../../src/optimizer/types/brands.js'; + +describe('extractSegments', () => { + it('extracts single component$ with correct symbolName and displayName', () => { + const source = ` +import { component$ } from '@qwik.dev/core'; +export const App = component$(() => { + return
Hello
; +}); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + + const seg = results[0]; + expect(seg.displayName).toBe('test.tsx_App_component'); + expect(seg.symbolName).toMatch(/^App_component_/); + expect(seg.calleeName).toBe('component$'); + expect(seg.isBare).toBe(false); + expect(seg.isSync).toBe(false); + expect(seg.qrlCallee).toBe('componentQrl'); + }); + + it('extracts bare $() with isBare=true and empty qrlCallee', () => { + const source = ` +import { $ } from '@qwik.dev/core'; +const handler = $(() => { + console.log('clicked'); +}); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + + const seg = results[0]; + expect(seg.isBare).toBe(true); + expect(seg.qrlCallee).toBe(''); + expect(seg.calleeName).toBe('$'); + }); + + it('includes the direct wrapper call name for bare $() segments', () => { + const source = ` +import { $, component } from '@qwik.dev/core'; +const renderHeader2 = component($(() => { + console.log('mount'); +})); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + + const seg = results[0]; + expect(seg.displayName).toBe('test.tsx_renderHeader2_component'); + expect(seg.symbolName).toMatch(/^renderHeader2_component_/); + expect(seg.ctxName).toBe('$'); + }); + + it('inherits local wrapper call names for bare $() segments', () => { + const source = ` +import { $ } from '@qwik.dev/core'; +const TestNoHmr = componentQrl($(() => { + return
Test
; +})); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + expect(results[0].displayName).toBe('test.tsx_TestNoHmr_componentQrl'); + }); + + it('inherits the dollar context from a direct Qrl wrapper', () => { + const source = ` +import { $ } from '@qwik.dev/core'; +import { routeLoaderQrl } from '@qwik.dev/router'; + +export const useSession = routeLoaderQrl($(() => 'session')); +export const standalone = $(() => 'standalone'); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(2); + expect(results[0].ctxName).toBe('routeLoader$'); + expect(results[0].displayName).toBe('test.tsx_useSession_routeLoaderQrl'); + expect(results[1].ctxName).toBe('$'); + expect(results[1].displayName).toBe('test.tsx_standalone'); + }); + + it('composes wrapper context with enclosing marker context', () => { + const source = ` +import { $, component$, useStyles } from '@qwik.dev/core'; + +export const Root = component$(() => { + useStyles($('thing')); + return $(() => { + return
; + }); +}); +`; + const results = extractSegments(source, 'test.tsx'); + const bareResults = results.filter((r) => r.ctxName === '$'); + const bareDisplayNames = bareResults.map((r) => r.displayName); + + expect(bareDisplayNames).toContain('test.tsx_Root_component_useStyles'); + expect(bareDisplayNames).toContain('test.tsx_Root_component_1'); + }); + + it('extracts useTask$() with qrlCallee="useTaskQrl"', () => { + const source = ` +import { useTask$ } from '@qwik.dev/core'; +const Cmp = () => { + useTask$(() => { + console.log('task'); + }); +}; +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + + const seg = results[0]; + expect(seg.qrlCallee).toBe('useTaskQrl'); + expect(seg.calleeName).toBe('useTask$'); + }); + + it('determines extension .tsx when segment body contains JSX', () => { + const source = ` +import { component$ } from '@qwik.dev/core'; +export const App = component$(() => { + return
Hello
; +}); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + expect(results[0].extension).toBe('.tsx'); + }); + + it('determines extension .js when .tsx source but segment has no JSX', () => { + const source = ` +import { $ } from '@qwik.dev/core'; +export const handler = $(() => { + console.log('no jsx here'); +}); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + expect(results[0].extension).toBe('.js'); + }); + + it('inlinedQrl arrow body containing JSX flips extension via activeSegmentBodies', () => { + const source = ` +import { inlinedQrl } from '@qwik.dev/core'; +const _x = inlinedQrl(() =>
Hello
, "Foo_component_xxxxxxxxxx"); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + expect(results[0].isInlinedQrl).toBe(true); + expect(results[0].extension).toBe('.tsx'); + }); + + it('inlinedQrl with no-JSX arrow body leaves extension as sourceExt', () => { + const source = ` +import { inlinedQrl } from '@qwik.dev/core'; +const _x = inlinedQrl(() => console.log('plain'), "Foo_component_xxxxxxxxxx"); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + expect(results[0].isInlinedQrl).toBe(true); + expect(results[0].extension).toBe('.tsx'); + }); + + it('sets ctxKind="function" and ctxName="component$" for component$', () => { + const source = ` +import { component$ } from '@qwik.dev/core'; +export const App = component$(() => { + return
Hi
; +}); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + expect(results[0].ctxKind).toBe('function'); + expect(results[0].ctxName).toBe('component$'); + }); + + it('has correct loc [line, col] from argument position', () => { + const source = `import { $ } from '@qwik.dev/core'; +const handler = $(() => { + console.log('hi'); +}); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + + const [line, col] = results[0].loc; + expect(line).toBeGreaterThanOrEqual(2); + expect(col).toBeGreaterThanOrEqual(0); + }); + + it('loc matches [argStart, argEnd] for marker-call path', () => { + const source = `import { component$ } from '@qwik.dev/core'; +export const App = component$(() => { + return null; +}); +`; + const results = extractSegments(source, 'test.tsx'); + expect(results).toHaveLength(1); + const seg = results[0]; + + expect(seg.loc[0]).toBe(seg.argStart); + expect(seg.loc[1]).toBe(seg.argEnd); + expect(source.slice(seg.loc[0], seg.loc[1])).toMatch(/^\(\)\s*=>/); + }); + + it('loc matches [argStart, argEnd] for JSX-attr path', () => { + const source = `import { component$ } from '@qwik.dev/core'; +export const App = component$(() => + ))} + + ))} + + ); +}); +`); + const segment = segmentFor(result.modules, 'test.tsx_Cmp_component'); + // The `.w([activate])` binding belongs in the component body, not spliced into + // the JSX that follows an unrelated sibling arrow. + expect(segment).toContain( + 'const activate = q_s_fE2SgvG6U44;\n\tconst s_8OoXXn6TGdw = q_s_8OoXXn6TGdw.w([activate]);' + ); + }); + + it('keeps a document: event attr that kebab-cases into an unparseable JSX name', () => { + const result = transform(` +import { component$, useSignal } from '@qwik.dev/core'; + +export const Cmp = component$(() => { + const count = useSignal(0); + return ; +}); +`); + const segment = segmentFor(result.modules, 'test.tsx_Cmp_component'); + // The kebab-cased name only survives as a quoted prop key; as a JSX attribute + // name it does not parse, and the transform throws. + expect(segment).toContain('"q-d:-d-o-m-content-loaded":'); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/helpers/parse-nodes.ts b/packages/ts-optimizer/tests/optimizer/helpers/parse-nodes.ts new file mode 100644 index 00000000000..2dea9351fcb --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/helpers/parse-nodes.ts @@ -0,0 +1,45 @@ +import { parseSync } from 'oxc-parser'; +import type { Expression, JSXElement, JSXFragment } from '../../../src/ast-types.js'; + +export function parseExprWithSource(code: string): { node: Expression; source: string } { + const source = `const __x = ${code};`; + const { program } = parseSync('test.tsx', source); + const decl = program.body[0]; + if (decl.type !== 'VariableDeclaration') { + throw new Error(`expected VariableDeclaration, got ${decl.type}`); + } + const init = decl.declarations[0]?.init; + if (!init) { + throw new Error('expected an initializer expression'); + } + return { node: init, source }; +} + +export function parseExpr(code: string): Expression { + return parseExprWithSource(code).node; +} + +function parseExprStatement(source: string): Expression { + const { program } = parseSync('test.tsx', source); + const stmt = program.body[0]; + if (stmt.type !== 'ExpressionStatement') { + throw new Error(`expected ExpressionStatement, got ${stmt.type}`); + } + return stmt.expression; +} + +export function parseJsxElement(source: string): JSXElement { + const expr = parseExprStatement(source); + if (expr.type !== 'JSXElement') { + throw new Error(`expected JSXElement, got ${expr.type}`); + } + return expr; +} + +export function parseJsxFragment(source: string): JSXFragment { + const expr = parseExprStatement(source); + if (expr.type !== 'JSXFragment') { + throw new Error(`expected JSXFragment, got ${expr.type}`); + } + return expr; +} diff --git a/packages/ts-optimizer/tests/optimizer/inlined-qrl-regressions.test.ts b/packages/ts-optimizer/tests/optimizer/inlined-qrl-regressions.test.ts new file mode 100644 index 00000000000..ace83e35691 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/inlined-qrl-regressions.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../src/optimizer/transform/index.js'; +import type { TransformModule } from '../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../src/optimizer/types/brands.js'; + +function findModule( + modules: readonly TransformModule[], + pathSubstr: string +): TransformModule | undefined { + return modules.find((m) => m.path.includes(pathSubstr)); +} + +describe('inlinedQrl migration', () => { + it('attributes module-level decl references inside inlinedQrl bodies to the segment', () => { + const input = ` +import { componentQrl, inlinedQrl, useTaskQrl } from '@qwik.dev/core'; + +const helper = (v) => v * 2; + +const Foo = componentQrl(inlinedQrl(() => { + useTaskQrl(inlinedQrl(() => { + return helper(1); + }, "Foo_useWatch_aaa", [])); + return helper(2); +}, "Foo_component_bbb", [])); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.ts'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + const parent = findModule(result.modules, 'test.ts'); + expect(parent?.code).toBeTruthy(); + expect(parent!.code).toContain('export { helper as _auto_helper }'); + + const innerSeg = findModule(result.modules, 'Foo_useWatch_aaa'); + expect(innerSeg?.code).toBeTruthy(); + expect(innerSeg!.code).toContain('_auto_helper as helper'); + + const outerSeg = findModule(result.modules, 'Foo_component_bbb'); + expect(outerSeg?.code).toBeTruthy(); + expect(outerSeg!.code).toContain('_auto_helper as helper'); + }); + + it('still treats a module-level decl referenced only at root as KEEP (no _auto_ export)', () => { + const input = ` +import { componentQrl, inlinedQrl } from '@qwik.dev/core'; + +const rootOnly = () => 1; +console.log(rootOnly()); + +const Foo = componentQrl(inlinedQrl(() => { + return 42; +}, "Foo_component_ccc", [])); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.ts'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + const parent = findModule(result.modules, 'test.ts'); + expect(parent?.code).toBeTruthy(); + expect(parent!.code).not.toContain('_auto_rootOnly'); + }); +}); + +describe('inlinedQrl segment imports', () => { + it('does not emit bogus `import { *Qrl } from "@qwik.dev/core"` for user-named identifiers', () => { + const input = ` +import { componentQrl, inlinedQrl } from '@qwik.dev/core'; + +function qwikifyQrl(reactCmpQrl) { + return componentQrl(inlinedQrl((props) => { + const [reactCmpQrl] = useLexicalScope(); + return reactCmpQrl.resolve(); + }, "qwikifyQrl_component_ddd", [reactCmpQrl])); +} +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.ts'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + const seg = findModule(result.modules, 'qwikifyQrl_component_ddd'); + expect(seg?.code).toBeTruthy(); + expect(seg!.code).not.toContain('import { reactCmpQrl } from "@qwik.dev/core"'); + }); + + it('still emits the legitimate `import { qrl }` for nested QRL declarations', () => { + const input = ` +import { component$, useTask$ } from '@qwik.dev/core'; + +const Foo = component$(() => { + useTask$(() => { console.log('inner'); }); +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + }); + + const outerSeg = result.modules.find( + (m) => m.kind === 'segment' && m.segment.ctxName === 'component$' + ); + expect(outerSeg?.code).toBeTruthy(); + expect(outerSeg!.code).toMatch(/import\s*\{\s*qrl\s*\}\s*from\s*"@qwik\.dev\/core"/); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/attr-value-jsx.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/attr-value-jsx.test.ts new file mode 100644 index 00000000000..5a7ce993a7e --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/attr-value-jsx.test.ts @@ -0,0 +1,56 @@ +import { it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +const transform = (code: string) => + transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + mode: 'dev', + isServer: true, + entryStrategy: { type: 'hoist' }, + }) + .modules.map((m) => m.code) + .join('\n'); + +it('JSX in an attr value takes no counter key and static values go const', () => { + const code = ` +import { component$ } from '@qwik.dev/core'; +export const Cmp = component$(() => { + return Loading...} />; +}); +`; + const out = transform(code); + // Static attr JSX: const bag (varProps null), key null, counter not consumed. + expect(out).toMatch(/_jsxSorted\(Foo, null, \{ fallback: /); + expect(out).toMatch(/_jsxSorted\("span", null, null, "Loading\.\.\.", 3, null/); + // The outer Foo takes the first counter key. + expect(out).toMatch(/_0"/); + expect(out).not.toMatch(/_1"/); +}); + +it('reactive attr JSX stays in the var bag with a null key', () => { + const code = ` +import { component$, useSignal } from '@qwik.dev/core'; +export const Cmp = component$(() => { + const sig = useSignal(0); + return {sig.value}} />; +}); +`; + const out = transform(code); + expect(out).toMatch(/_jsxSorted\(Foo, \{ a: /); + expect(out).toMatch(/_jsxSorted\("span", null, null, _wrapProp\(sig\), 3, null/); +}); + +it('JSX inside a function inside an attr value keeps counter keys', () => { + const code = ` +import { component$ } from '@qwik.dev/core'; +export const Cmp = component$(() => { + return

done

} />; +}); +`; + const out = transform(code); + expect(out).toMatch(/_jsxSorted\("p", null, null, "done", 3, "[A-Za-z0-9_]+_0"/); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/bag-split.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/bag-split.test.ts new file mode 100644 index 00000000000..94001916305 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/bag-split.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import type { TransformModule } from '../../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function findParent(result: { modules: readonly TransformModule[] }): TransformModule { + const parent = result.modules.find((m) => m.kind === 'parent'); + if (!parent) { + throw new Error('parent module not found'); + } + return parent; +} + +describe('_jsxSplit bag split for _restProps spread + real const entry', () => { + it('preserves bind and spread override order', () => { + const input = ` +import { component$, useSignal } from '@qwik.dev/core'; +export const C = component$((props) => { + const value = useSignal(''); + return <>; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'segment' }, + transpileJsx: true, + }); + const segment = result.modules.find( + (module) => module.kind === 'segment' && module.segment.ctxName === 'component$' + ); + if (!segment) { + throw new Error('component segment not found'); + } + + expect(segment.code).toMatch( + /"bind:value": value,\s*\.\.\._getVarProps\(props\)[\s\S]*\.\.\._getVarProps\(props\),\s*"bind:value": value/ + ); + }); + + it('splits spread when a boolean-attribute const entry is present', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const C = component$(({some = 1+2, ...rest}) => { + return
hi
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + + expect(code).toMatch(/\{\s*\.\.\._getConstProps\(rest\),\s*override:\s*true\s*\}/); + expect(code).toContain('..._getVarProps(rest)'); + expect(code).not.toMatch( + /\{[^}]*\.\.\._getVarProps\([^}]*\.\.\._getConstProps[^}]*\}\s*,\s*\{\s*override/ + ); + }); + + it('keeps merged-in-var when const bag only has event-handler routing', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const C = component$(({...rest}) => { + const onClickHandler = () => console.log('clicked'); + return ; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + expect(code).toMatch(/\.\.\._getVarProps\(rest\)/); + expect(code).toMatch(/\.\.\._getConstProps\(rest\)/); + expect(code).toMatch(/\}\s*,\s*\{\s*"q-e:click"/); + }); + + it('keeps merged-in-var when there are no const entries', () => { + const input = ` +import { component$, useStore } from '@qwik.dev/core'; +export const C = component$(({...rest}) => { + const store = useStore({ x: 1 }); + return
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + expect(code).toMatch(/\.\.\._getVarProps\(rest\)/); + expect(code).toMatch(/\.\.\._getConstProps\(rest\)/); + }); + + it('positions _getConstProps before const entries in const bag', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const C = component$(({...rest}) => { + return
x
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + expect(code).toMatch(/\.\.\._getConstProps\(rest\),\s*flag:\s*true/); + }); +}); + +describe('_jsxSplit component with single spread partitions const props', () => { + const input = ` +import { component$, useSignal } from '@qwik.dev/core'; +import { Cmp } from './cmp'; +export const C = component$((props) => { + const open = useSignal(false); + return ; +}); +`; + + function jsxSplitCall(): string { + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'segment' }, + }); + const seg = result.modules.find((m) => m.kind === 'segment' && /_jsxSplit\(Cmp/.test(m.code)); + if (!seg) { + throw new Error('Cmp _jsxSplit segment not found'); + } + const line = seg.code.split('\n').find((l) => l.includes('_jsxSplit(Cmp')); + if (!line) { + throw new Error('_jsxSplit(Cmp ...) call not found'); + } + return line; + } + + function bags(call: string): { varBag: string; constBag: string } { + const open1 = call.indexOf('{'); + const close1 = call.indexOf('}', open1); + const open2 = call.indexOf('{', close1); + const close2 = call.indexOf('}', open2); + return { + varBag: call.slice(open1, close1 + 1), + constBag: call.slice(open2, close2 + 1), + }; + } + + it('routes reactive _fnSignal and literal into the const bag (not a null bag)', () => { + const { constBag } = bags(jsxSplitCall()); + expect(constBag).toMatch(/"aria-hidden":\s*_fnSignal/); + expect(constBag).toMatch(/role:\s*"button"/); + }); + + it('keeps the inline event handler and _getVarProps in the var bag', () => { + const { varBag } = bags(jsxSplitCall()); + expect(varBag).toContain('..._getVarProps(props)'); + expect(varBag).toMatch(/onClick\$:/); + }); + + it('never leaves a reactive _fnSignal in the var bag', () => { + const { varBag } = bags(jsxSplitCall()); + expect(varBag).not.toContain('_fnSignal'); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/bare-expr-hoist.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/bare-expr-hoist.test.ts new file mode 100644 index 00000000000..56ecfffa106 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/bare-expr-hoist.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import type { TransformModule } from '../../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function findParent(result: { modules: readonly TransformModule[] }): TransformModule { + const parent = result.modules.find((m) => m.kind === 'parent'); + if (!parent) { + throw new Error('parent module not found'); + } + return parent; +} + +describe('bare-expression _fnSignal hoist for defaulted prop', () => { + it('hoists `some={some}` to _fnSignal when `some` is defaulted', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const C = component$(({some = 1+2}) => { + return
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + expect(code).toMatch(/const _hf0\b[^=]*=\s*\(p0\)\s*=>\s*p0\.some\s*\?\?\s*3/); + expect(code).toMatch(/_hf0_str\s*=\s*["']p0\.some\?\?1\+2["']/); + expect(code).toMatch(/some:\s*_fnSignal\(_hf0,\s*\[_rawProps\],\s*_hf0_str\)/); + expect(code).not.toMatch(/some:\s*_rawProps\.some\s*\?\?\s*1\s*\+\s*2/); + }); + + it('still uses _wrapProp for non-defaulted destructure prop locals', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const C = component$(({count}) => { + return
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + expect(code).toMatch(/class:\s*_wrapProp\(_rawProps,\s*["']count["']\)/); + expect(code).not.toMatch(/class:\s*_fnSignal/); + }); + + it('unwraps ParenthesizedExpression in JSX prop values from non-rawProps sources', () => { + const input = ` +import { component$, useStore } from '@qwik.dev/core'; +export const C = component$(() => { + const a = useStore({ x: 1 }); + const b = useStore({ y: 2 }); + return
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + expect(code).toContain('_fnSignal'); + }); + + it('hoists separately when both bare and object forms of the same defaulted local appear', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const C = component$(({some = 1+2}) => { + return
; +}); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + }); + + const code = findParent(result).code; + expect(code).toMatch(/const _hf0\b[^=]*=\s*\(p0\)\s*=>\s*p0\.some\s*\?\?\s*3/); + expect(code).toMatch( + /const _hf1\b[^=]*=\s*\(p0\)\s*=>\s*\(?\{\s*some:\s*\(?p0\.some\s*\?\?\s*3/ + ); + expect(code).toMatch(/some:\s*_fnSignal\(_hf0,/); + expect(code).toMatch(/params:\s*_fnSignal\(_hf1,/); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/bind-transform.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/bind-transform.test.ts new file mode 100644 index 00000000000..7a034de3043 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/bind-transform.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { + transformBindProp, + isBindProp, + mergeEventHandlers, +} from '../../../src/optimizer/jsx/bind.js'; +import { transformModule } from '../../../src/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function transformComponentBind(code: string): string { + const result = transformModule({ + srcDir: mkFilePath('/proj/src'), + input: [{ path: mkFilePath('/proj/src/c.tsx'), code: mkSourceText(code) }], + transpileTs: true, + transpileJsx: true, + minify: 'simplify', + mode: 'dev', + isServer: false, + entryStrategy: { type: 'segment' }, + }); + return result.modules.map((m) => m.code).join('\n'); +} + +describe('bind-transform', () => { + describe('isBindProp', () => { + it('returns true for bind:value', () => { + expect(isBindProp('bind:value')).toBe(true); + }); + + it('returns true for bind:checked', () => { + expect(isBindProp('bind:checked')).toBe(true); + }); + + it('returns true for bind:stuff', () => { + expect(isBindProp('bind:stuff')).toBe(true); + }); + + it('returns false for onClick$', () => { + expect(isBindProp('onClick$')).toBe(false); + }); + + it('returns false for class', () => { + expect(isBindProp('class')).toBe(false); + }); + + it('returns false for value (no bind: prefix)', () => { + expect(isBindProp('value')).toBe(false); + }); + }); + + describe('transformBindProp', () => { + it('transforms bind:value with inlinedQrl(_val)', () => { + const result = transformBindProp('bind:value', 'value'); + + expect(result.propName).toBe('value'); + expect(result.propValue).toBe('value'); + expect(result.handler).not.toBeNull(); + expect(result.handler!.name).toBe('q-e:input'); + expect(result.handler!.code).toBe('inlinedQrl(_val, "_val", [value])'); + expect(result.needsImport).toContain('inlinedQrl'); + expect(result.needsImport).toContain('_val'); + }); + + it('transforms bind:checked with inlinedQrl(_chk)', () => { + const result = transformBindProp('bind:checked', 'checked'); + + expect(result.propName).toBe('checked'); + expect(result.propValue).toBe('checked'); + expect(result.handler).not.toBeNull(); + expect(result.handler!.name).toBe('q-e:input'); + expect(result.handler!.code).toBe('inlinedQrl(_chk, "_chk", [checked])'); + expect(result.needsImport).toContain('inlinedQrl'); + expect(result.needsImport).toContain('_chk'); + }); + + it('passes through unknown bind:stuff without handler', () => { + const result = transformBindProp('bind:stuff', 'stuff'); + + expect(result.propName).toBe('bind:stuff'); + expect(result.propValue).toBe('stuff'); + expect(result.handler).toBeNull(); + expect(result.needsImport).toEqual([]); + }); + + it('handles bind:value with a different expression', () => { + const result = transformBindProp('bind:value', 'localValue'); + + expect(result.propName).toBe('value'); + expect(result.propValue).toBe('localValue'); + expect(result.handler!.code).toBe('inlinedQrl(_val, "_val", [localValue])'); + }); + + it('handles bind:checked with a different expression', () => { + const result = transformBindProp('bind:checked', 'localChecked'); + + expect(result.propName).toBe('checked'); + expect(result.propValue).toBe('localChecked'); + expect(result.handler!.code).toBe('inlinedQrl(_chk, "_chk", [localChecked])'); + }); + + it('passes through bind:disabled without handler', () => { + const result = transformBindProp('bind:disabled', 'isDisabled'); + + expect(result.propName).toBe('bind:disabled'); + expect(result.propValue).toBe('isDisabled'); + expect(result.handler).toBeNull(); + }); + }); + + describe('mergeEventHandlers', () => { + it('returns new handler when no existing handler', () => { + const result = mergeEventHandlers(null, 'newHandler'); + expect(result).toBe('newHandler'); + }); + + it('merges existing and new handler into array', () => { + const result = mergeEventHandlers('existingHandler', 'newHandler'); + expect(result).toBe('[existingHandler, newHandler]'); + }); + + it('merges bind:checked inlinedQrl with extracted handler QRL', () => { + const bindHandler = 'inlinedQrl(_chk, "_chk", [localValue])'; + const existingHandler = 'q_FieldInput_component_input_q_e_input_wqR1xEjZjf4'; + const result = mergeEventHandlers(existingHandler, bindHandler); + expect(result).toBe(`[${existingHandler}, ${bindHandler}]`); + }); + }); + + describe('bind: on a component with a store-field value', () => { + it('wraps a member-access bind value in _wrapProp(obj, "field")', () => { + const out = transformComponentBind(`import { component$ } from '@qwik.dev/core'; +import { Inner } from './inner'; +import { useBindings } from './bindings'; +export const C = component$((props) => { + const b = useBindings(props, { open: false }); + return ; +}); +`); + expect(out).toMatch(/"bind:open":\s*_wrapProp\(b,\s*"openSig"\)/); + expect(out).not.toMatch(/"bind:open":\s*b\.openSig\b/); + }); + + it('leaves a plain-identifier bind value raw', () => { + const out = transformComponentBind(`import { component$, useSignal } from '@qwik.dev/core'; +import { Inner } from './inner'; +export const C = component$(() => { + const open = useSignal(false); + return ; +}); +`); + expect(out).toMatch(/"bind:open":\s*open\b/); + expect(out).not.toMatch(/_wrapProp\(open/); + }); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/child-template-no-wrap.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/child-template-no-wrap.test.ts new file mode 100644 index 00000000000..3fccc6a80c4 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/child-template-no-wrap.test.ts @@ -0,0 +1,40 @@ +import { it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +const transform = (code: string) => + transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + mode: 'dev', + entryStrategy: { type: 'hoist' }, + }) + .modules.map((m) => m.code) + .join('\n'); + +it('a non-const template-literal child stays raw (rust parity)', () => { + // Re-evaluating a hoisted template could repeat side effects (issue 4228). + const code = ` +import { component$ } from '@qwik.dev/core'; +export const DisplayA = component$<{ counters: any }>(({ counters }) => { + return {\`\${counters.countA}:\${(window as any).countA++}\`}; +}); +`; + const out = transform(code); + expect(out).not.toContain('_fnSignal'); + expect(out).toContain('countA++'); +}); + +it('a const-deps template-literal child still hoists', () => { + const code = ` +import { component$, useSignal } from '@qwik.dev/core'; +export const Cmp = component$(() => { + const sig = useSignal(1); + return {\`v:\${sig.value}\`}; +}); +`; + const out = transform(code); + expect(out).toContain('_fnSignal'); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/class-hoist-and-wcall-var.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/class-hoist-and-wcall-var.test.ts new file mode 100644 index 00000000000..9490a4d0ded --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/class-hoist-and-wcall-var.test.ts @@ -0,0 +1,53 @@ +import { it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +const transform = (code: string) => + transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + srcDir: mkFilePath('.'), + transpileTs: true, + transpileJsx: true, + mode: 'dev', + isServer: true, + entryStrategy: { type: 'hoist' }, + }) + .modules.map((m) => m.code) + .join('\n'); + +it('a props-derived object class hoists to _fnSignal', () => { + const code = ` +import { component$ } from '@qwik.dev/core'; +export const Child = component$<{ count: number }>(({ count }) => { + return {count}; +}); +`; + const out = transform(code); + expect(out).toContain('class: _fnSignal(_hf0, [_rawProps], _hf0_str)'); +}); + +it('a store-derived object class hoists to _fnSignal (rust parity)', () => { + const code = ` +import { component$, useStore } from '@qwik.dev/core'; +export const Cmp = component$(() => { + const store = useStore({ active: 'a' }); + return
; +}); +`; + const out = transform(code); + expect(out).toContain('class: _fnSignal(_hf0, [store], _hf0_str)'); +}); + +it('a .w() with a param capture classifies var on component props', () => { + const code = ` +import { component$ } from '@qwik.dev/core'; +export const Wrapper = component$((props: { data: any }) => { + return { props.data.x = 1; }} text="a" />; +}); +`; + const out = transform(code); + // onClick$ (w-call capturing props) in the var bag; text stays const. + expect(out).toMatch( + /_jsxSorted\(Inner, \{ onClick\$: q_[\w$]+\.w\(\[props\]\) \}, \{ text: "a" \}/ + ); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/crlf-jsx-text.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/crlf-jsx-text.test.ts new file mode 100644 index 00000000000..dd03e9776fc --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/crlf-jsx-text.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function parentCode(source: string): string { + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(source) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'inline' }, + transpileTs: true, + transpileJsx: true, + }); + const parent = result.modules.find((m) => m.kind === 'parent'); + if (!parent) { + throw new Error('parent module not found'); + } + return parent.code; +} + +const LF_SOURCE = ` +import { component$ } from '@qwik.dev/core'; +export const C = component$(() => { + return ( +
+ Hello + world + ! +
+ ); +}); +`; + +// A Windows checkout has no .gitattributes normalizing these files, so the +// optimizer sees CRLF and must fold it exactly like LF. +describe('JSX text normalization is line-ending agnostic', () => { + it('produces identical output for CRLF and LF sources', () => { + expect(parentCode(LF_SOURCE.replace(/\n/g, '\r\n'))).toBe(parentCode(LF_SOURCE)); + }); + + it('leaves no carriage return in the emitted text', () => { + expect(parentCode(LF_SOURCE.replace(/\n/g, '\r\n'))).not.toContain('\\r'); + }); + + it('folds a lone-CR source the same way', () => { + expect(parentCode(LF_SOURCE.replace(/\n/g, '\r'))).toBe(parentCode(LF_SOURCE)); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/dev-info-line-number.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/dev-info-line-number.test.ts new file mode 100644 index 00000000000..9f12184a57d --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/dev-info-line-number.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function findDevInfo(code: string): Array<{ lineNumber: number; columnNumber: number }> { + const result: Array<{ lineNumber: number; columnNumber: number }> = []; + const re = /lineNumber:\s*(\d+),\s*\n\s*columnNumber:\s*(\d+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + result.push({ lineNumber: Number(m[1]), columnNumber: Number(m[2]) }); + } + return result; +} + +describe('JSX dev-info source-relative positions', () => { + it('keeps nested default-strategy JSX on its original source line', async () => { + const code = ` +import { component$ } from '@qwik.dev/core'; + +export const App = component$(() => { + return ( + +

console.log('warn')}>Hello

+
+ ); +}); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + mode: 'dev', + }); + const segment = result.modules.find( + (module) => module.kind === 'segment' && module.segment.ctxKind === 'function' + ); + expect(segment).toBeDefined(); + expect(findDevInfo(segment!.code)).toContainEqual({ lineNumber: 7, columnNumber: 7 }); + }); + + it('Inline strategy emits source-relative lineNumber from wrapped body', async () => { + const code = `import { component$ } from '@qwik.dev/core'; + +export const App = component$((props) =>
{props.x}
); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + entryStrategy: { type: 'inline' }, + mode: 'dev', + }); + const parent = result.modules.find((m) => m.kind === 'parent')!; + const found = findDevInfo(parent.code); + expect(found.length).toBeGreaterThan(0); + expect(found[0].lineNumber).toBe(3); + }); + + it('Inline strategy handles JSX that crosses body line boundaries', async () => { + const code = `import { component$ } from '@qwik.dev/core'; + +export const App = component$((props) => { + return ( +
+ {props.x} +
+ ); +}); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + entryStrategy: { type: 'inline' }, + mode: 'dev', + }); + const parent = result.modules.find((m) => m.kind === 'parent')!; + const found = findDevInfo(parent.code); + expect(found.length).toBeGreaterThanOrEqual(2); + const lines = found.map((f) => f.lineNumber).sort((a, b) => a - b); + expect(lines).toEqual([5, 6]); + }); + + it('Default strategy emits source-relative lineNumber from segment body', async () => { + const code = `import { component$ } from '@qwik.dev/core'; + +export const App = component$((props) => { + return
{props.x}
; +}); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + mode: 'dev', + }); + const segment = result.modules.find((m) => m !== result.modules[0]); + expect(segment).toBeDefined(); + const found = findDevInfo(segment!.code); + expect(found.length).toBeGreaterThan(0); + expect(found[0].lineNumber).toBe(4); + }); + + it('column on the first body line uses absolute source column', async () => { + const code = `import { component$ } from '@qwik.dev/core'; +export const App = component$((props) =>
); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + entryStrategy: { type: 'inline' }, + mode: 'dev', + }); + const parent = result.modules.find((m) => m.kind === 'parent')!; + const found = findDevInfo(parent.code); + expect(found.length).toBeGreaterThan(0); + expect(found[0].lineNumber).toBe(2); + expect(found[0].columnNumber).toBe(42); + }); + + it('non-dev mode produces no dev-info (devOptions undefined → no suffix)', async () => { + const code = `import { component$ } from '@qwik.dev/core'; +export const App = component$((props) =>
{props.x}
); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + entryStrategy: { type: 'inline' }, + }); + const parent = result.modules.find((m) => m.kind === 'parent')!; + const found = findDevInfo(parent.code); + expect(found).toEqual([]); + }); + + it('default-strategy + multi-line component body — emits source-relative for each JSX element', async () => { + const code = `import { component$ } from '@qwik.dev/core'; + +export const App = component$(() => { + return ( +
    +
  • a
  • +
  • b
  • +
+ ); +}); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + mode: 'dev', + }); + const segment = result.modules.find((m) => m !== result.modules[0]); + expect(segment).toBeDefined(); + const found = findDevInfo(segment!.code); + const lines = new Set(found.map((f) => f.lineNumber)); + expect(lines.has(5)).toBe(true); + expect(lines.has(6)).toBe(true); + expect(lines.has(7)).toBe(true); + }); + + it('keeps JSX positions after multiline nested QRLs', async () => { + const code = `import { component$, serverStuff$ } from '@qwik.dev/core'; + +export const App = component$(() => { + serverStuff$(async () => { + console.log('removed'); + }); + + return ( + +

Hello

+
+ ); +}); +`; + const result = await transformModule({ + srcDir: mkFilePath('/p'), + input: [{ code: mkSourceText(code), path: mkFilePath('test.tsx') }], + transpileJsx: true, + mode: 'dev', + }); + const segment = result.modules.find( + (module) => module.kind === 'segment' && module.code.includes('_jsxSorted') + ); + + expect(segment).toBeDefined(); + expect(findDevInfo(segment!.code)).toContainEqual({ lineNumber: 10, columnNumber: 7 }); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/devpath-threading.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/devpath-threading.test.ts new file mode 100644 index 00000000000..0b3b5b5e1d4 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/devpath-threading.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import type { TransformModule } from '../../../src/optimizer/types/types.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +function findParent(result: { modules: readonly TransformModule[] }): TransformModule { + const parent = result.modules.find((m) => m.kind === 'parent'); + if (!parent) { + throw new Error('parent module not found'); + } + return parent; +} + +describe('input.devPath threading into JSX dev-info', () => { + it('JSX dev-info fileName uses input.devPath when explicitly set', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const App = component$(() =>
); +`; + const result = transformModule({ + input: [ + { + path: mkFilePath('test.tsx'), + code: mkSourceText(input), + devPath: '/hello/from/dev/test.tsx', + }, + ], + srcDir: mkFilePath('/src'), + transpileTs: true, + transpileJsx: true, + mode: 'dev', + }); + + const allCode = result.modules.map((m) => m.code).join('\n'); + if (allCode.includes('fileName:')) { + expect(allCode).toMatch(/fileName: ["']\/hello\/from\/dev\/test\.tsx["']/); + } + expect(findParent(result).code).toMatch(/file: ["']\/hello\/from\/dev\/test\.tsx["']/); + }); + + it('qrlDEV file: uses composed devFilePath when no input.devPath (negative scope)', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const App = component$(() =>
); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('/user/qwik/src'), + transpileTs: true, + transpileJsx: true, + mode: 'dev', + }); + + const parent = findParent(result); + expect(parent.code).toMatch(/file: ["']\/user\/qwik\/src\/test\.tsx["']/); + }); + + it('JSX dev-info fileName falls back to relPath when no devPath override', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const App = component$(() =>
); +`; + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(input) }], + srcDir: mkFilePath('/user/qwik/src'), + transpileTs: true, + transpileJsx: true, + mode: 'dev', + }); + + const allCode = result.modules.map((m) => m.code).join('\n'); + if (allCode.includes('fileName:')) { + expect(allCode).not.toMatch(/fileName: ["']\/user\/qwik\/src\/test\.tsx["']/); + expect(allCode).toMatch(/fileName: ["']test\.tsx["']/); + } + }); + + it('non-dev modes ignore devPath for JSX fileName (negative scope)', () => { + const input = ` +import { component$ } from '@qwik.dev/core'; +export const App = component$(() =>
); +`; + const result = transformModule({ + input: [ + { + path: mkFilePath('test.tsx'), + code: mkSourceText(input), + devPath: '/hello/from/dev/test.tsx', + }, + ], + srcDir: mkFilePath('/src'), + transpileTs: true, + transpileJsx: true, + mode: 'prod', + }); + + const allCode = result.modules.map((m) => m.code).join('\n'); + expect(allCode).not.toMatch(/fileName:/); + expect(allCode).not.toMatch(/qrlDEV/); + }); +}); diff --git a/packages/ts-optimizer/tests/optimizer/jsx/event-capture-delivery.test.ts b/packages/ts-optimizer/tests/optimizer/jsx/event-capture-delivery.test.ts new file mode 100644 index 00000000000..8d3967cf972 --- /dev/null +++ b/packages/ts-optimizer/tests/optimizer/jsx/event-capture-delivery.test.ts @@ -0,0 +1,131 @@ +import { it, expect } from 'vitest'; +import { transformModule } from '../../../src/optimizer/transform/index.js'; +import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; + +const OPTS = { + srcDir: mkFilePath('.'), + entryStrategy: { type: 'segment' } as const, + transpileTs: true, + transpileJsx: true, + mode: 'dev' as const, + isServer: false, +}; + +it('explicit $() handlers inside component$ keep .w() capture delivery', () => { + const code = ` +import { $, component$, useStore } from '@qwik.dev/core'; +export const Cmp = component$(() => { + const store = useStore({ log: '' }); + return
{ store.log += 'x'; })} />; +}); +`; + const result = transformModule({ + ...OPTS, + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + }); + const all = result.modules.map((m) => m.code).join('\n'); + expect(all).toMatch(/q-e:mouseleave": q_\w+\.w\(\[store\]\)/); +}); + +it('promoted handlers keep author-written event params', () => { + const code = ` +import { component$, useStore } from '@qwik.dev/core'; +export const Cmp = (props: { x: number }) => { + const store = useStore({ n: 0 }); + return { ev.stopPropagation(); store.n++; }}>go; +}; +export default component$(() => ); +`; + const result = transformModule({ + ...OPTS, + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + }); + const handler = result.modules.find((m) => m.path.includes('q_e_click'))!; + expect(handler.code).toMatch(/\(ev, _1, store\)/); +}); + +it('loop handlers without captures keep author-written event params', () => { + const code = ` +import { component$ } from '@qwik.dev/core'; +export default component$((props) => ( +
    + {props.items.map(() => ( +
  • { event.preventDefault(); event.stopPropagation(); }} /> + ))} +
+)); +`; + const result = transformModule({ + ...OPTS, + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + }); + const handler = result.modules.find((m) => m.path.includes('q_e_mousedown'))!; + expect(handler.code).toMatch(/\(event, _1\).*event\.preventDefault\(\)/s); +}); + +it('optional-chain map handlers deliver values through q:ps', () => { + const code = ` +import { component$, $ } from '@qwik.dev/core'; +export default component$(() => { + const select = $((index: number) => index); + const source = { items: ['a'] }; + return
    {source?.items.map((label, index) => ( +
  • select(index)}>{label}
  • + ))}
; +}); +`; + const result = transformModule({ + ...OPTS, + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + }); + const all = result.modules.map((m) => m.code).join('\n'); + const handler = result.modules.find((m) => m.path.includes('q_e_click'))!; + expect(handler.code).toMatch(/\(_, _1, select, index\)|\(_, _1, index, select\)/); + expect(all).toMatch(/"q:ps": \[(?:select, index|index, select)\]/); + expect(all).not.toMatch(/q_\w+\.w\(\[select\]\)/); +}); + +it('inline document handlers deliver captures through q:p', () => { + const code = ` +import { component$, useSignal } from '@qwik.dev/core'; +export default component$(() => { + const count = useSignal(0); + return