#9012·qwik

[] Optimizer emits duplicate `_defaultValue` consts for defaulted props captured by separate QRLs

Author: thelfroesCreated Sep 15, 2026Updated Sep 16, 2026

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/optimizer 2.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 _rawProps nor _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 main still reproduces? props_destructuring.rs has not changed since 8e3bedc75 (2026-08-26), which predates the beta.8 publish, and the only later commits under packages/optimizer are 7f59af37e ("fix: preserve nested QRLs captured by library components" — despite the title, its diff is a one-line third_arg.fold_with(self) in transform.rs, unrelated to this) and 3dfe6f9fc ("fix(qwik-vite): restore Rust optimizer default"), so we expect main to still carry it, but we have not built main to check.
  • Happy to open a PR if a maintainer can point at where the segment-local renaming should reserve the outer-scope name.