+
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 count.value++}>{count.value} ;
+ });
+ `,
+ },
+ ],
+ 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