diff --git a/.changeset/smart-interaction-boundaries.md b/.changeset/smart-interaction-boundaries.md new file mode 100644 index 00000000000..b393139a9eb --- /dev/null +++ b/.changeset/smart-interaction-boundaries.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/core': patch +'@qwik.dev/optimizer': patch +--- + +fix: keep client-only callbacks outside smart render bundles diff --git a/packages/optimizer/core/src/entry_strategy.rs b/packages/optimizer/core/src/entry_strategy.rs index 506aa9e954f..a0703d69d10 100644 --- a/packages/optimizer/core/src/entry_strategy.rs +++ b/packages/optimizer/core/src/entry_strategy.rs @@ -93,9 +93,10 @@ impl SmartStrategy { impl EntryPolicy for SmartStrategy { fn get_entry_for_sym(&self, context: &[String], segment: &SegmentData) -> Option { - // Event handlers without scope variables are put into a separate file - if segment.scoped_idents.is_empty() - && (segment.ctx_kind != SegmentKind::Function || &segment.ctx_name == "event$") + // Known events stay separate even when they capture state. + if segment.ctx_kind == SegmentKind::EventHandler + || segment.ctx_name == "event$" + || (segment.scoped_idents.is_empty() && segment.ctx_kind != SegmentKind::Function) { return None; } diff --git a/packages/optimizer/core/src/fixtures/smart-lifted-captures.tsx b/packages/optimizer/core/src/fixtures/smart-lifted-captures.tsx new file mode 100644 index 00000000000..0969a04f8fa --- /dev/null +++ b/packages/optimizer/core/src/fixtures/smart-lifted-captures.tsx @@ -0,0 +1,16 @@ +import { $, component$, event$, useSignal } from '@qwik.dev/core'; +import { openFeature } from './heavy-feature'; + +export const App = component$(() => { + const count = useSignal(0); + const open$ = event$(() => openFeature(count.value)); + const read = $(() => count.value); + read(); + return ( + <> + + + + + ); +}); diff --git a/packages/optimizer/core/src/fixtures/smart-visible-task-heavy-library.tsx b/packages/optimizer/core/src/fixtures/smart-visible-task-heavy-library.tsx new file mode 100644 index 00000000000..75c42e295ae --- /dev/null +++ b/packages/optimizer/core/src/fixtures/smart-visible-task-heavy-library.tsx @@ -0,0 +1,8 @@ +import { component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; +import { renderChart } from 'heavy-library'; + +export const App = component$(() => { + const count = useSignal(0); + useVisibleTask$(() => renderChart(count.value)); + return
{count.value}
; +}); diff --git a/packages/optimizer/core/src/snapshots/qwik_core__test__smart_lifted_event_captures.snap b/packages/optimizer/core/src/snapshots/qwik_core__test__smart_lifted_event_captures.snap new file mode 100644 index 00000000000..7635ad702be --- /dev/null +++ b/packages/optimizer/core/src/snapshots/qwik_core__test__smart_lifted_event_captures.snap @@ -0,0 +1,222 @@ +--- +source: packages/optimizer/core/src/test.rs +expression: output +--- +==INPUT== + +import { $, component$, event$, useSignal } from '@qwik.dev/core'; +import { openFeature } from './heavy-feature'; + +export const App = component$(() => { + const count = useSignal(0); + const open$ = event$(() => openFeature(count.value)); + const read = $(() => count.value); + read(); + return ( + <> + + + + + ); +}); + +============================= test.js == + +import { componentQrl } from "@qwik.dev/core"; +import { qrl } from "@qwik.dev/core"; +// +const q_s_ckEPmXZlub0 = /*#__PURE__*/ qrl(()=>import("./test.tsx_App_component_ckEPmXZlub0"), "s_ckEPmXZlub0"); +// +export const App = /*#__PURE__*/ componentQrl(q_s_ckEPmXZlub0); + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;AAGA,OAAO,MAAM,oBAAM,8BAYhB\"}") +============================= test.tsx_App_component_open_event_91f0czdL41I.js (ENTRY POINT)== + +import { _capturesObj } from "@qwik.dev/core"; +import { openFeature } from "./heavy-feature"; +// +export const s_91f0czdL41I = ()=>{ + let count = _capturesObj._[0]; + return openFeature(count.value); +}; + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;6BAKuB;;WAAM,YAAY,MAAM,KAAK\"}") +/* +{ + "origin": "test.tsx", + "name": "s_91f0czdL41I", + "entry": null, + "displayName": "test.tsx_App_component_open_event", + "hash": "91f0czdL41I", + "canonicalFilename": "test.tsx_App_component_open_event_91f0czdL41I", + "path": "", + "extension": "js", + "parent": "s_ckEPmXZlub0", + "ctxKind": "function", + "ctxName": "event$", + "captures": true, + "loc": [ + 207, + 237 + ], + "captureNames": [ + "count" + ] +} +*/ +============================= test.tsx_App_component_Fragment_button_q_e_click_1_MAgjEZcOmJg.js (ENTRY POINT)== + +export const s_MAgjEZcOmJg = ()=>console.log('click'); + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\"6BAWwB,IAAM,QAAQ,GAAG,CAAC\"}") +/* +{ + "origin": "test.tsx", + "name": "s_MAgjEZcOmJg", + "entry": null, + "displayName": "test.tsx_App_component_Fragment_button_q_e_click_1", + "hash": "MAgjEZcOmJg", + "canonicalFilename": "test.tsx_App_component_Fragment_button_q_e_click_1_MAgjEZcOmJg", + "path": "", + "extension": "js", + "parent": "s_ckEPmXZlub0", + "ctxKind": "eventHandler", + "ctxName": "onClick$", + "captures": false, + "loc": [ + 393, + 419 + ] +} +*/ +============================= test.tsx_App_component_ckEPmXZlub0.js == + +import { Fragment as _Fragment } from "@qwik.dev/core/jsx-runtime"; +import { _jsxSorted } from "@qwik.dev/core"; +import { eventQrl } from "@qwik.dev/core"; +import { qrl } from "@qwik.dev/core"; +import { useSignal } from "@qwik.dev/core"; +// +const q_s_91f0czdL41I = /*#__PURE__*/ qrl(()=>import("./test.tsx_App_component_open_event_91f0czdL41I"), "s_91f0czdL41I"); +const q_s_L8uVMi11z9s = /*#__PURE__*/ qrl(()=>import("./test.tsx_App_component_read_L8uVMi11z9s"), "s_L8uVMi11z9s"); +const q_s_MAgjEZcOmJg = /*#__PURE__*/ qrl(()=>import("./test.tsx_App_component_Fragment_button_q_e_click_1_MAgjEZcOmJg"), "s_MAgjEZcOmJg"); +const q_s_YRU7SJ8EHtY = /*#__PURE__*/ qrl(()=>import("./test.tsx_App_component_Fragment_button_q_e_click_YRU7SJ8EHtY"), "s_YRU7SJ8EHtY").m(); +// +export const s_ckEPmXZlub0 = ()=>{ + const count = useSignal(0); + const open$ = eventQrl(q_s_91f0czdL41I.w([ + count + ])); + const read = q_s_L8uVMi11z9s.w([ + count + ]); + read(); + return /*#__PURE__*/ _jsxSorted(_Fragment, null, null, [ + /*#__PURE__*/ _jsxSorted("button", { + "q:p": count + }, { + "q-e:click": q_s_YRU7SJ8EHtY + }, "increment", 7, null), + /*#__PURE__*/ _jsxSorted("button", null, { + "q-e:click": q_s_MAgjEZcOmJg + }, "plain", 3, null), + /*#__PURE__*/ _jsxSorted("button", null, { + "q-e:click": open$ + }, "open", 3, null) + ], 1, "u6_0"); +}; + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;;;;;;;6BAG8B;IAC5B,MAAM,QAAQ,UAAU;IACxB,MAAM,QAAQ;;;IACd,MAAM;;;IACN;IACA,qBACE;sBACE,WAAC;;;YAAO,WAAQ;WAAuB;sBACvC,WAAC;YAAO,WAAQ;WAA8B;sBAC9C,WAAC;YAAO,aAAU;WAAO;;AAG/B\"}") +/* +{ + "origin": "test.tsx", + "name": "s_ckEPmXZlub0", + "entry": "test.tsx_entry_App", + "displayName": "test.tsx_App_component", + "hash": "ckEPmXZlub0", + "canonicalFilename": "test.tsx_App_component_ckEPmXZlub0", + "path": "", + "extension": "js", + "parent": null, + "ctxKind": "function", + "ctxName": "component$", + "captures": false, + "loc": [ + 146, + 495 + ] +} +*/ +============================= test.tsx_App_component_Fragment_button_q_e_click_YRU7SJ8EHtY.js (ENTRY POINT)== + +export const s_YRU7SJ8EHtY = (_, _1, count)=>count.value++; + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\"6BAUwB,gBAAM,MAAM,KAAK\"}") +/* +{ + "origin": "test.tsx", + "name": "s_YRU7SJ8EHtY", + "entry": null, + "displayName": "test.tsx_App_component_Fragment_button_q_e_click", + "hash": "YRU7SJ8EHtY", + "canonicalFilename": "test.tsx_App_component_Fragment_button_q_e_click_YRU7SJ8EHtY", + "path": "", + "extension": "js", + "parent": "s_ckEPmXZlub0", + "ctxKind": "eventHandler", + "ctxName": "onClick$", + "captures": false, + "loc": [ + 329, + 348 + ], + "paramNames": [ + "_", + "_1", + "count" + ] +} +*/ +============================= test.tsx_App_component_read_L8uVMi11z9s.js == + +import { _capturesObj } from "@qwik.dev/core"; +// +export const s_L8uVMi11z9s = ()=>{ + let count = _capturesObj._[0]; + return count.value; +}; + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;6BAMiB;;WAAM,MAAM,KAAK\"}") +/* +{ + "origin": "test.tsx", + "name": "s_L8uVMi11z9s", + "entry": "test.tsx_entry_App", + "displayName": "test.tsx_App_component_read", + "hash": "L8uVMi11z9s", + "canonicalFilename": "test.tsx_App_component_read_L8uVMi11z9s", + "path": "", + "extension": "js", + "parent": "s_ckEPmXZlub0", + "ctxKind": "function", + "ctxName": "$", + "captures": true, + "loc": [ + 257, + 274 + ], + "captureNames": [ + "count" + ] +} +*/ +== DIAGNOSTICS == + +[] diff --git a/packages/optimizer/core/src/snapshots/qwik_core__test__smart_visible_task_heavy_library.snap b/packages/optimizer/core/src/snapshots/qwik_core__test__smart_visible_task_heavy_library.snap new file mode 100644 index 00000000000..30485618599 --- /dev/null +++ b/packages/optimizer/core/src/snapshots/qwik_core__test__smart_visible_task_heavy_library.snap @@ -0,0 +1,105 @@ +--- +source: packages/optimizer/core/src/test.rs +assertion_line: 93 +expression: output +--- +==INPUT== + +import { component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; +import { renderChart } from 'heavy-library'; + +export const App = component$(() => { + const count = useSignal(0); + useVisibleTask$(() => renderChart(count.value)); + return
{count.value}
; +}); + +============================= test.js == + +import { componentQrl } from "@qwik.dev/core"; +import { qrl } from "@qwik.dev/core"; +// +const q_s_ckEPmXZlub0 = /*#__PURE__*/ qrl(()=>import("./test.tsx_App_component_ckEPmXZlub0"), "s_ckEPmXZlub0"); +// +export const App = /*#__PURE__*/ componentQrl(q_s_ckEPmXZlub0); + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;AAGA,OAAO,MAAM,oBAAM,8BAIhB\"}") +============================= test.tsx_App_component_useVisibleTask_e7KrEs3jyoc.js == + +import { _capturesObj } from "@qwik.dev/core"; +import { renderChart } from "heavy-library"; +// +export const s_e7KrEs3jyoc = ()=>{ + let count = _capturesObj._[0]; + return renderChart(count.value); +}; + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;6BAKkB;;WAAM,YAAY,MAAM,KAAK\"}") +/* +{ + "origin": "test.tsx", + "name": "s_e7KrEs3jyoc", + "entry": "test.tsx_entry_App", + "displayName": "test.tsx_App_component_useVisibleTask", + "hash": "e7KrEs3jyoc", + "canonicalFilename": "test.tsx_App_component_useVisibleTask_e7KrEs3jyoc", + "path": "", + "extension": "js", + "parent": "s_ckEPmXZlub0", + "ctxKind": "function", + "ctxName": "useVisibleTask$", + "captures": true, + "loc": [ + 206, + 236 + ], + "captureNames": [ + "count" + ] +} +*/ +============================= test.tsx_App_component_ckEPmXZlub0.js == + +import { _jsxSorted } from "@qwik.dev/core"; +import { _wrapProp } from "@qwik.dev/core"; +import { qrl } from "@qwik.dev/core"; +import { useSignal } from "@qwik.dev/core"; +import { useVisibleTaskQrl } from "@qwik.dev/core"; +// +const q_s_e7KrEs3jyoc = /*#__PURE__*/ qrl(()=>import("./test.tsx_App_component_useVisibleTask_e7KrEs3jyoc"), "s_e7KrEs3jyoc"); +// +export const s_ckEPmXZlub0 = ()=>{ + const count = useSignal(0); + useVisibleTaskQrl(q_s_e7KrEs3jyoc.w([ + count + ])); + return /*#__PURE__*/ _jsxSorted("div", null, null, _wrapProp(count), 3, "u6_0"); +}; + + +Some("{\"version\":3,\"sources\":[\"/user/qwik/src/test.tsx\"],\"names\":[],\"mappings\":\";;;;;;;;6BAG8B;IAC5B,MAAM,QAAQ,UAAU;IACxB;;;IACA,qBAAO,WAAC,6BAAK;AACf\"}") +/* +{ + "origin": "test.tsx", + "name": "s_ckEPmXZlub0", + "entry": "test.tsx_entry_App", + "displayName": "test.tsx_App_component", + "hash": "ckEPmXZlub0", + "canonicalFilename": "test.tsx_App_component_ckEPmXZlub0", + "path": "", + "extension": "js", + "parent": null, + "ctxKind": "function", + "ctxName": "component$", + "captures": false, + "loc": [ + 150, + 275 + ] +} +*/ +== DIAGNOSTICS == + +[] diff --git a/packages/optimizer/core/src/test.rs b/packages/optimizer/core/src/test.rs index 8227fe15e2c..4567e451b06 100644 --- a/packages/optimizer/core/src/test.rs +++ b/packages/optimizer/core/src/test.rs @@ -1,6 +1,7 @@ #![allow(unused_must_use)] use super::*; +use crate::transform::SegmentKind; use serde_json::to_string_pretty; use swc_atoms::Atom; @@ -87,6 +88,79 @@ fn test_input_fn(input: TestInput) -> Result { }) } +#[test] +fn smart_visible_task_heavy_library() { + test_input!(TestInput { + code: include_str!("fixtures/smart-visible-task-heavy-library.tsx").to_string(), + entry_strategy: EntryStrategy::Smart, + transpile_ts: true, + transpile_jsx: true, + mode: EmitMode::Prod, + ..TestInput::default() + }); +} + +#[test] +fn smart_lifted_event_captures() { + let code = include_str!("fixtures/smart-lifted-captures.tsx").to_string(); + let result = test_input_fn(TestInput { + code: code.clone(), + entry_strategy: EntryStrategy::Smart, + transpile_ts: true, + transpile_jsx: true, + mode: EmitMode::Prod, + ..TestInput::default() + }); + let output = result.as_ref().unwrap(); + let handlers: Vec<_> = output + .modules + .iter() + .filter_map(|module| module.segment.as_ref()) + .filter(|segment| segment.ctx_kind == SegmentKind::EventHandler) + .collect(); + assert_eq!(handlers.len(), 2); + let lifted = handlers + .iter() + .find(|segment| { + segment + .param_names + .as_ref() + .is_some_and(|params| params.len() > 2) + }) + .unwrap(); + assert!(!lifted.captures); + assert!(lifted.entry.is_none()); + assert_eq!( + handlers + .iter() + .filter(|segment| segment.entry.is_none()) + .count(), + 2 + ); + let callback = output + .modules + .iter() + .filter_map(|module| module.segment.as_ref()) + .find(|segment| segment.ctx_name == "$") + .unwrap(); + let render = output + .modules + .iter() + .filter_map(|module| module.segment.as_ref()) + .find(|segment| segment.ctx_name == "component$") + .unwrap(); + assert!(render.entry.is_some()); + assert_eq!(callback.entry, render.entry); + let event = output + .modules + .iter() + .filter_map(|module| module.segment.as_ref()) + .find(|segment| segment.ctx_name == "event$") + .unwrap(); + assert!(event.entry.is_none()); + snapshot_res!(&result, format!("==INPUT==\n\n{}", code)); +} + #[test] fn example_1() { test_input!(TestInput { diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index 38f36f63835..cbd5bebf145 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -2,6 +2,7 @@ import type { CodeSplittingOptions, ChunkingContext } from 'rolldown'; import type { DevEnvironment, HotUpdateOptions, Plugin, Rolldown, ViteDevServer } from 'vite'; import { hashCode } from '../../../qwik/src/core/shared/utils/hash_code'; import { generateManifestFromBundles, getValidManifest } from '../manifest'; +import { isStrippedSegment } from '../../../ts-optimizer/src/optimizer/rewrite/predicates'; import type { DecoratorOptions, Diagnostic, @@ -1458,14 +1459,18 @@ export const isDev = ${JSON.stringify(isDev)}; if (module) { const segment = module.meta.segment as SegmentAnalysis | undefined; if (segment) { + const isSmartClientOnly = + opts.entryStrategy.type === 'smart' && + isStrippedSegment(segment.ctxName, segment.ctxKind, CLIENT_STRIP_CTX_NAME, true); // TODO: Remove useComputed$ once we don't need to eagerly load them anymore - if (EAGER_CTX_NAMES.has(segment.ctxName)) { + if (EAGER_CTX_NAMES.has(segment.ctxName) && !isSmartClientOnly) { return null; } const { hash } = segment; // Group segments by entry or Qwik Insights hash. const chunkName = - (opts.entryStrategy as SmartEntryStrategy).manual?.[hash] || segment.entry; + (opts.entryStrategy as SmartEntryStrategy).manual?.[hash] || + (isSmartClientOnly ? `segment_${hash}` : segment.entry); if (chunkName) { return sanitizeChunkGroupName(chunkName); } diff --git a/packages/qwik-vite/src/plugins/plugin.unit.ts b/packages/qwik-vite/src/plugins/plugin.unit.ts index b8d00ad7d8b..1567152c601 100644 --- a/packages/qwik-vite/src/plugins/plugin.unit.ts +++ b/packages/qwik-vite/src/plugins/plugin.unit.ts @@ -144,6 +144,95 @@ test('entryStrategy, smart', async () => { assert.deepEqual(opts.entryStrategy.type, 'smart'); }); +test.each([ + { tsOptimizer: false, compiled: false }, + { tsOptimizer: true, compiled: false }, + { tsOptimizer: false, compiled: true }, + { tsOptimizer: true, compiled: true }, +])( + 'smart preserves optimizer entries and SSR stripping inside custom hooks ($tsOptimizer, compiled: $compiled)', + async ({ tsOptimizer, compiled }) => { + const hooks = compiled + ? `useTaskQrl(inlinedQrl(() => { const [value] = useLexicalScope(); console.log('shared-task', value); }, 's_task', [value])); + useVisibleTaskQrl(inlinedQrl(() => { const [value] = useLexicalScope(); clientFeature(value); }, 's_visible', [value])); + useClientFeatureQrl(inlinedQrl(() => { const [value] = useLexicalScope(); clientFeature(value); }, 's_client', [value]));` + : `useTask$(() => console.log('shared-task', value)); + visible(() => clientFeature(value)); + useClientFeature$(() => clientFeature(value));`; + const source = `import { $, component$, inlinedQrl, useLexicalScope, useTaskQrl, useVisibleTaskQrl, useTask$, useVisibleTask$ as visible } from '@qwik.dev/core'; + import { clientFeature } from './client-feature'; + import { useClientFeature$, useClientFeatureQrl } from './custom-client-hook'; + function useCustomHook(value) { + ${hooks} + } + export const App = component$(() => { + const value = 1; + useCustomHook(value); + const read = $(() => console.log('shared-callback', value)); + read(); + return ; + });`; + const transform = async (target: 'client' | 'ssr') => { + const plugin = await mockPlugin(process.platform, true, tsOptimizer); + await plugin.normalizeOptions({ + rootDir: '/root', + srcDir: '/root/src', + target, + buildMode: 'production', + entryStrategy: { type: 'smart' }, + }); + const result = await plugin.transform( + { + addWatchFile() {}, + emitFile() {}, + async resolve() { + return null; + }, + } as any, + source, + '/root/src/example.tsx' + ); + const segments = await Promise.all( + result!.meta!.qwikdeps.map(async (id: string) => { + const output = (await plugin.load({} as any, id)) as { + code: string; + meta: { segment: any }; + }; + return { code: output.code, segment: output.meta.segment }; + }) + ); + return { + segments, + code: [result!.code, ...segments.map((output) => output.code)].join('\n'), + }; + }; + const { segments: client } = await transform('client'); + const server = await transform('ssr'); + const visible = client.find(({ segment }) => segment.ctxName === 'useVisibleTask$')!; + const task = client.find(({ segment }) => segment.ctxName === 'useTask$')!; + const callback = client.find(({ segment }) => segment.ctxName === '$')!; + expect(visible.segment.entry).toBe(task.segment.entry); + expect(visible.code).toContain('clientFeature('); + expect( + client.find(({ segment }) => segment.ctxName === 'useClientFeature$')!.segment.entry + ).toBe(task.segment.entry); + expect(task.segment.entry).toBe('example.tsx_entry_useCustomHook'); + expect(callback.segment.entry).toBe( + client.find(({ segment }) => segment.ctxName === 'component$')!.segment.entry + ); + expect( + client.find(({ segment }) => segment.ctxKind === 'eventHandler')!.segment.entry + ).toBeNull(); + expect(server.code).toContain('shared-task'); + expect(server.code).toContain('shared-callback'); + if (compiled) { + expect(server.code).toContain('_noopQrl("s_visible")'); + } else { + expect(server.code).not.toContain('clientFeature(value)'); + } + } +); + test('entryStrategy, segment no forceFullBuild', async () => { const plugin = await mockPlugin(); const opts = await plugin.normalizeOptions({ entryStrategy: { type: 'segment' } }); diff --git a/packages/qwik-vite/src/plugins/rolldown.unit.ts b/packages/qwik-vite/src/plugins/rolldown.unit.ts index 248799e3c22..5656eac87a6 100644 --- a/packages/qwik-vite/src/plugins/rolldown.unit.ts +++ b/packages/qwik-vite/src/plugins/rolldown.unit.ts @@ -2,7 +2,7 @@ import path, { resolve } from 'node:path'; import { qwikRolldown } from './rolldown'; import type { Rolldown } from 'vite'; import type { OptimizerOptions } from '../types'; -import type { NormalizedQwikPluginOptions } from './plugin'; +import { createQwikPlugin, type NormalizedQwikPluginOptions } from './plugin'; import { assert, expect, test, vi } from 'vitest'; import { normalizePath } from '../../../qwik/src/testing/util'; @@ -208,6 +208,234 @@ async function clientCodeSplitting(output: Rolldown.OutputOptions) { return outputOpts.codeSplitting as CodeSplitting; } +test.each([undefined, { click123: 'manual_click', visible123: 'manual_visible' }])( + 'smart preserves lazy segment boundaries and manual groups (%s)', + async (manual) => { + const plugin = qwikRolldown({ + optimizerOptions: mockOptimizerOptions(), + entryStrategy: { type: 'smart', manual }, + }); + await plugin.options!({}); + const output = (await plugin.outputOptions!({})) as Rolldown.OutputOptions; + const groups = (output.codeSplitting as CodeSplitting).groups!; + const name = groups[2].name as Rolldown.CodeSplittingNameFunction; + const ctx = { + getModuleInfo: () => ({ + meta: { + segment: { + hash: 'click123', + ctxName: 'event$', + ctxKind: 'function', + entry: 'entry_render', + }, + }, + }), + } as any; + assert.equal(name('/src/click.js', ctx), manual?.click123 ?? 'segment_click123'); + ctx.getModuleInfo = () => ({ + meta: { segment: { hash: 'visible123', ctxName: 'useVisibleTask$', entry: 'entry_render' } }, + }); + assert.equal(name('/src/visible.js', ctx), manual?.visible123 ?? 'segment_visible123'); + ctx.getModuleInfo = () => ({ + meta: { segment: { hash: 'read123', ctxName: '$', ctxKind: 'function', entry: null } }, + }); + assert.equal(name('/src/read.js', ctx), null); + } +); + +test('smart rendering never imports a future interaction library through bundler helpers', async () => { + const plugin = qwikRolldown({ + optimizerOptions: mockOptimizerOptions(), + entryStrategy: { type: 'smart' }, + }); + await plugin.options!({}); + const output = (await plugin.outputOptions!({})) as Rolldown.OutputOptions; + const source: Record = { + main: `import('render').then(console.log); import('click').then(console.log);`, + render: `export const value = 1; export const self = () => import('render');`, + click: `export { default } from 'heavy-library';`, + 'heavy-library': `exports.feature = () => console.log('future interaction');`, + }; + const { rolldown } = await import('rolldown'); + const build = await rolldown({ + input: 'main', + preserveEntrySignatures: 'allow-extension', + plugins: [ + { + name: 'fixture', + resolveId: (id) => (id in source ? id : null), + load: (id) => + id in source + ? { + code: source[id], + meta: + id === 'render' || id === 'click' + ? { + segment: { + hash: id, + ctxName: id === 'render' ? 'component$' : 'event$', + entry: id === 'render' ? 'entry_render' : null, + }, + } + : {}, + } + : null, + }, + ], + }); + try { + const { output: chunks } = await build.generate({ + format: 'es', + codeSplitting: output.codeSplitting, + }); + const byName = new Map( + chunks.filter((chunk) => chunk.type === 'chunk').map((chunk) => [chunk.fileName, chunk]) + ); + const render = [...byName.values()].find((chunk) => 'render' in chunk.modules)!; + const visited = new Set(); + const visit = (name: string) => { + if (visited.has(name)) { + return; + } + visited.add(name); + const chunk = byName.get(name)!; + expect(Object.keys(chunk.modules)).not.toContain('heavy-library'); + chunk.imports.forEach(visit); + }; + visit(render.fileName); + } finally { + await build.close(); + } +}); + +test.each( + [ + { tsOptimizer: true, minify: false }, + { tsOptimizer: false, minify: false }, + { tsOptimizer: true, minify: true }, + { tsOptimizer: false, minify: true }, + ].flatMap((options) => + (['smart', 'component'] as const).flatMap((strategy) => + (['native', 'win32'] as const).map((pathStyle) => ({ ...options, strategy, pathStyle })) + ) + ) +)( + '$strategy separates client-only code ($tsOptimizer, $minify, $pathStyle)', + async ({ tsOptimizer, minify, strategy, pathStyle }) => { + const fixturePath = pathStyle === 'win32' ? path.win32 : path; + const rootDir = normalizePath(resolve(cwd, 'fixture')); + const srcDir = `${rootDir}/src`; + const clientFeatureId = `${srcDir}/client-feature`; + const plugin = createQwikPlugin({ tsOptimizer }); + await plugin.init(); + await plugin.normalizeOptions({ + target: 'client', + buildMode: 'production', + entryStrategy: { type: strategy }, + rootDir, + srcDir, + }); + const parent = `${srcDir}/app.tsx`; + const transformed = await plugin.transform( + { addWatchFile() {}, emitFile() {} } as any, + strategy === 'component' + ? `import { $, component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; + import { clientFeature } from './client-feature'; + export const App = component$(() => { + const state = useSignal(0); + const read = $(() => state.value); + read(); + useVisibleTask$(() => clientFeature(state.value)); + return
{state.value}
; + });` + : `import { $, component$, event$, useTask$, useVisibleTask$ as visible } from '@qwik.dev/core'; + import { clientFeature } from './client-feature'; + function useCustomHook() { + const value = 1; + useTask$(() => console.log('shared', value)); + visible(() => clientFeature(value)); + return event$(() => clientFeature(value)); + } + export const App = component$(() => { + const value = 1; + const read = $(() => value); + read(); + const click = useCustomHook(); + return <> + + + ; + });`, + parent + ); + const modules = new Map([[parent, transformed]]); + for (const id of transformed!.meta!.qwikdeps) { + modules.set(id, await plugin.load({} as any, id)); + } + const { rolldown } = await import('rolldown'); + const build = await rolldown({ + input: [...modules.keys()], + external: (id) => id.startsWith('@qwik.dev/'), + preserveEntrySignatures: 'allow-extension', + plugins: [ + { + name: 'custom-hook-fixture', + resolveId(id, importer) { + const resolved = normalizePath( + id.startsWith('.') ? fixturePath.resolve(fixturePath.dirname(importer!), id) : id + ); + if (modules.has(resolved)) { + return resolved; + } + if (resolved === clientFeatureId) { + return resolved; + } + return null; + }, + load(id) { + if (id === clientFeatureId) { + return 'exports.clientFeature = () => "CLIENT_ONLY_HEAVY";'; + } + return modules.get(id); + }, + }, + ], + }); + try { + const { output } = await build.generate({ + format: 'es', + minify, + codeSplitting: { ...plugin.codeSplitting(), includeDependenciesRecursively: false }, + }); + const chunks = output.filter((chunk) => chunk.type === 'chunk'); + const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk])); + const componentId = [...modules].find( + ([, output]) => output?.meta?.segment?.ctxName === 'component$' + )![0]; + const render = chunks.find((chunk) => componentId in chunk.modules)!; + const readId = [...modules].find(([, output]) => output?.meta?.segment?.ctxName === '$')![0]; + expect(readId in render.modules).toBe(true); + const visited = new Set(); + const visit = (file: string) => { + if (visited.has(file)) { + return; + } + visited.add(file); + const chunk = byName.get(file); + if (!chunk) { + return; + } + expect(Object.keys(chunk.modules)).not.toContain(clientFeatureId); + chunk.imports.forEach(visit); + }; + visit(render.fileName); + expect(chunks.some((chunk) => chunk.code.includes('CLIENT_ONLY_HEAVY'))).toBe(true); + } finally { + await build.close(); + } + } +); + test('codeSplitting starts with the qwik groups and disables dependency recursion', async () => { const codeSplitting = await clientCodeSplitting({}); diff --git a/packages/qwik/src/core/tests/use-visible-task.spec.tsx b/packages/qwik/src/core/tests/use-visible-task.spec.tsx index 1c15a2cb167..b222587cf73 100644 --- a/packages/qwik/src/core/tests/use-visible-task.spec.tsx +++ b/packages/qwik/src/core/tests/use-visible-task.spec.tsx @@ -69,7 +69,7 @@ describe.each([ ); }); - it('should execute visible task with strategy document-ready', async () => { + it.each([false, true])('should execute document-ready task (yield: %s)', async (shouldYield) => { const VisibleCmp = component$(() => { const state = useSignal('SSR'); useVisibleTask$( @@ -84,14 +84,25 @@ describe.each([ }); const { vNode, document } = await render(, { debug }); - await trigger(document.body, 'span', 'd:qinit'); - expect(vNode).toMatchVDOM( - - - CSR - - - ); + let now = 0; + // Force a yield before the resumed task finishes resolving. + const clock = shouldYield + ? vi.spyOn(performance, 'now').mockImplementation(() => (now += 20)) + : undefined; + try { + await trigger(document.body, 'span', 'd:qinit'); + await vi.waitFor(() => { + expect(vNode).toMatchVDOM( + + + CSR + + + ); + }); + } finally { + clock?.mockRestore(); + } }); it('should execute visible task with strategy document-idle', async () => { diff --git a/packages/ts-optimizer/src/optimizer/segment/entry-strategy.ts b/packages/ts-optimizer/src/optimizer/segment/entry-strategy.ts index d62813444b3..db031905e88 100644 --- a/packages/ts-optimizer/src/optimizer/segment/entry-strategy.ts +++ b/packages/ts-optimizer/src/optimizer/segment/entry-strategy.ts @@ -49,8 +49,12 @@ export function resolveEntryField( case 'component': return getRootEntry(segment) ?? 'entry_segments'; case 'smart': - // Capture-free handlers load on their own; everything else loads with its root. - if (!segment.captures && (segment.ctxKind !== 'function' || segment.ctxName === 'event$')) { + // Known events stay separate even when they capture state. + if ( + segment.ctxKind === 'eventHandler' || + segment.ctxName === 'event$' || + (!segment.captures && segment.ctxKind !== 'function') + ) { return null; } return getRootEntry(segment); diff --git a/packages/ts-optimizer/tests/optimizer/segment/entry-strategy-transform.test.ts b/packages/ts-optimizer/tests/optimizer/segment/entry-strategy-transform.test.ts index 4b8ebda0d55..5b60413f572 100644 --- a/packages/ts-optimizer/tests/optimizer/segment/entry-strategy-transform.test.ts +++ b/packages/ts-optimizer/tests/optimizer/segment/entry-strategy-transform.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; import { transformModule } from '../../../src/optimizer/transform/index.js'; import { mkFilePath, mkSourceText } from '../../../src/optimizer/types/brands.js'; @@ -55,6 +56,43 @@ const expectedEntries = { }; describe('smart entry strategy', () => { + it('keeps captured handlers separate from their component render', () => { + const code = readFileSync( + new URL('../../../../optimizer/core/src/fixtures/smart-lifted-captures.tsx', import.meta.url), + 'utf8' + ); + const result = transformModule({ + input: [{ path: mkFilePath('test.tsx'), code: mkSourceText(code) }], + srcDir: mkFilePath('.'), + entryStrategy: { type: 'smart' }, + mode: 'prod', + transpileTs: true, + transpileJsx: true, + }); + const handlers = result.modules.flatMap((mod) => + mod.kind === 'segment' && mod.segment.ctxKind === 'eventHandler' ? [mod.segment] : [] + ); + + expect(handlers).toHaveLength(2); + const lifted = handlers.find((handler) => handler.displayName.endsWith('_click'))!; + expect(lifted.captures).toBe(false); + expect(lifted.entry).toBeNull(); + expect(handlers.find((handler) => handler !== lifted)!.entry).toBeNull(); + const callback = result.modules.find( + (mod) => mod.kind === 'segment' && mod.segment.ctxName === '$' + ); + const render = result.modules.find( + (mod) => mod.kind === 'segment' && mod.segment.ctxName === 'component$' + ); + expect(callback?.kind === 'segment' && callback.segment.entry).toBe( + render?.kind === 'segment' && render.segment.entry + ); + const event = result.modules.find( + (mod) => mod.kind === 'segment' && mod.segment.ctxName === 'event$' + ); + expect(event?.kind === 'segment' && event.segment.entry).toBeNull(); + }); + it('groups segments by their root context in prod mode', () => { expect(entriesByDisplayName('test.tsx', 'prod')).toEqual(expectedEntries); }); diff --git a/packages/ts-optimizer/tests/optimizer/segment/entry-strategy.test.ts b/packages/ts-optimizer/tests/optimizer/segment/entry-strategy.test.ts index d20710db933..4c4356ee4de 100644 --- a/packages/ts-optimizer/tests/optimizer/segment/entry-strategy.test.ts +++ b/packages/ts-optimizer/tests/optimizer/segment/entry-strategy.test.ts @@ -13,24 +13,36 @@ const segment = { describe('resolveEntryField', () => { describe('smart strategy', () => { + it.each([false, true])('groups ordinary QRLs with their root (captures: %s)', (captures) => { + expect(resolveEntryField('smart', { ...segment, ctxName: '$', captures }, undefined)).toBe( + 'test.tsx_entry_App' + ); + }); + it('groups captured JSX prop callbacks with their root', () => { + expect( + resolveEntryField('smart', { ...segment, ctxKind: 'jSXProp', captures: true }, undefined) + ).toBe('test.tsx_entry_App'); + }); it('keeps capture-free event handlers separate', () => { expect( resolveEntryField('smart', { ...segment, ctxKind: 'eventHandler' }, undefined) ).toBeNull(); }); - it('keeps capture-free event$ functions separate', () => { - expect(resolveEntryField('smart', { ...segment, ctxName: 'event$' }, undefined)).toBeNull(); + it.each([false, true])('keeps explicit events separate (captures: %s)', (captures) => { + expect( + resolveEntryField('smart', { ...segment, ctxName: 'event$', captures }, undefined) + ).toBeNull(); }); - it('groups event handlers that capture with their component', () => { + it('keeps captured JSX handlers separate', () => { expect( resolveEntryField( 'smart', { ...segment, ctxKind: 'eventHandler', captures: true }, undefined ) - ).toBe('test.tsx_entry_App'); + ).toBeNull(); }); it('returns null for segment strategy (alias of smart)', () => { diff --git a/packages/ts-optimizer/tests/optimizer/snapshot-options.ts b/packages/ts-optimizer/tests/optimizer/snapshot-options.ts index 99a5298f33d..698114bdc3d 100644 --- a/packages/ts-optimizer/tests/optimizer/snapshot-options.ts +++ b/packages/ts-optimizer/tests/optimizer/snapshot-options.ts @@ -37,6 +37,18 @@ export const DEFAULT_OPTIONS: Required< }; export const SNAPSHOT_OPTIONS: Record = { + smart_visible_task_heavy_library: { + entryStrategy: { type: 'smart' }, + transpileTs: true, + transpileJsx: true, + mode: 'prod', + }, + smart_lifted_event_captures: { + entryStrategy: { type: 'smart' }, + transpileTs: true, + transpileJsx: true, + mode: 'prod', + }, example_1: {}, example_2: {}, example_3: {},