[] Optimizer emits duplicate `_defaultValue` consts for defaulted props captured by separate QRLs
Which component is affected?
Qwik Optimizer (rust)
Describe the bug
Since @qwik.dev/[email protected] (pinned by @qwik.dev/[email protected]), a
component$ with two or more defaulted destructured props that are each read inside a different
nested QRL transforms into a module with two const _defaultValue declarations in the same
scope. That is invalid JavaScript, so the build fails:
[PARSE_ERROR] Identifier `_defaultValue` has already been declared
@qwik.dev/[email protected] — which @qwik.dev/core 2.0.0-beta.40, .41 and .42 all pin —
is unaffected, so this is a regression in beta.8. Cross-pinning isolates it to the optimizer:
[email protected] + [email protected] fails, and [email protected] +
[email protected] builds clean.
It only affects builds that lift extracted segments to module scope — SSR builds. Client builds
of the same component succeed, and mode: 'lib' is clean because it keeps segments nested inside
the component.
Input:
import { $, component$, useSignal } from '@qwik.dev/core';
const defaults = { showDelay: 100, hideDelay: 200 };
export const Repro = component$<{ showDelay?: number; hideDelay?: number }>(
({ showDelay = defaults.showDelay, hideDelay = defaults.hideDelay }) => {
const open = useSignal(false);
// Both defaulted props are read ONLY inside a nested QRL.
const show$ = $(() => setTimeout(() => (open.value = true), showDelay));
const hide$ = $(() => setTimeout(() => (open.value = false), hideDelay));
return <div onMouseEnter$={show$} onMouseLeave$={hide$} />;
},
);
Generated (entryStrategy: inline, mode: prod, minify: none, trimmed):
q_s_1Bgq4uQheOM.s(() => {
const _defaultValue = _captures[0], _rawProps = _captures[1], open = _captures[2];
return setTimeout(() => open.value = true, _rawProps.showDelay === void 0 ? _defaultValue : _rawProps.showDelay);
});
q_s_aITRKUm9lrM.s(() => {
const _defaultValue = _captures[0], _rawProps = _captures[1], open = _captures[2];
return setTimeout(() => open.value = false, _rawProps.hideDelay === void 0 ? _defaultValue : _rawProps.hideDelay);
});
q_s_vyCdaWrE0Hs.s((_rawProps) => {
const _defaultValue = untrack(() => _rawProps.showDelay) === void 0 ? defaults.showDelay : void 0;
const _defaultValue = untrack(() => _rawProps.hideDelay) === void 0 ? defaults.hideDelay : void 0; // ← duplicate
const open = useSignal(false);
const show$ = q_s_1Bgq4uQheOM.w([_defaultValue, _rawProps, open]);
const hide$ = q_s_aITRKUm9lrM.w([_defaultValue, _rawProps, open]); // ← same binding
...
});
There is a second defect underneath the syntax error: both capture arrays close over the same
binding, so even with unique names hide$ would receive showDelay's default. A fix needs to
re-point the captures, not only rename.
Expected: each hoisted default gets a distinct binding, as it already does when the props are
read in the component body (_defaultValue, _defaultValue1, _defaultValue2, …).
Reproduction
https://github.com/thelfroes/qwik-optimizer-defaultvalue-repro
Steps to reproduce
mkdir qwik-defaultvalue-repro && cd $_
npm init -y && npm pkg set type=module
npm i -D @qwik.dev/[email protected] [email protected]
src/repro.tsx — the component above.
src/entry.ssr.tsx:
import {
renderToString,
type RenderToStringOptions,
} from '@qwik.dev/core/server';
import { Repro } from './repro';
export default function (opts: RenderToStringOptions) {
return renderToString(<Repro />, opts);
}
vite.config.ts:
import { defineConfig } from 'vite';
import { qwikVite } from '@qwik.dev/core/optimizer';
export default defineConfig({
plugins: [qwikVite()],
build: { ssr: true, rollupOptions: { input: ['src/entry.ssr.tsx'] } },
});
Then:
npx vite build
The build fails with Identifier _defaultValue has already been declared. The
error frame is labelled src/repro.tsx:25:8, but repro.tsx is 13 lines long — the position is
the generated module's, carrying the source path, which makes this considerably harder to
diagnose in a real codebase than it needs to be.
To see it without a bundler, createOptimizer().transformModules({ entryStrategy: { type: 'inline' }, mode: 'prod', minify: 'none', … }) produces the same duplicate declaration, so the
defect is in the transform rather than in Rolldown.
What triggers it
| Shape | Result |
|---|---|
| 2 defaulted props, both read in the component body | ok |
2 defaulted props, literal defaults (a = 1) |
ok (defaults are inlined, no const emitted) |
| 1 defaulted prop read inside a nested QRL | ok |
| 1 prop read in the body, 1 read inside a nested QRL | ok |
| 2 defaulted props read inside the same nested QRL | ok (_defaultValue, _defaultValue1) |
| 2 defaulted props, each read inside a different nested QRL | duplicate _defaultValue |
| 4 defaulted props, 2 QRLs capturing 2 each | duplicate _defaultValue and _defaultValue1 |
So the collision count follows the number of nested QRLs, not the number of props: numbering
appears to restart inside each extracted segment, and the const hoisted into the enclosing scope
inherits the segment-local name.
By entry strategy (same input, @qwik.dev/[email protected]):
entryStrategy |
prod |
lib |
dev |
|---|---|---|---|
inline |
fails | ok | fails |
hoist |
fails | ok | fails |
segment |
ok | ok | ok |
single |
ok | ok | ok |
component |
ok | ok | ok |
The failing set is exactly the combinations that lift segments out of the component function to
module top level (q_s_X.s(() => { … })). mode: 'lib' keeps them nested
(inlinedQrl((…) => …)) and is clean there — the nested form inherits the correct numbering,
_defaultValue and _defaultValue1, each captured by the right closure. So the defect looks like
this: a segment lifted to module scope is renumbered against its own scope, while the const it
needs stays behind in the component scope and inherits that segment-local name.
Where it comes from
packages/optimizer/core/src/props_destructuring.rs, create_dynamic_default, names the binding
with private_ident!("_defaultValue") (line 528 on main at f970ba881). That works while the
declaration and its uses stay in one scope, but a use that ends up inside an extracted segment
appears to be renumbered against the segment's own scope, and the hoisted declaration follows it
out.
qwik_core__test__dynamic_props_defaults_do_not_subscribe_component covers a single defaulted
prop, which is why this is not caught: two props in two segments is the untested shape.
System Info
System:
OS: macOS 26.5
CPU: (12) arm64 Apple M4 Pro
Binaries:
Node: 22.15.0 (also reproduced on 25.2.1)
npm: 10.9.8 (also reproduced on 11.6.2)
npmPackages:
@qwik.dev/core: 2.0.0-beta.43 => 2.0.0-beta.43
@qwik.dev/optimizer: 2.1.0-beta.8 (transitive, pinned by @qwik.dev/core)
vite: 8.2.2 => 8.2.2
rolldown: 1.2.8 (transitive, via vite)
Additional Information
- Regression window:
@qwik.dev/optimizer2.1.0-beta.7 → 2.1.0-beta.8 (published 2026-08-21 and 2026-09-01). beta.7 does not apply this rewrite at all — it leaves the destructuring pattern verbatim and emits neither_rawPropsnor_defaultValue. - Consumer impact: this blocks the whole beta.43 upgrade for us. Any component with two defaulted props used from separate handlers hits it; in one mid-sized app two components did, producing six parse errors.
- Could someone confirm whether
mainstill reproduces?props_destructuring.rshas not changed since8e3bedc75(2026-08-26), which predates the beta.8 publish, and the only later commits underpackages/optimizerare7f59af37e("fix: preserve nested QRLs captured by library components" — despite the title, its diff is a one-linethird_arg.fold_with(self)intransform.rs, unrelated to this) and3dfe6f9fc("fix(qwik-vite): restore Rust optimizer default"), so we expectmainto still carry it, but we have not builtmainto check. - Happy to open a PR if a maintainer can point at where the segment-local renaming should reserve the outer-scope name.
Source: QwikDev/qwik