Skip to content

Commit

Permalink
feat(qwik): Experimental support for synchronous QRL sync$(). (Qwik…
Browse files Browse the repository at this point in the history
…Dev#5545)

* feat(qwik): Experimental support for synchronous QRL `sync$()`.

It is often desirable to have call API on events synchronously. For example, `event.preventDefault()` and `event.stopPropagation()` must be called synchronously in order to have a helpful effect.

```TypeScript
  <button onClick$={[
    $sync(event => event.preventDefault()),
    $(() => {
      // normal behavior.
    })
  ]}>Click Me</button>
```

The idea is that `$sync()` will be inlined into HTML. For this reason, the function, `$sync()`, must not only be a pure function but also can not depend on any external code, such as imports.

The best way to think about it is that the function inside `$sync()` must be able to survive `fn.toString()` and then be reconstructed from the string into a proper function.

In practice, this means that the function must be a simple function which can't:
- Close over any state.
- Close over any imports.

Fix QwikDev#5322
Fix QwikDev#4496

* fix(qrl.ts): dedupe sync$ (QwikDev#5566)

* fix(qrl.ts): dedupe sync$

* refactor(core): sync$ inlineFns key

* feat(qwik): Experimental support for synchronous QRL `sync$()`.

It is often desirable to have call API on events synchronously. For example, `event.preventDefault()` and `event.stopPropagation()` must be called synchronously in order to have a helpful effect.

```TypeScript
  <button onClick$={[
    $sync(event => event.preventDefault()),
    $(() => {
      // normal behavior.
    })
  ]}>Click Me</button>
```

The idea is that `$sync()` will be inlined into HTML. For this reason, the function, `$sync()`, must not only be a pure function but also can not depend on any external code, such as imports.

The best way to think about it is that the function inside `$sync()` must be able to survive `fn.toString()` and then be reconstructed from the string into a proper function.

In practice, this means that the function must be a simple function which can't:
- Close over any state.
- Close over any imports.

Fix QwikDev#5322
Fix QwikDev#4496

---------

Co-authored-by: PatrickJS <github@patrickjs.com>
  • Loading branch information
mhevery and PatrickJS authored Dec 15, 2023
1 parent ec65ba7 commit d50ceaa
Show file tree
Hide file tree
Showing 59 changed files with 1,036 additions and 220 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
etc
temp
.history
.lh
*.
**/*.log
*.node
Expand Down
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"internalConsoleOptions": "neverOpen",
"program": "${workspaceFolder}/./node_modules/vitest/vitest.mjs",
"cwd": "${workspaceFolder}",
"args": ["--threads=false", "packages/qwik/src/core/container/render.unit.tsx"]
"args": ["--threads=false", "${file}"]
}
]
}
6 changes: 3 additions & 3 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
"@algolia/autocomplete-core": "1.7.4",
"@algolia/client-search": "4.14.3",
"@builder.io/partytown": "^0.8.1",
"@builder.io/qwik": "github:BuilderIo/qwik-build#cf7395265d5bbe81207e4e7130b70351077d3d88",
"@builder.io/qwik-city": "github:BuilderIo/qwik-city-build#25737029f53230ed83a65f3929776f2bd1fe0118",
"@builder.io/qwik-labs": "github:BuilderIo/qwik-labs-build#1ff13169b1b323c844fc68bffad0500df3ba65e5",
"@builder.io/qwik": "github:BuilderIo/qwik-build#ffd3473f5703d9da68ba57bf6f13a5b80ea4aaaf",
"@builder.io/qwik-city": "github:BuilderIo/qwik-city-build#a01f2f73eb8c4e06d17931288a3fc2d2b664c33c",
"@builder.io/qwik-labs": "github:BuilderIo/qwik-labs-build#6c19732d6aae5cef76f8711e4c98d950eba52b37",
"@builder.io/qwik-react": "0.5.0",
"@builder.io/sdk-qwik": "^0.6.2",
"@docsearch/css": "^3.5.2",
Expand Down
19 changes: 18 additions & 1 deletion packages/docs/src/routes/api/qwik-city/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@
"id": "qwik-city",
"package": "@builder.io/qwik-city",
"members": [
{
"name": "\"link:app\"",
"id": "linkprops-_link_app_",
"hierarchy": [
{
"name": "LinkProps",
"id": "linkprops-_link_app_"
},
{
"name": "\"link:app\"",
"id": "linkprops-_link_app_"
}
],
"kind": "PropertySignature",
"content": "```typescript\n'link:app'?: boolean;\n```",
"mdFile": "qwik-city.linkprops._link_app_.md"
},
{
"name": "Action",
"id": "action",
Expand Down Expand Up @@ -362,7 +379,7 @@
}
],
"kind": "Interface",
"content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\n\n\n| Property | Modifiers | Type | Description |\n| --- | --- | --- | --- |\n| [prefetch?](#) | | boolean | _(Optional)_ |\n| [reload?](#) | | boolean | _(Optional)_ |\n| [replaceState?](#) | | boolean | _(Optional)_ |\n| [scroll?](#) | | boolean | _(Optional)_ |",
"content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\n\n\n| Property | Modifiers | Type | Description |\n| --- | --- | --- | --- |\n| [\"link:app\"?](#linkprops-_link_app_) | | boolean | _(Optional)_ |\n| [prefetch?](#) | | boolean | _(Optional)_ |\n| [reload?](#) | | boolean | _(Optional)_ |\n| [replaceState?](#) | | boolean | _(Optional)_ |\n| [scroll?](#) | | boolean | _(Optional)_ |",
"editUrl": "https://github.com/BuilderIO/qwik/tree/main/packages/qwik-city/runtime/src/link-component.tsx",
"mdFile": "qwik-city.linkprops.md"
},
Expand Down
19 changes: 13 additions & 6 deletions packages/docs/src/routes/api/qwik-city/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ title: \@builder.io/qwik-city API Reference

# [API](/api) &rsaquo; @builder.io/qwik-city

## "link:app"

```typescript
'link:app'?: boolean;
```

## Action

```typescript
Expand Down Expand Up @@ -469,12 +475,13 @@ export interface LinkProps extends AnchorAttributes
**Extends:** AnchorAttributes
| Property | Modifiers | Type | Description |
| ------------------ | --------- | ------- | ------------ |
| [prefetch?](#) | | boolean | _(Optional)_ |
| [reload?](#) | | boolean | _(Optional)_ |
| [replaceState?](#) | | boolean | _(Optional)_ |
| [scroll?](#) | | boolean | _(Optional)_ |
| Property | Modifiers | Type | Description |
| ------------------------------------ | --------- | ------- | ------------ |
| ["link:app"?](#linkprops-_link_app_) | | boolean | _(Optional)_ |
| [prefetch?](#) | | boolean | _(Optional)_ |
| [reload?](#) | | boolean | _(Optional)_ |
| [replaceState?](#) | | boolean | _(Optional)_ |
| [scroll?](#) | | boolean | _(Optional)_ |
[Edit this section](https://github.com/BuilderIO/qwik/tree/main/packages/qwik-city/runtime/src/link-component.tsx)
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/routes/api/qwik-testing/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
}
],
"kind": "Variable",
"content": "CreatePlatform and CreateDocument\n\n\n```typescript\ncreateDOM: () => Promise<{\n render: (jsxElement: JSXNode) => Promise<import(\"@builder.io/qwik\").RenderResult>;\n screen: HTMLElement;\n userEvent: (queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventNameCamel: string | keyof WindowEventMap, eventPayload?: any) => Promise<void>;\n}>\n```",
"content": "CreatePlatform and CreateDocument\n\n\n```typescript\ncreateDOM: ({ html }?: {\n html?: string | undefined;\n}) => Promise<{\n render: (jsxElement: JSXNode) => Promise<import(\"@builder.io/qwik\").RenderResult>;\n screen: HTMLElement;\n userEvent: (queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventNameCamel: string | keyof WindowEventMap, eventPayload?: any) => Promise<void>;\n}>\n```",
"editUrl": "https://github.com/BuilderIO/qwik/tree/main/packages/qwik/src/testing/library.ts",
"mdFile": "qwik.createdom.md"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/routes/api/qwik-testing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: \@builder.io/qwik/testing API Reference
CreatePlatform and CreateDocument

```typescript
createDOM: () =>
createDOM: ({ html }?: { html?: string | undefined }) =>
Promise<{
render: (
jsxElement: JSXNode,
Expand Down
28 changes: 28 additions & 0 deletions packages/docs/src/routes/api/qwik/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
"id": "qwik",
"package": "@builder.io/qwik",
"members": [
{
"name": "_qrlSync",
"id": "_qrlsync",
"hierarchy": [
{
"name": "_qrlSync",
"id": "_qrlsync"
}
],
"kind": "Variable",
"content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nExtract function into a synchronously loadable QRL.\n\nNOTE: Synchronous QRLs functions can't close over any variables, including exports.\n\n\n```typescript\n_qrlSync: <TYPE extends Function>(fn: TYPE, serializedFn?: string) => SyncQRL<TYPE>\n```",
"editUrl": "https://github.com/BuilderIO/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.ts",
"mdFile": "qwik._qrlsync.md"
},
{
"name": "\"q:slot\"",
"id": "componentbaseprops-_q_slot_",
Expand Down Expand Up @@ -2696,6 +2710,20 @@
"editUrl": "https://github.com/BuilderIO/qwik/tree/main/packages/qwik/src/core/render/jsx/types/jsx-generated.ts",
"mdFile": "qwik.svgprops.md"
},
{
"name": "sync$",
"id": "sync_",
"hierarchy": [
{
"name": "sync$",
"id": "sync_"
}
],
"kind": "Variable",
"content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nExtract function into a synchronously loadable QRL.\n\nNOTE: Synchronous QRLs functions can't close over any variables, including exports.\n\n\n```typescript\nsync$: <T extends Function>(fn: T) => SyncQRL<T>\n```",
"editUrl": "https://github.com/BuilderIO/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.ts",
"mdFile": "qwik.sync_.md"
},
{
"name": "TableHTMLAttributes",
"id": "tablehtmlattributes",
Expand Down
29 changes: 29 additions & 0 deletions packages/docs/src/routes/api/qwik/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ title: \@builder.io/qwik API Reference

# [API](/api) &rsaquo; @builder.io/qwik

## \_qrlSync

> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.
Extract function into a synchronously loadable QRL.

NOTE: Synchronous QRLs functions can't close over any variables, including exports.

```typescript
_qrlSync: <TYPE extends Function>(fn: TYPE, serializedFn?: string) =>
SyncQRL<TYPE>;
```

[Edit this section](https://github.com/BuilderIO/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.ts)

## "q:slot"

```typescript
Expand Down Expand Up @@ -2907,6 +2922,20 @@ export interface SVGProps<T extends Element> extends SVGAttributes, QwikAttribut
[Edit this section](https://github.com/BuilderIO/qwik/tree/main/packages/qwik/src/core/render/jsx/types/jsx-generated.ts)
## sync$
> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.
Extract function into a synchronously loadable QRL.
NOTE: Synchronous QRLs functions can't close over any variables, including exports.
```typescript
sync$: <T extends Function>(fn: T) => SyncQRL<T>;
```
[Edit this section](https://github.com/BuilderIO/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.ts)
## TableHTMLAttributes
```typescript
Expand Down
38 changes: 38 additions & 0 deletions packages/docs/src/routes/demo/cookbook/sync-event/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { component$, useSignal, sync$, $ } from '@builder.io/qwik';

export default component$(() => {
const shouldPreventDefault = useSignal(true);
return (
<div>
<div>Sync Event:</div>
<input
type="checkbox"
checked={shouldPreventDefault.value}
onChange$={(e, target) =>
(shouldPreventDefault.value = target.checked)
}
/>{' '}
Should Prevent Default
<hr />
<a
href="https://google.com"
target="_blank"
data-should-prevent-default={shouldPreventDefault.value}
onClick$={[
sync$((e: MouseEvent, target: HTMLAnchorElement) => {
if (target.hasAttribute('data-should-prevent-default')) {
e.preventDefault();
}
}),
$(() => {
console.log(
shouldPreventDefault.value ? 'Prevented' : 'Not Prevented'
);
}),
]}
>
open Google
</a>
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ import { component$, Slot, useStore } from '@builder.io/qwik';
export default component$(() => {
return (
<Button onTripleClick$={() => alert('TRIPLE CLICKED!')}>
Triple click me!
Triple Click me!
</Button>
);
});
Expand Down Expand Up @@ -289,7 +289,6 @@ export const Button = component$<ButtonProps>(({ onTripleClick$ }) => {
</button>
);
});

```
</CodeSandbox>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ export default component$(() => {
Add to list
</button>
<ul>
{store.list.map((item, index) => (
<li key={`items-${index}`}>{item}</li>
{store.list.map((item, key) => (
<li key={key}>{item}</li>
))}
</ul>
</>
Expand Down Expand Up @@ -404,9 +404,7 @@ export default component$(() => {
<div>
<label>
Enter your name, and I'll guess your age!
<input
onInput$={(ev, el) => (name.value = el.value)}
/>
<input onInput$={(ev, el) => (name.value = el.value)} />
</label>
</div>
<Resource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export default component$(() => {
id="playsInlineCheckbox"
class="checkbox"
checked={playsInlineSignal.value}
onChange$={() => {
onchange$={() => {
videoElementSignal.value?.pause();
playsInlineSignal.value = !playsInlineSignal.value;
}}
Expand Down
11 changes: 7 additions & 4 deletions packages/docs/src/routes/docs/cookbook/portal/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ interface Portal {
name: string;
jsx: JSXNode;
close: QRL<() => void>;
contexts: Array<ContextPair<unknown>>;
contexts: Array<ContextPair<any>>;
}

export const PortalProvider = component$(() => {
Expand Down Expand Up @@ -245,8 +245,8 @@ export const Portal = component$<{ name: string }>(({ name }) => {
const myPortals = portals.value.filter((portal) => portal.name === name);
return (
<>
{myPortals.map((portal) => (
<div data-portal={name}>
{myPortals.map((portal, key) => (
<div key={key} data-portal={name}>
<WrapJsxInContext jsx={portal.jsx} contexts={portal.contexts} />
</div>
))}
Expand All @@ -258,7 +258,10 @@ export const WrapJsxInContext = component$<{
jsx: JSXNode;
contexts: Array<ContextPair<any>>;
}>(({ jsx, contexts }) => {
contexts.forEach(({ id, value }) => useContextProvider(id, value));
contexts.forEach(({ id, value }) => {
// eslint-disable-next-line
useContextProvider(id, value);
});
return (
<>
{/* Workaround: https://github.com/BuilderIO/qwik/issues/4966 */}
Expand Down
76 changes: 76 additions & 0 deletions packages/docs/src/routes/docs/cookbook/sync-events/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
title: Cookbook | Synchronous Events with State
contributors:
- mhevery
---

import CodeSandbox, {CodeFile} from '../../../../components/code-sandbox/index.tsx';


# `sync$()` Synchronous Events (BETA)

Qwik processes events asynchronously. This means that some APIs such as `event.preventDefault()` and `event.stopPropagation()` do not work as expected. To work around this limitation, Qwik provides a `sync$()` API which allows you to process events synchronously. But `sync$()` comes with a few caveats:
1. `sync$()` can't close over any state.
2. `sync$()` can't call other functions which are declared in scope or imported.
3. `sync$()` is serialized into HTML and therefore we should be conscious of the size of the function.

A typical way to deal with these limitations is to split the event handling into two parts:
1. `sync$()` which is called synchronously and can call methods such as `event.preventDefault()` and `event.stopPropagation()`.
2. `$()` which is called asynchronously and can close over the state and call other functions, and has no restriction on the size.

Because `sync$()` can't access the state what is the best strategy to deal with it? The answer is to use element attributes to pass state into the `sync$()` function.

## Example: `sync$()` with state

In this example, we have a behavior where we want to prevent the default behavior of the link based on some state. We do this by breaking the code into three parts:
1. `sync$()`: a synchronous portion that is kept to the minimum,
2. `$()`: an asynchronous portion that can be arbitrarily large, and can close over state,
3. `data-should-prevent-default`: an attribute on the element that is used to pass state into the `sync$()` function.


<CodeSandbox url="/demo/cookbook/sync-event/" style={{ height: '15em' }}>
</CodeSandbox>


<CodeFile src="/src/routes/demo/cookbook/sync-event/index.tsx">
```tsx
import { component$, useSignal, sync$, $ } from '@builder.io/qwik';

export default component$(() => {
const shouldPreventDefault = useSignal(true);
return (
<div>
<div>Sync Event:</div>
<input
type="checkbox"
checked={shouldPreventDefault.value}
onChange$={(e, target) =>
(shouldPreventDefault.value = target.checked)
}
/>{' '}
Should Prevent Default
<hr />
<a
href="https://google.com"
target="_blank"
data-should-prevent-default={shouldPreventDefault.value}
onClick$={[
sync$((e: MouseEvent, target: HTMLAnchorElement) => {
if (target.hasAttribute('data-should-prevent-default')) {
e.preventDefault();
}
}),
$(() => {
console.log(
shouldPreventDefault.value ? 'Prevented' : 'Not Prevented'
);
}),
]}
>
open Google
</a>
</div>
);
});
```
</CodeFile>
Loading

0 comments on commit d50ceaa

Please sign in to comment.