diff --git a/.changeset/wet-groups-enter.md b/.changeset/wet-groups-enter.md new file mode 100644 index 00000000000..791ed47501a --- /dev/null +++ b/.changeset/wet-groups-enter.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: Add experimental `Show` control-flow component with `when$`, `then$`, and optional `else$` branches. diff --git a/e2e/qwik-e2e/apps/e2e/src/components/show/show.tsx b/e2e/qwik-e2e/apps/e2e/src/components/show/show.tsx new file mode 100644 index 00000000000..4be94b16169 --- /dev/null +++ b/e2e/qwik-e2e/apps/e2e/src/components/show/show.tsx @@ -0,0 +1,72 @@ +import { Show, component$, useSignal } from '@qwik.dev/core'; + +export const ShowRoot = component$(() => { + const withElse = useSignal(false); + const withoutElse = useSignal(false); + const interactive = useSignal(true); + const branchCount = useSignal(0); + const item = useSignal(null); + + return ( + <> +

Show

+ +
+ true} then$={() => Initial then} /> +
+ +
+ false} then$={() => Then} else$={() => Else} /> +
+ +
+ false} then$={() => Empty then} /> +
+ + +
+ withElse.value} + then$={() => Shown} + else$={() => Hidden} + /> +
+ + +
+ withoutElse.value} then$={() => Present} /> +
+ + +
+ interactive.value} + then$={() => ( + + )} + else$={() => Interactive else} + /> +
+
{branchCount.value}
+ + +
+ item.value} + then$={(v) => Got: {v}} + else$={() => No value} + /> +
+ + ); +}); diff --git a/e2e/qwik-e2e/apps/e2e/src/root.tsx b/e2e/qwik-e2e/apps/e2e/src/root.tsx index c97834af1de..6f46b8df46a 100644 --- a/e2e/qwik-e2e/apps/e2e/src/root.tsx +++ b/e2e/qwik-e2e/apps/e2e/src/root.tsx @@ -40,6 +40,7 @@ import { QRL } from './components/qrl/qrl'; import { AsyncRoot } from './components/use-async/use-async'; import { Backpatching } from './components/backpatching/backpatching'; import { EachRoot } from './components/each/each'; +import { ShowRoot } from './components/show/show'; import { SuspenseRoot } from './components/suspense/suspense'; const tests: Record = { @@ -81,6 +82,7 @@ const tests: Record = { '/e2e/async-computed': () => , '/e2e/backpatching': () => , '/e2e/each': () => , + '/e2e/show': () => , '/e2e/suspense': () => , '/e2e/worker': () => , }; diff --git a/e2e/qwik-e2e/dev-server.ts b/e2e/qwik-e2e/dev-server.ts index df6b690e7e8..b79ba8f10d2 100644 --- a/e2e/qwik-e2e/dev-server.ts +++ b/e2e/qwik-e2e/dev-server.ts @@ -208,7 +208,7 @@ export { router } clientManifest = manifest; }, }, - experimental: ['each', 'suspense', 'preventNavigate', 'enableRequestRewrite'], + experimental: ['each', 'show', 'suspense', 'preventNavigate', 'enableRequestRewrite'], }), ], }) @@ -224,7 +224,7 @@ export { router } plugins: [ ...plugins, optimizer.qwikVite({ - experimental: ['each', 'suspense', 'preventNavigate', 'enableRequestRewrite'], + experimental: ['each', 'show', 'suspense', 'preventNavigate', 'enableRequestRewrite'], ssr: { manifestInput: clientManifest, }, diff --git a/e2e/qwik-e2e/tests/show.e2e.ts b/e2e/qwik-e2e/tests/show.e2e.ts new file mode 100644 index 00000000000..88c13dd3170 --- /dev/null +++ b/e2e/qwik-e2e/tests/show.e2e.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; + +test.describe('show', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/e2e/show'); + page.on('pageerror', (err) => expect(err).toEqual(undefined)); + page.on('console', (msg) => { + if (msg.type() === 'error') { + expect(msg.text()).toEqual(undefined); + } + }); + }); + + test('should render initial branches during SSR', async ({ page }) => { + await expect(page.locator('#initial-true')).toHaveText('Initial then'); + await expect(page.locator('#initial-false')).toHaveText('Else'); + await expect(page.locator('#initial-empty')).toHaveText(''); + }); + + test('should update then and else branches after resume', async ({ page }) => { + const result = page.locator('#toggle-with-else-result'); + + await expect(result).toHaveText('Hidden'); + await page.locator('#toggle-with-else').click(); + await expect(result).toHaveText('Shown'); + await page.locator('#toggle-with-else').click(); + await expect(result).toHaveText('Hidden'); + }); + + test('should update empty fallback after resume', async ({ page }) => { + const result = page.locator('#toggle-without-else-result'); + + await expect(result).toHaveText(''); + await page.locator('#toggle-without-else').click(); + await expect(result).toHaveText('Present'); + await page.locator('#toggle-without-else').click(); + await expect(result).toHaveText(''); + }); + + test('should keep event handlers in rendered branches working', async ({ page }) => { + await expect(page.locator('#branch-action')).toHaveText('Inside 0'); + await page.locator('#branch-action').click(); + await expect(page.locator('#branch-action')).toHaveText('Inside 1'); + await expect(page.locator('#branch-count')).toHaveText('1'); + + await page.locator('#toggle-interactive').click(); + await expect(page.locator('#interactive-result')).toHaveText('Interactive else'); + await page.locator('#toggle-interactive').click(); + await expect(page.locator('#branch-action')).toHaveText('Inside 1'); + }); + + test('should pass the when$ value to the then$ branch', async ({ page }) => { + await expect(page.locator('#item-result')).toHaveText('No value'); + await page.locator('#set-item').click(); + await expect(page.locator('#item-result')).toHaveText('Got: hello'); + }); +}); diff --git a/packages/docs/src/routes/api/qwik-optimizer/api.json b/packages/docs/src/routes/api/qwik-optimizer/api.json index 2e42f35eae7..b384ff027f4 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/api.json +++ b/packages/docs/src/routes/api/qwik-optimizer/api.json @@ -60,7 +60,7 @@ } ], "kind": "Enum", - "content": "Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.\n\nAdd experimental features to this enum definition.\n\n\n```typescript\nexport declare enum ExperimentalFeatures \n```\n\n\n\n\n\n\n\n\n\n\n
\n\nMember\n\n\n\n\nValue\n\n\n\n\nDescription\n\n\n
\n\neach\n\n\n\n\n`\"each\"`\n\n\n\n\nEnable the Each keyed-list primitive\n\n\n
\n\nenableRequestRewrite\n\n\n\n\n`\"enableRequestRewrite\"`\n\n\n\n\nEnable request.rewrite()\n\n\n
\n\ninsights\n\n\n\n\n`\"insights\"`\n\n\n\n\nEnable the ability to use the Qwik Insights vite plugin and `` component\n\n\n
\n\nnoSPA\n\n\n\n\n`\"noSPA\"`\n\n\n\n\nDisable SPA navigation handler in Qwik Router\n\n\n
\n\npreventNavigate\n\n\n\n\n`\"preventNavigate\"`\n\n\n\n\nEnable the usePreventNavigate hook\n\n\n
\n\nsuspense\n\n\n\n\n`\"suspense\"`\n\n\n\n\nEnable the Suspense fallback primitive\n\n\n
\n\nvalibot\n\n\n\n\n`\"valibot\"`\n\n\n\n\nEnable the Valibot form validation\n\n\n
", + "content": "Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or `false` via an exact string replacement.\n\nAdd experimental features to this enum definition.\n\n\n```typescript\nexport declare enum ExperimentalFeatures \n```\n\n\n\n\n\n\n\n\n\n\n\n
\n\nMember\n\n\n\n\nValue\n\n\n\n\nDescription\n\n\n
\n\neach\n\n\n\n\n`\"each\"`\n\n\n\n\nEnable the Each keyed-list primitive\n\n\n
\n\nenableRequestRewrite\n\n\n\n\n`\"enableRequestRewrite\"`\n\n\n\n\nEnable request.rewrite()\n\n\n
\n\ninsights\n\n\n\n\n`\"insights\"`\n\n\n\n\nEnable the ability to use the Qwik Insights vite plugin and `` component\n\n\n
\n\nnoSPA\n\n\n\n\n`\"noSPA\"`\n\n\n\n\nDisable SPA navigation handler in Qwik Router\n\n\n
\n\npreventNavigate\n\n\n\n\n`\"preventNavigate\"`\n\n\n\n\nEnable the usePreventNavigate hook\n\n\n
\n\nshow\n\n\n\n\n`\"show\"`\n\n\n\n\nEnable the Show conditional primitive\n\n\n
\n\nsuspense\n\n\n\n\n`\"suspense\"`\n\n\n\n\nEnable the Suspense fallback primitive\n\n\n
\n\nvalibot\n\n\n\n\n`\"valibot\"`\n\n\n\n\nEnable the Valibot form validation\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/plugins/plugin.ts", "mdFile": "qwik-vite.experimentalfeatures.md" }, @@ -339,6 +339,23 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts", "mdFile": "qwik-vite.serverqwikmanifest.md" }, + { + "name": "show", + "id": "experimentalfeatures-show", + "hierarchy": [ + { + "name": "ExperimentalFeatures", + "id": "experimentalfeatures-show" + }, + { + "name": "show", + "id": "experimentalfeatures-show" + } + ], + "kind": "EnumMember", + "content": "", + "mdFile": "qwik-vite.experimentalfeatures.show.md" + }, { "name": "suspense", "id": "experimentalfeatures-suspense", diff --git a/packages/docs/src/routes/api/qwik-optimizer/index.mdx b/packages/docs/src/routes/api/qwik-optimizer/index.mdx index 99ea683aa79..36868bc5e4a 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/index.mdx +++ b/packages/docs/src/routes/api/qwik-optimizer/index.mdx @@ -116,6 +116,19 @@ Enable the usePreventNavigate hook +show + + + +`"show"` + + + +Enable the Show conditional primitive + + + + suspense @@ -1449,6 +1462,8 @@ export type ServerQwikManifest = Pick< [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-vite/src/types.ts) +

show

+

suspense

SymbolMapper

diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 99abf3ef791..a1c6b9a8a6c 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -2073,6 +2073,48 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/platform/platform.ts", "mdFile": "core.setplatform.md" }, + { + "name": "Show", + "id": "show", + "hierarchy": [ + { + "name": "Show", + "id": "show" + } + ], + "kind": "Variable", + "content": "```typescript\nShow: ShowComponent\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/control-flow/show.ts", + "mdFile": "core.show.md" + }, + { + "name": "ShowComponent", + "id": "showcomponent", + "hierarchy": [ + { + "name": "ShowComponent", + "id": "showcomponent" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type ShowComponent = (props: PublicProps>, key: string | null, flags: number, dev?: DevJSX) => JSXOutput;\n```\n**References:** [JSXOutput](#jsxoutput), [PublicProps](#publicprops), [ShowProps](#showprops), [DevJSX](#devjsx)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/control-flow/show.ts", + "mdFile": "core.showcomponent.md" + }, + { + "name": "ShowProps", + "id": "showprops", + "hierarchy": [ + { + "name": "ShowProps", + "id": "showprops" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface ShowProps \n```\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nelse$?\n\n\n\n\n\n\n\n[QRL](#qrl-type-alias)<(when: WHEN) => ELSE>\n\n\n\n\n_(Optional)_\n\n\n
\n\nthen$\n\n\n\n\n\n\n\n[QRL](#qrl-type-alias)<(when: WHEN) => THEN>\n\n\n\n\n\n
\n\nwhen$\n\n\n\n\n\n\n\n[QRL](#qrl-type-alias)<() => WHEN>\n\n\n\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/control-flow/show.ts", + "mdFile": "core.showprops.md" + }, { "name": "Signal", "id": "signal", diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index e60d9ff0c6e..727c4d2cf3c 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -4505,6 +4505,101 @@ plt [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/platform/platform.ts) +

Show

+ +```typescript +Show: ShowComponent; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/control-flow/show.ts) + +

ShowComponent

+ +```typescript +export type ShowComponent = < + WHEN = unknown, + THEN extends JSXOutput = JSXOutput, + ELSE extends JSXOutput = JSXOutput, +>( + props: PublicProps>, + key: string | null, + flags: number, + dev?: DevJSX, +) => JSXOutput; +``` + +**References:** [JSXOutput](#jsxoutput), [PublicProps](#publicprops), [ShowProps](#showprops), [DevJSX](#devjsx) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/control-flow/show.ts) + +

ShowProps

+ +```typescript +export interface ShowProps +``` + + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +else$? + + + + + +[QRL](#qrl-type-alias)<(when: WHEN) => ELSE> + + + +_(Optional)_ + +
+ +then$ + + + + + +[QRL](#qrl-type-alias)<(when: WHEN) => THEN> + + + +
+ +when$ + + + + + +[QRL](#qrl-type-alias)<() => WHEN> + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/control-flow/show.ts) +

Signal

A signal is a reactive value which can be read and written. When the signal is written, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered. diff --git a/packages/docs/src/routes/demo/component/show/index.tsx b/packages/docs/src/routes/demo/component/show/index.tsx new file mode 100644 index 00000000000..0ec93e4e089 --- /dev/null +++ b/packages/docs/src/routes/demo/component/show/index.tsx @@ -0,0 +1,22 @@ +import { Show, component$, useSignal } from '@qwik.dev/core'; + +export default component$(() => { + const count = useSignal(0); + + return ( + <> + + + + count.value} + then$={(count) => ( +

+ {count} item{count === 1 ? '' : 's'} in your cart. +

+ )} + else$={(count) =>

Your cart is empty ({count}).

} + /> + + ); +}); diff --git a/packages/docs/src/routes/docs/(qwik)/core/rendering/index.mdx b/packages/docs/src/routes/docs/(qwik)/core/rendering/index.mdx index ecc8dac6c89..b2bd765c9ba 100644 --- a/packages/docs/src/routes/docs/(qwik)/core/rendering/index.mdx +++ b/packages/docs/src/routes/docs/(qwik)/core/rendering/index.mdx @@ -125,6 +125,9 @@ export const Parent = component$(() => { Conditional rendering is done with the Javascipt [ternary operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator) `?`, the `&&` operator, or just by using `if` statements. +Qwik also provides the experimental [`Show`](/docs/labs/show/) component for QRL-based +conditional control flow once it is enabled with `experimental: ['show']`. + ```tsx import { component$ } from '@qwik.dev/core'; diff --git a/packages/docs/src/routes/docs/labs/show/index.mdx b/packages/docs/src/routes/docs/labs/show/index.mdx new file mode 100644 index 00000000000..617f410fbf1 --- /dev/null +++ b/packages/docs/src/routes/docs/labs/show/index.mdx @@ -0,0 +1,148 @@ +--- +title: 'Show | Experimental' +keywords: 'conditional rendering, control flow, branches' +--- + +import CodeSandbox from '../../../../components/code-sandbox/index.tsx'; +import { Note } from '~/components/note/note'; + +# `Show` + +**Stage:** `implementation` + + + `Show` is experimental and currently a technical preview. Its API and behavior may still change + as we gather feedback from real-world usage. + + +`Show` is a built-in Qwik component for conditional rendering. + +It is useful when you want the condition and branches to be QRL-based. `Show` tracks the value +returned from `when$`, renders `then$` when it is truthy, and renders `else$` when it is falsey. +If `else$` is omitted, falsey conditions render empty content. + +To use it, you must add `experimental: ['show']` to your `qwikVite` plugin options: + +```ts +// vite.config.ts +import { defineConfig } from 'vite'; +import { qwikVite } from '@qwik.dev/core/optimizer'; + +export default defineConfig(() => { + return { + plugins: [ + qwikVite({ + experimental: ['show'], + }), + ], + }; +}); +``` + + +```tsx /Show/ /when$/ /then$/ /else$/ +import { Show, component$, useSignal } from '@qwik.dev/core'; + +export default component$(() => { + const count = useSignal(0); + + return ( + <> + + + + count.value} + then$={(count) => ( +

+ {count} item{count === 1 ? '' : 's'} in your cart. +

+ )} + else$={(count) =>

Your cart is empty ({count}).

} + /> + + ); +}); +``` +
+ +## Props + +`Show` accepts three props: + +- `when$`: returns the condition. Truthy values select `then$`, falsey values select `else$`. +- `then$`: receives the value returned by `when$` and returns the JSX to render when it is truthy. +- `else$`: optionally receives the value returned by `when$` and returns the JSX to render when it + is falsey. + +`when$`, `then$`, and `else$` are QRL props. Use the `$` suffix so the optimizer can extract the +closures. + +The `then$` and `else$` callback argument is typed from the return value of `when$`: + +```tsx /Show/ /when$/ /then$/ /else$/ +const count = useSignal(0); + + count.value} + then$={(count) =>

{count} items selected.

} + else$={(count) =>

No items selected ({count}).

} +/>; +``` + +## Without `else$` + +When `else$` is not provided, a falsey condition renders nothing: + +```tsx /Show/ /when$/ /then$/ +import { Show, component$, useSignal } from '@qwik.dev/core'; + +export default component$(() => { + const expanded = useSignal(false); + + return ( + <> + + + expanded.value} + then$={() =>

Details are visible.

} + /> + + ); +}); +``` + +## Reactivity + +`Show` tracks the values read by `when$`. Put the reactive reads that should switch the branch +inside `when$`: + +```tsx /Show/ /user.value/ + user.value?.name ?? ''} + then$={(name) =>

Hello {name}

} + else$={() =>

Please sign in.

} +/> +``` + +Only the selected branch is resolved and invoked. When `when$` is truthy, `then$` runs and `else$` +does not. When `when$` is falsey, `else$` runs if it exists and `then$` does not. + +## `Show` vs JSX conditionals + +For simple conditional markup, ordinary JSX is still a good fit: + +```tsx +{signedIn.value ?

Welcome back.

:

Please sign in.

} +``` + +Use `Show` when you specifically want QRL-based conditional control flow and branch selection +handled by the built-in primitive. + +## Related + +- For general conditional rendering, see [Rendering](/docs/core/rendering/) +- For the generated API entry, see [API Reference](/api/qwik/#show) diff --git a/packages/docs/src/routes/docs/menu.md b/packages/docs/src/routes/docs/menu.md index 720726de622..53aa7d1feea 100644 --- a/packages/docs/src/routes/docs/menu.md +++ b/packages/docs/src/routes/docs/menu.md @@ -163,6 +163,7 @@ - [Overview](/docs/labs/index.mdx) - [Each](/docs/labs/each/index.mdx) +- [Show](/docs/labs/show/index.mdx) - [Insights](/docs/labs/insights/index.mdx) - [Typed Routes](/docs/labs/typed-routes/index.mdx) - [Devtools](/docs/labs/devtools/index.mdx) diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts index 3ba5f7f3e53..d582ed9d198 100644 --- a/packages/docs/vite.config.ts +++ b/packages/docs/vite.config.ts @@ -251,7 +251,7 @@ export default defineConfig(({ mode }) => { }), qwikVite({ debug: false, - experimental: ['each', 'suspense', 'preventNavigate', 'insights'], + experimental: ['each', 'show', 'suspense', 'preventNavigate', 'insights'], devTools: { hmr: false }, }), partytownVite({ diff --git a/packages/qwik-vite/src/manifest.ts b/packages/qwik-vite/src/manifest.ts index ca855c29361..4f11133811f 100644 --- a/packages/qwik-vite/src/manifest.ts +++ b/packages/qwik-vite/src/manifest.ts @@ -14,6 +14,9 @@ const extraSymbols = new Set([ // Each '_eaC', '_eaT', + // Show + '_shC', + '_shT', // Suspense '_suC', '_suT', diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index 8d8a4c04c8c..2c45eee7df9 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -70,6 +70,8 @@ const CLIENT_STRIP_CTX_NAME = [ export enum ExperimentalFeatures { /** Enable the Each keyed-list primitive */ each = 'each', + /** Enable the Show conditional primitive */ + show = 'show', /** Enable the Suspense fallback primitive */ suspense = 'suspense', /** Enable the usePreventNavigate hook */ diff --git a/packages/qwik-vite/src/qwik.optimizer.api.md b/packages/qwik-vite/src/qwik.optimizer.api.md index afefa243f8f..ff3f217fd94 100644 --- a/packages/qwik-vite/src/qwik.optimizer.api.md +++ b/packages/qwik-vite/src/qwik.optimizer.api.md @@ -24,6 +24,7 @@ export enum ExperimentalFeatures { insights = "insights", noSPA = "noSPA", preventNavigate = "preventNavigate", + show = "show", suspense = "suspense", valibot = "valibot" } diff --git a/packages/qwik/handlers.mjs b/packages/qwik/handlers.mjs index 36e0d6dd21c..f3d09995967 100644 --- a/packages/qwik/handlers.mjs +++ b/packages/qwik/handlers.mjs @@ -16,6 +16,9 @@ export { // Each _eaC, _eaT, + // Show + _shC, + _shT, // Suspense _suC, _suT, diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts index 2bd36c7b52e..c6dc239c077 100644 --- a/packages/qwik/public.d.ts +++ b/packages/qwik/public.d.ts @@ -57,6 +57,9 @@ export { Reveal, RevealOrder, RevealProps, + Show, + ShowComponent, + ShowProps, Signal, SkipRender, Slot, diff --git a/packages/qwik/src/core/control-flow/show.ts b/packages/qwik/src/core/control-flow/show.ts new file mode 100644 index 00000000000..9ebea07d3d8 --- /dev/null +++ b/packages/qwik/src/core/control-flow/show.ts @@ -0,0 +1,95 @@ +import { isServer } from '@qwik.dev/core/build'; +import type { PublicProps } from '../shared/component.public'; +import { componentQrl } from '../shared/component.public'; +import { setNodeDiffPayload } from '../shared/cursor/chore-execution'; +import type { DevJSX, JSXOutput } from '../shared/jsx/types/jsx-node'; +import { SkipRender } from '../shared/jsx/utils.public'; +import { isServerPlatform } from '../shared/platform/platform'; +import { inlinedQrl } from '../shared/qrl/qrl'; +import { _captures, type QRLInternal } from '../shared/qrl/qrl-class'; +import type { QRL } from '../shared/qrl/qrl.public'; +import { isQrl } from '../shared/qrl/qrl-utils'; +import { qTest } from '../shared/utils/qdev'; +import { maybeThen } from '../shared/utils/promises'; +import { ChoreBits } from '../shared/vnode/enums/chore-bits.enum'; +import { markVNodeDirty } from '../shared/vnode/vnode-dirty'; +import type { VNode } from '../shared/vnode/vnode'; +import type { SSRContainer } from '../ssr/ssr-types'; +import { tryGetInvokeContext, type InvokeContext } from '../use/use-core'; +import { type TaskCtx, useTaskQrl } from '../use/use-task'; + +/** @public @experimental */ +export interface ShowProps< + WHEN = unknown, + THEN extends JSXOutput = JSXOutput, + ELSE extends JSXOutput = JSXOutput, +> { + when$: QRL<() => WHEN>; + then$: QRL<(when: WHEN) => THEN>; + else$?: QRL<(when: WHEN) => ELSE>; +} + +/** @public @experimental */ +export type ShowComponent = < + WHEN = unknown, + THEN extends JSXOutput = JSXOutput, + ELSE extends JSXOutput = JSXOutput, +>( + props: PublicProps>, + key: string | null, + flags: number, + dev?: DevJSX +) => JSXOutput; + +const invokeShowFn = ( + fn: QRL<(arg: A) => T> | ((arg: A) => T), + context: InvokeContext | undefined, + arg: A +) => { + return isQrl(fn) ? (fn as QRLInternal<(arg: A) => T>).getFn(context)(arg) : fn(arg); +}; + +/** @internal */ +export const showCmpTask = ({ track }: TaskCtx) => { + const props = _captures![0] as ShowProps; + const context = tryGetInvokeContext()!; + const host = context.$hostElement$!; + const container = context.$container$!; + const isSsr = qTest ? isServerPlatform() : isServer; + + return maybeThen( + track(() => invokeShowFn(props.when$, tryGetInvokeContext(), undefined as any)), + (condition) => { + const branch = condition ? props.then$ : props.else$; + return maybeThen(branch ? invokeShowFn(branch, context, condition) : null, (output) => { + const jsx = branch ? [output] : []; + if (isSsr) { + const ssr = container as SSRContainer; + return ssr.renderJSX(jsx, { + currentStyleScoped: null, + parentComponentFrame: ssr.getComponentFrame(0), + }); + } else { + setNodeDiffPayload(host as VNode, jsx); + markVNodeDirty(container, host, ChoreBits.NODE_DIFF); + } + }); + } + ); +}; + +/** @internal */ +export const showCmp = (props: ShowProps) => { + if (!__EXPERIMENTAL__.show) { + throw new Error( + 'Show is experimental and must be enabled with `experimental: ["show"]` in the `qwikVite` plugin.' + ); + } + useTaskQrl(/*#__PURE__*/ inlinedQrl(showCmpTask, '_shT', [props])); + return SkipRender; +}; + +/** @public @experimental */ +export const Show = /*#__PURE__*/ componentQrl>( + /*#__PURE__*/ inlinedQrl(showCmp, '_shC') +) as ShowComponent; diff --git a/packages/qwik/src/core/control-flow/show.unit.tsx b/packages/qwik/src/core/control-flow/show.unit.tsx new file mode 100644 index 00000000000..c6c9cdd1c9e --- /dev/null +++ b/packages/qwik/src/core/control-flow/show.unit.tsx @@ -0,0 +1,64 @@ +import type { JSXOutput, QRL } from '@qwik.dev/core'; +import { describe, expectTypeOf, test } from 'vitest'; +import { Show } from './show'; + +describe('Show types', () => { + test('accepts plain when$ and branch functions', () => () => { + const inferBranch = ( + props: Parameters>[0] + ) => { + expectTypeOf(props.when$).toMatchTypeOf WHEN> | (() => WHEN)>(); + return null as unknown as THEN | ELSE; + }; + + const branch = inferBranch({ + when$: () => true, + then$: () => Then, + else$: () => Else, + }); + + expectTypeOf(branch).toMatchTypeOf(); + }); + + test('allows omitted else$', () => () => { + const props: Parameters>[0] = { + when$: () => false, + then$: () => Then, + }; + + expectTypeOf(props.else$).toEqualTypeOf< + undefined | QRL<(when: boolean) => JSXOutput> | ((when: boolean) => JSXOutput) + >(); + }); + + test('accepts a QRL when$ value', () => () => { + const when = null as unknown as QRL<() => boolean>; + const props: Parameters>[0] = { + when$: when, + then$: () => Then, + }; + + expectTypeOf(props.when$).toEqualTypeOf boolean> | (() => boolean)>(); + }); + + test('then$ receives the when$ return value', () => () => { + void ({ + when$: () => 'hello', + then$: (when: string) => { + expectTypeOf(when).toEqualTypeOf(); + return {when}; + }, + } satisfies Parameters>[0]); + }); + + test('else$ receives the when$ return value', () => () => { + void ({ + when$: () => 'hello', + then$: (when: string) => {when}, + else$: (when: string) => { + expectTypeOf(when).toEqualTypeOf(); + return {when}; + }, + } satisfies Parameters>[0]); + }); +}); diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index d05d435b0a1..a6b6e0269e6 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -183,6 +183,9 @@ export type { ComputedOptions } from './reactive-primitives/types'; ////////////////////////////////////////////////////////////////////////////////////////// export { eachCmpTask as _eaT, eachCmp as _eaC } from './control-flow/each'; export { Each } from './control-flow/each'; +export { showCmpTask as _shT, showCmp as _shC } from './control-flow/show'; +export { Show } from './control-flow/show'; +export type { ShowComponent, ShowProps } from './control-flow/show'; export { revealCanReveal as _reR, revealCleanupTask as _reT, diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 38d4684ac65..a044ba7241a 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -1128,6 +1128,28 @@ export abstract class _SharedContainer implements _Container { trackSignalValue(signal: Signal, subscriber: HostElement, property: string, data: _SubscriptionData): T; } +// @internal (undocumented) +export const _shC: (props: ShowProps) => JSXNode; + +// @public (undocumented) +export const Show: ShowComponent; + +// @public (undocumented) +export type ShowComponent = (props: PublicProps>, key: string | null, flags: number, dev?: DevJSX) => JSXOutput; + +// @public (undocumented) +export interface ShowProps { + // (undocumented) + else$?: QRL<(when: WHEN) => ELSE>; + // (undocumented) + then$: QRL<(when: WHEN) => THEN>; + // (undocumented) + when$: QRL<() => WHEN>; +} + +// @internal (undocumented) +export const _shT: (input: TaskCtx) => ValueOrPromise; + // @public export interface Signal { trigger(): void; diff --git a/packages/qwik/src/core/tests/show.spec.tsx b/packages/qwik/src/core/tests/show.spec.tsx new file mode 100644 index 00000000000..8538f992e99 --- /dev/null +++ b/packages/qwik/src/core/tests/show.spec.tsx @@ -0,0 +1,202 @@ +import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; +import { describe, expect, it } from 'vitest'; +import { component$, Fragment, Show, useSignal } from '@qwik.dev/core'; + +const debug = false; //true; +Error.stackTraceLimit = 100; + +describe.each([ + { render: ssrRenderToDom }, // + { render: domRender }, // +])('$render.name: Show', ({ render }) => { + it('should render the true branch', async () => { + const Cmp = component$(() => ( +
+ true} then$={() => Then} else$={() => Else} /> +
+ )); + + const { document } = await render(, { debug }); + + await expect(document.getElementById('show')).toMatchDOM( +
+ Then +
+ ); + }); + + it('should render the false branch', async () => { + const Cmp = component$(() => ( +
+ false} then$={() => Then} else$={() => Else} /> +
+ )); + + const { document } = await render(, { debug }); + + await expect(document.getElementById('show')).toMatchDOM( +
+ Else +
+ ); + }); + + it('should render empty content without else$', async () => { + const Cmp = component$(() => ( +
+ false} then$={() => Then} /> +
+ )); + + const { document } = await render(, { debug }); + + await expect(document.getElementById('show')).toMatchDOM(
); + }); + + it('should update when the condition changes', async () => { + const Cmp = component$(() => { + const visible = useSignal(true); + return ( + +
+ visible.value} + then$={() => Then} + else$={() => Else} + /> +
+ +
+ ); + }); + + const { document } = await render(, { debug }); + + await expect(document.getElementById('show')).toMatchDOM( +
+ Then +
+ ); + + await trigger(document.body, 'button', 'click'); + await expect(document.getElementById('show')).toMatchDOM( +
+ Else +
+ ); + + await trigger(document.body, 'button', 'click'); + await expect(document.getElementById('show')).toMatchDOM( +
+ Then +
+ ); + }); + + it('should only invoke the selected branch', async () => { + (globalThis as any).showBranchCalls = []; + const Cmp = component$(() => { + const visible = useSignal(false); + return ( + +
+ visible.value} + then$={() => { + (globalThis as any).showBranchCalls.push('then'); + return Then; + }} + else$={() => { + (globalThis as any).showBranchCalls.push('else'); + return Else; + }} + /> +
+ +
+ ); + }); + + const { document } = await render(, { debug }); + + expect((globalThis as any).showBranchCalls).toEqual(['else']); + await expect(document.getElementById('show')).toMatchDOM( +
+ Else +
+ ); + + await trigger(document.body, 'button', 'click'); + expect((globalThis as any).showBranchCalls).toEqual(['else', 'then']); + await expect(document.getElementById('show')).toMatchDOM( +
+ Then +
+ ); + }); + + it('should pass the when$ value to then$', async () => { + const Cmp = component$(() => ( +
+ 'hello'} then$={(v) => {v}} /> +
+ )); + + const { document } = await render(, { debug }); + + await expect(document.getElementById('show')).toMatchDOM( +
+ hello +
+ ); + }); + + it('should pass the when$ value to else$', async () => { + const Cmp = component$(() => ( +
+ null as string | null} + then$={() => Then} + else$={(v) => {String(v)}} + /> +
+ )); + + const { document } = await render(, { debug }); + + await expect(document.getElementById('show')).toMatchDOM( +
+ null +
+ ); + }); + + it('should pass the updated when$ value to then$ after signal change', async () => { + const Cmp = component$(() => { + const item = useSignal('first'); + return ( + +
+ item.value} then$={(v) => {v}} /> +
+ +
+ ); + }); + + const { document } = await render(, { debug }); + + await expect(document.getElementById('show')).toMatchDOM( +
+ first +
+ ); + + await trigger(document.body, 'button', 'click'); + await expect(document.getElementById('show')).toMatchDOM( +
+ second +
+ ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index bbb69869e57..487b21b236d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ debug: !true, srcDir: `./packages/qwik/src`, devTools: { hmr: false }, - experimental: ['each', 'suspense'], + experimental: ['each', 'show', 'suspense'], }), tsconfigPaths({ ignoreConfigErrors: true }), ],