diff --git a/.changeset/tricky-peaches-buy.md b/.changeset/tricky-peaches-buy.md new file mode 100644 index 00000000000..547912ba4d4 --- /dev/null +++ b/.changeset/tricky-peaches-buy.md @@ -0,0 +1,9 @@ +--- +'@qwik.dev/core': minor +--- + +FEAT: `useSerializer$`, `createSerializer$`: Create a Signal holding a custom serializable value. See {@link useSerializer$} for more details. + +FEAT: `NoSerializeSymbol`: objects that have this symbol will not be serialized. + +FEAT: `SerializerSymbol`: When defined on an object, this function will get called with the object and is expected to returned a serializable object literal representing this object. Use this to remove data cached data, consolidate things, integrate with other libraries, etc. diff --git a/packages/docs/src/repl/worker/app-bundle-client.ts b/packages/docs/src/repl/worker/app-bundle-client.ts index def68d27088..9c4c14a9f2a 100644 --- a/packages/docs/src/repl/worker/app-bundle-client.ts +++ b/packages/docs/src/repl/worker/app-bundle-client.ts @@ -55,7 +55,7 @@ export const appBundleClient = async ( const loc = warning.loc; if (loc && loc.file) { diagnostic.file = loc.file; - diagnostic.highlights.push({ + diagnostic.highlights!.push({ startCol: loc.column, endCol: loc.column + 1, startLine: loc.line, diff --git a/packages/docs/src/repl/worker/app-bundle-ssr.ts b/packages/docs/src/repl/worker/app-bundle-ssr.ts index 33d08b9f6ae..73940560b1d 100644 --- a/packages/docs/src/repl/worker/app-bundle-ssr.ts +++ b/packages/docs/src/repl/worker/app-bundle-ssr.ts @@ -45,7 +45,7 @@ export const appBundleSsr = async (options: ReplInputOptions, result: ReplResult const loc = warning.loc; if (loc && loc.file) { diagnostic.file = loc.file; - diagnostic.highlights.push({ + diagnostic.highlights!.push({ startCol: loc.column, endCol: loc.column + 1, startLine: loc.line, diff --git a/packages/docs/src/routes/api/qwik-optimizer/api.json b/packages/docs/src/routes/api/qwik-optimizer/api.json index 74e41971e41..1e64d0efcb2 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/api.json +++ b/packages/docs/src/routes/api/qwik-optimizer/api.json @@ -57,7 +57,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface Diagnostic \n```\n\n\n\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\n[category](#)\n\n\n\n\n\n\n\n[DiagnosticCategory](#diagnosticcategory)\n\n\n\n\n\n
\n\n[code](#)\n\n\n\n\n\n\n\nstring \\| null\n\n\n\n\n\n
\n\n[file](#)\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\n[highlights](#)\n\n\n\n\n\n\n\n[SourceLocation](#sourcelocation)\\[\\]\n\n\n\n\n\n
\n\n[message](#)\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\n[scope](#)\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\n[suggestions](#)\n\n\n\n\n\n\n\nstring\\[\\] \\| null\n\n\n\n\n\n
", + "content": "```typescript\nexport interface Diagnostic \n```\n\n\n\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\n[category](#)\n\n\n\n\n\n\n\n[DiagnosticCategory](#diagnosticcategory)\n\n\n\n\n\n
\n\n[code](#)\n\n\n\n\n\n\n\nstring \\| null\n\n\n\n\n\n
\n\n[file](#)\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\n[highlights](#)\n\n\n\n\n\n\n\n[SourceLocation](#sourcelocation)\\[\\] \\| null\n\n\n\n\n\n
\n\n[message](#)\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\n[scope](#)\n\n\n\n\n\n\n\nstring\n\n\n\n\n\n
\n\n[suggestions](#)\n\n\n\n\n\n\n\nstring\\[\\] \\| null\n\n\n\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/types.ts", "mdFile": "core.diagnostic.md" }, diff --git a/packages/docs/src/routes/api/qwik-optimizer/index.md b/packages/docs/src/routes/api/qwik-optimizer/index.md index 86eddf3c448..9c415435830 100644 --- a/packages/docs/src/routes/api/qwik-optimizer/index.md +++ b/packages/docs/src/routes/api/qwik-optimizer/index.md @@ -216,7 +216,7 @@ string -[SourceLocation](#sourcelocation)[] +[SourceLocation](#sourcelocation)[] \| null diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index e584eef0f7e..ed4d5b195d3 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -390,7 +390,7 @@ } ], "kind": "Function", - "content": "Create a computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated.\n\nThe QRL must be a function which returns the value of the signal. The function must not have side effects, and it mus be synchronous.\n\nIf you need the function to be async, use `useSignal` and `useTask$` instead.\n\n\n```typescript\ncreateComputed$: (qrl: () => T) => T extends Promise ? never : ComputedSignal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n() => T\n\n\n\n\n\n
\n**Returns:**\n\nT extends Promise<any> ? never : [ComputedSignal](#computedsignal)<T>", + "content": "Create a computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated.\n\nThe QRL must be a function which returns the value of the signal. The function must not have side effects, and it must be synchronous.\n\nIf you need the function to be async, use `useSignal` and `useTask$` instead.\n\n\n```typescript\ncreateComputed$: (qrl: () => T) => T extends Promise ? never : ComputedSignal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n() => T\n\n\n\n\n\n
\n**Returns:**\n\nT extends Promise<any> ? never : [ComputedSignal](#computedsignal)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/signal/signal.public.ts", "mdFile": "core.createcomputed_.md" }, @@ -408,6 +408,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-context.ts", "mdFile": "core.createcontextid.md" }, + { + "name": "createSerializer$", + "id": "createserializer_", + "hierarchy": [ + { + "name": "createSerializer$", + "id": "createserializer_" + } + ], + "kind": "Function", + "content": "Create a signal that holds a custom serializable value. See [useSerializer$](#useserializer_) for more details.\n\n\n```typescript\ncreateSerializer$: (arg: SerializerArg) => T extends Promise ? never : SerializerSignal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\narg\n\n\n\n\nSerializerArg<T, S>\n\n\n\n\n\n
\n**Returns:**\n\nT extends Promise<any> ? never : SerializerSignal<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/signal/signal.public.ts", + "mdFile": "core.createserializer_.md" + }, { "name": "createSignal", "id": "createsignal", @@ -716,7 +730,7 @@ } ], "kind": "Function", - "content": "```typescript\nisSignal: (value: any) => value is ISignal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nvalue\n\n\n\n\nany\n\n\n\n\n\n
\n**Returns:**\n\nvalue is [ISignal](#signal)<unknown>", + "content": "```typescript\nisSignal: (value: any) => value is Signal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nvalue\n\n\n\n\nany\n\n\n\n\n\n
\n**Returns:**\n\nvalue is [Signal](#signal)<unknown>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/signal/signal.ts", "mdFile": "core.issignal.md" }, @@ -1014,6 +1028,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/utils/serialize-utils.ts", "mdFile": "core.noserialize.md" }, + { + "name": "NoSerializeSymbol", + "id": "noserializesymbol", + "hierarchy": [ + { + "name": "NoSerializeSymbol", + "id": "noserializesymbol" + } + ], + "kind": "Variable", + "content": "If an object has this property, it will not be serialized. Use this on prototypes to avoid having to call `noSerialize()` on every object.\n\n\n```typescript\nNoSerializeSymbol: unique symbol\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/utils/serialize-utils.ts", + "mdFile": "core.noserializesymbol.md" + }, { "name": "OnRenderFn", "id": "onrenderfn", @@ -1714,6 +1742,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-resource.ts", "mdFile": "core.resourcereturn.md" }, + { + "name": "SerializerSymbol", + "id": "serializersymbol", + "hierarchy": [ + { + "name": "SerializerSymbol", + "id": "serializersymbol" + } + ], + "kind": "Variable", + "content": "If an object has this property as a function, it will be called with the object and should return a serializable value.\n\nThis can be used to clean up, integrate with other libraries, etc.\n\nThe type your object should conform to is:\n\n```ts\n{\n [SerializerSymbol]: (this: YourType, toSerialize: YourType) => YourSerializableType;\n}\n```\n\n\n```typescript\nSerializerSymbol: unique symbol\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/utils/serialize-utils.ts", + "mdFile": "core.serializersymbol.md" + }, { "name": "setPlatform", "id": "setplatform", @@ -2060,8 +2102,8 @@ } ], "kind": "Function", - "content": "Creates a computed signal which is calculated from the given function. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated, and if the result changed, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered.\n\nThe function must be synchronous and must not have any side effects.\n\n\n```typescript\nuseComputed$: (qrl: import(\"./use-computed\").ComputedFn) => T extends Promise ? never : import(\"..\").ReadonlySignal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\nimport(\"./use-computed\").[ComputedFn](#computedfn)<T>\n\n\n\n\n\n
\n**Returns:**\n\nT extends Promise<any> ? never : import(\"..\").[ReadonlySignal](#readonlysignal)<T>", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed-dollar.ts", + "content": "Creates a computed signal which is calculated from the given function. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated, and if the result changed, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered.\n\nThe function must be synchronous and must not have any side effects.\n\n\n```typescript\nuseComputed$: (qrl: ComputedFn) => T extends Promise ? never : ReadonlySignal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n[ComputedFn](#computedfn)<T>\n\n\n\n\n\n
\n**Returns:**\n\nT extends Promise<any> ? never : [ReadonlySignal](#readonlysignal)<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts", "mdFile": "core.usecomputed_.md" }, { @@ -2190,6 +2232,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-resource-dollar.ts", "mdFile": "core.useresource_.md" }, + { + "name": "useSerializer$", + "id": "useserializer_", + "hierarchy": [ + { + "name": "useSerializer$", + "id": "useserializer_" + } + ], + "kind": "Variable", + "content": "Creates a signal which holds a custom serializable value. It requires that the value implements the `CustomSerializable` type, which means having a function under the `[SerializeSymbol]` property that returns a serializable value when called.\n\nThe `fn` you pass is called with the result of the serialization (in the browser, only when the value is needed), or `undefined` when not yet initialized. If you refer to other signals, `fn` will be called when those change just like computed signals, and then the argument will be the previous output, not the serialized result.\n\nThis is useful when using third party libraries that use custom objects that are not serializable.\n\nNote that the `fn` is called lazily, so it won't impact container resume.\n\n\n```typescript\nuseSerializer$: typeof createSerializer$\n```\n\n\n\n```tsx\nclass MyCustomSerializable {\n constructor(public n: number) {}\n inc() {\n this.n++;\n }\n}\nconst Cmp = component$(() => {\n const custom = useSerializer$({\n deserialize: (data) => new MyCustomSerializable(data),\n serialize: (data) => data.n,\n initial: 2,\n });\n return
custom.value.inc()}>{custom.value.n}
;\n});\n```\n\n\nWhen using a Signal as the data to create the object, you need to pass the configuration as a function, and you can then also provide the `update` function to update the object when the signal changes.\n\nBy returning an object from `update`, you signal that the listeners have to be notified. You can mutate the current object but you should return it so that it will trigger listeners.\n\n```tsx\nconst Cmp = component$(() => {\n const n = useSignal(2);\n const custom = useSerializer$(() =>\n ({\n deserialize: () => new MyCustomSerializable(n.value),\n update: (current) => {\n current.n = n.value;\n return current;\n }\n })\n );\n return
n.value++}>{custom.value.n}
;\n});\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-serializer.ts", + "mdFile": "core.useserializer_.md" + }, { "name": "useServerData", "id": "useserverdata", diff --git a/packages/docs/src/routes/api/qwik/index.md b/packages/docs/src/routes/api/qwik/index.md index 813b834f58c..e2c3ffefd8e 100644 --- a/packages/docs/src/routes/api/qwik/index.md +++ b/packages/docs/src/routes/api/qwik/index.md @@ -669,7 +669,7 @@ Description Create a computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated. -The QRL must be a function which returns the value of the signal. The function must not have side effects, and it mus be synchronous. +The QRL must be a function which returns the value of the signal. The function must not have side effects, and it must be synchronous. If you need the function to be async, use `useSignal` and `useTask$` instead. @@ -789,6 +789,45 @@ The name of the context. [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-context.ts) +## createSerializer$ + +Create a signal that holds a custom serializable value. See [useSerializer$](#useserializer_) for more details. + +```typescript +createSerializer$: (arg: SerializerArg) => T extends Promise ? never : SerializerSignal +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +arg + + + +SerializerArg<T, S> + + + +
+**Returns:** + +T extends Promise<any> ? never : SerializerSignal<T> + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/signal/signal.public.ts) + ## createSignal Creates a Signal with the given value. If no value is given, the signal is created with `undefined`. @@ -1385,7 +1424,7 @@ interface IntrinsicElements extends LenientQwikElements ## isSignal ```typescript -isSignal: (value: any) => value is ISignal +isSignal: (value: any) => value is Signal ```
@@ -1415,7 +1454,7 @@ any
**Returns:** -value is [ISignal](#signal)<unknown> +value is [Signal](#signal)<unknown> [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/signal/signal.ts) @@ -1913,6 +1952,16 @@ export type NoSerialize = [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/utils/serialize-utils.ts) +## NoSerializeSymbol + +If an object has this property, it will not be serialized. Use this on prototypes to avoid having to call `noSerialize()` on every object. + +```typescript +NoSerializeSymbol: unique symbol +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/utils/serialize-utils.ts) + ## OnRenderFn ```typescript @@ -3607,6 +3656,26 @@ export type ResourceReturn = [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-resource.ts) +## SerializerSymbol + +If an object has this property as a function, it will be called with the object and should return a serializable value. + +This can be used to clean up, integrate with other libraries, etc. + +The type your object should conform to is: + +```ts +{ + [SerializerSymbol]: (this: YourType, toSerialize: YourType) => YourSerializableType; +} +``` + +```typescript +SerializerSymbol: unique symbol +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/utils/serialize-utils.ts) + ## setPlatform Sets the `CorePlatform`. @@ -8355,7 +8424,7 @@ Creates a computed signal which is calculated from the given function. A compute The function must be synchronous and must not have any side effects. ```typescript -useComputed$: (qrl: import("./use-computed").ComputedFn) => T extends Promise ? never : import("..").ReadonlySignal +useComputed$: (qrl: ComputedFn) => T extends Promise ? never : ReadonlySignal ```
@@ -8377,7 +8446,7 @@ qrl -import("./use-computed").[ComputedFn](#computedfn)<T> +[ComputedFn](#computedfn)<T> @@ -8385,9 +8454,9 @@ import("./use-computed").[ComputedFn](#computedfn)<T>
**Returns:** -T extends Promise<any> ? never : import("..").[ReadonlySignal](#readonlysignal)<T> +T extends Promise<any> ? never : [ReadonlySignal](#readonlysignal)<T> -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed-dollar.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts) ## useConstant @@ -8844,6 +8913,57 @@ _(Optional)_ [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-resource-dollar.ts) +## useSerializer$ + +Creates a signal which holds a custom serializable value. It requires that the value implements the `CustomSerializable` type, which means having a function under the `[SerializeSymbol]` property that returns a serializable value when called. + +The `fn` you pass is called with the result of the serialization (in the browser, only when the value is needed), or `undefined` when not yet initialized. If you refer to other signals, `fn` will be called when those change just like computed signals, and then the argument will be the previous output, not the serialized result. + +This is useful when using third party libraries that use custom objects that are not serializable. + +Note that the `fn` is called lazily, so it won't impact container resume. + +```typescript +useSerializer$: typeof createSerializer$; +``` + +```tsx +class MyCustomSerializable { + constructor(public n: number) {} + inc() { + this.n++; + } +} +const Cmp = component$(() => { + const custom = useSerializer$({ + deserialize: (data) => new MyCustomSerializable(data), + serialize: (data) => data.n, + initial: 2, + }); + return
custom.value.inc()}>{custom.value.n}
; +}); +``` + +When using a Signal as the data to create the object, you need to pass the configuration as a function, and you can then also provide the `update` function to update the object when the signal changes. + +By returning an object from `update`, you signal that the listeners have to be notified. You can mutate the current object but you should return it so that it will trigger listeners. + +```tsx +const Cmp = component$(() => { + const n = useSignal(2); + const custom = useSerializer$(() => ({ + deserialize: () => new MyCustomSerializable(n.value), + update: (current) => { + current.n = n.value; + return current; + }, + })); + return
n.value++}>{custom.value.n}
; +}); +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-serializer.ts) + ## useServerData ```typescript diff --git a/packages/docs/src/routes/docs/(qwik)/advanced/eslint/index.mdx b/packages/docs/src/routes/docs/(qwik)/advanced/eslint/index.mdx index 3ddee4530e2..c3d80cc0b7a 100644 --- a/packages/docs/src/routes/docs/(qwik)/advanced/eslint/index.mdx +++ b/packages/docs/src/routes/docs/(qwik)/advanced/eslint/index.mdx @@ -5,7 +5,8 @@ contributors: - manucorporat - gioboa - maiieul -updated_at: '2023-11-22T07:02:07Z' + - wmertens +updated_at: '2025-03-18T00:00:00Z' created_at: '2023-05-20T00:04:28Z' --- @@ -15,6 +16,7 @@ created_at: '2023-05-20T00:04:28Z' [//]: <> ( to update run: pnpm eslint.update ) [//]: <> ( after changing the rule metadata on ) [//]: <> ( packages/eslint-plugin-qwik/index.ts ) +[//]: <> ( and update the frontmatter if needed ) [//]: <> (--------------------------------------) import './styles.css'; @@ -323,7 +325,33 @@ import './styles.css'; + 🔔 + + + + + + +
+ serializer-signal-usage + Ensure signals used in update() are also used in deserialize() +
+
+ + ✅ + + @@ -972,7 +1000,7 @@ export const ColorList = component$(() => {
✓ ```tsx {4,12} /serverGreeter/#a -import { $, component$ } from '@qwik.dev/core'; +import { component$ } from '@qwik.dev/core'; import { server$ } from '@qwik.dev/router'; const serverGreeter = server$((firstName: string, lastName: string) => { @@ -982,10 +1010,10 @@ const serverGreeter = server$((firstName: string, lastName: string) => { export default component$(() => ( @@ -1086,5 +1114,61 @@ import Image from '~/media/image.png';
+ +
+
\ No newline at end of file diff --git a/packages/docs/src/routes/docs/(qwik)/components/state/index.mdx b/packages/docs/src/routes/docs/(qwik)/components/state/index.mdx index 2cb1d4bfa4e..70b44b93e75 100644 --- a/packages/docs/src/routes/docs/(qwik)/components/state/index.mdx +++ b/packages/docs/src/routes/docs/(qwik)/components/state/index.mdx @@ -36,7 +36,7 @@ contributors: - Jemsco - shairez - ianlet -updated_at: '2023-10-04T21:48:45Z' +updated_at: '2025-03-17T12:00:00Z' created_at: '2023-03-20T23:45:13Z' --- @@ -550,13 +550,75 @@ export const Child = component$(() => { ``` -## `noSerialize()` +## `useSerializer$()` / `createSerializer$()` + +Sometimes you need to serialize data that Qwik doesn't know how to serialize. For this, you can use the `useSerializer$(cfg)` hook to create a serializer for the data. `createSerializer$(cfg)` does the same thing but is not bound to a specific component and can be called anywhere. + +The `cfg` argument is an object with generic type `S` (serialized data) and `T` (the object type) with the following properties: + +- `deserialize: (data: S) => T`: Creates the object using the initial value or the deserialized value. +- `serialize?: (value: T) => S | Promise`: Optional, serializes the object. If not provided, the object will be serialized as `undefined`. +- `initial?: S`: Optional, the initial value of the serializer. +- `update?: (value: T) => T | void`: Optional, updates the object when the reactive state changes. This can only be passed when `cfg` is a function, see below. + +The result is a `SerializerSignal` object. This is a lazy reactive signal that contains the serialized value of the object. It must be passed around as the signal, otherwise serialization will not work. + +```tsx /useSerializer$/ +import { component$, useSerializer$ } from '@qwik.dev/core'; +import { MyClass } from './my-class'; + +export default component$(() => { + const serializer = useSerializer$({ + deserialize: (data) => new MyClass(data), + serialize: (value) => value.serialize(), + initial: {x: 3, y: 4}, + }); + + return
{JSON.stringify(serializer.value)}
; +}); +``` + +During SSR, `deserialize` will be called with the initial value. You can then use `signal.value` to access the custom object. On the client, the `deserialize` function will be called with the serialized value, but only when the signal is first read. So if you don't need to read the signal, no code will run. + +You can also use reactive state to create a serializer. For this, you need to pass the config as a function that captures the reactive state. Then you can use the `update` function to update the object when the reactive state changes. If the `update` function returns an object, it will be used as the new value for the serializer, and it will trigger all listeners. The trigger happens even when returning the same object. + +```tsx /useSerializer$/ +import { component$, useSerializer$ } from '@qwik.dev/core'; +import { ComplexObject } from './complex-object'; + +export default component$(() => { + const sig = useSignal(123); + const serializer = useSerializer$((cfg) => ({ + deserialize: () => new ComplexObject(sig.value), + update: (obj) => { + if (sig.value < 7) { + // ignore changes below 7 + return; + } + // Tell the object about the change + obj.update(sig.value); + // Return the updated object so the listeners are notified + return obj; + } + })); + + // ... +}); +``` + +This primitive is very powerful, and can be used to create all sorts of custom serializers. Note that the `serialize` function is allowed to return a Promise, so you could for example write the data to a database and return the id. Note that you will need to use `if (isServer)` to guard this operation, so vite won't bundle database code in the client bundle. + +Also note that serialization can also happen on the client, when calling `server$` functions. + +## `noSerialize()` / `NoSerializeSymbol` Qwik ensures that all application state is always serializable. This is important to ensure that Qwik applications have a [resumability](/docs/(qwik)/concepts/resumable/index.mdx) property. Sometimes, it's necessary to store data that can't be serialized; `noSerialize()` instructs Qwik not to attempt serializing the marked value. For example, a reference to a third-party library such as [Monaco editor](https://microsoft.github.io/monaco-editor/) will always need `noSerialize()`, as it is not serializable. +`NoSerializeSymbol` is an alternative to `noSerialize()`: You can add this symbol to an object (preferably on the prototype) and it will mark the object as non-serializable. + If a value is marked as non-serializable, then that value will not survive serialization events, such as resuming the application on the client from SSR. In this situation, the value will be set to `undefined` and it is up to the developer to re-initialize the value on the client. @@ -572,10 +634,15 @@ import { import type Monaco from './monaco'; import { monacoEditor } from './monaco'; +class MyClass { + [NoSerializeSymbol] = true; +} + export default component$(() => { const editorRef = useSignal(); - const store = useStore<{ monacoInstance: NoSerialize }>({ + const store = useStore<{ monacoInstance: NoSerialize, myClass: MyClass }>({ monacoInstance: undefined, + myClass: undefined, }); useVisibleTask$(() => { @@ -585,8 +652,38 @@ export default component$(() => { // Monaco is not serializable, so we can't serialize it as part of SSR // We can however instantiate it on the client after the component is visible store.monacoInstance = noSerialize(editor); + // Here we demonstrate `NoSerializeSymbol` for the same purpose + store.myClass = new MyClass(); }); return
loading...
; }); ```
+ +## `SerializerSymbol` + +The `SerializerSymbol` is a symbol that can be added to an object to achieve custom serialization. There is no corresponding `DeserializerSymbol` however, for that you should use the `useSerializer$()` hook. + +This can be used to skip serializing part of an object, or to work with third-party objects that are not serializable by default. + +```tsx /SerializerSymbol/ +import { component$, SerializerSymbol } from '@qwik.dev/core'; +import { getItem, type Item } from './my-db-layer'; + +const serializer = (o: Item) => o.toJSON(); +type MyItem = Item & { [SerializerSymbol]: typeof serializer }; + +export default component$((props: {id: string}) => { + const obj = useSignal(null); + useTask$(async () => { + const item = await getItem(props.id); + if (item) { + item[SerializerSymbol] = serializer; + obj.value = item; + } + }); + return
{JSON.stringify(obj.value)}
; +}); +``` + +You could even monkey-patch the third-party library to add the `SerializerSymbol` to their objects. Note that this can result in problems when the third-party library is updated, so use this method with caution. You will also need to update the types so that our linting plugin doesn't complain about serialization of unknown types. diff --git a/packages/eslint-plugin-qwik/examples.ts b/packages/eslint-plugin-qwik/examples.ts index 4041390ee17..b1fbaccbed6 100644 --- a/packages/eslint-plugin-qwik/examples.ts +++ b/packages/eslint-plugin-qwik/examples.ts @@ -9,6 +9,7 @@ import { jsxNoScriptUrlExamples } from './src/jsxNoScriptUrl'; import { loaderLocationExamples } from './src/loaderLocation'; import { noReactPropsExamples } from './src/noReactProps'; import { preferClasslistExamples } from './src/preferClasslist'; +import { serializerSignalUsageExamples } from './src/serializerSignalUsage'; import { unusedServerExamples } from './src/unusedServer'; import { useMethodUsageExamples } from './src/useMethodUsage'; import { validLexicalScopeExamples } from './src/validLexicalScope'; @@ -38,4 +39,5 @@ export const examples = { 'jsx-key': jsxKeyExamples, 'unused-server': unusedServerExamples, 'jsx-img': jsxImgExamples, + 'serializer-signal-usage': serializerSignalUsageExamples, }; diff --git a/packages/eslint-plugin-qwik/index.ts b/packages/eslint-plugin-qwik/index.ts index dc530e7962f..cbc1ddec787 100644 --- a/packages/eslint-plugin-qwik/index.ts +++ b/packages/eslint-plugin-qwik/index.ts @@ -9,6 +9,7 @@ import { preferClasslist } from './src/preferClasslist'; import { unusedServer } from './src/unusedServer'; import { useMethodUsage } from './src/useMethodUsage'; import { validLexicalScope } from './src/validLexicalScope'; +import { serializerSignalUsage } from './src/serializerSignalUsage'; import pkg from './package.json'; const rules = { @@ -23,6 +24,7 @@ const rules = { 'jsx-img': jsxImg, 'jsx-a': jsxAtag, 'no-use-visible-task': noUseVisibleTask, + 'serializer-signal-usage': serializerSignalUsage, }; const recommendedRules = { @@ -37,6 +39,7 @@ const recommendedRules = { 'qwik/jsx-img': 'warn', 'qwik/jsx-a': 'warn', 'qwik/no-use-visible-task': 'warn', + 'qwik/serializer-signal-usage': 'error', }; const strictRules = { 'qwik/valid-lexical-scope': 'error', @@ -50,6 +53,7 @@ const strictRules = { 'qwik/jsx-img': 'error', 'qwik/jsx-a': 'error', 'qwik/no-use-visible-task': 'warn', + 'qwik/serializer-signal-usage': 'error', }; const configs = { diff --git a/packages/eslint-plugin-qwik/src/serializerSignalUsage.ts b/packages/eslint-plugin-qwik/src/serializerSignalUsage.ts new file mode 100644 index 00000000000..d880ec3b7c2 --- /dev/null +++ b/packages/eslint-plugin-qwik/src/serializerSignalUsage.ts @@ -0,0 +1,274 @@ +// This was vibe-coded with AI +import { ESLintUtils } from '@typescript-eslint/utils'; +import ts from 'typescript'; +import { QwikEslintExamples } from '../examples'; + +const createRule = ESLintUtils.RuleCreator( + (name) => `https://qwik.dev/docs/advanced/eslint/#${name}` +); + +export const serializerSignalUsage = createRule({ + name: 'serializer-signal-usage', + meta: { + type: 'problem', + docs: { + description: 'Ensure signals used in update() are also used in deserialize()', + }, + schema: [], + messages: { + serializerSignalMismatch: + 'Signals/Stores used in update() must also be used in deserialize(). Missing: {{signals}}', + }, + }, + defaultOptions: [], + create(context) { + // Check if we have TypeScript services available + if (!context.parserServices?.program || !context.parserServices?.esTreeNodeToTSNodeMap) { + return {}; + } + + const services = { + program: context.parserServices.program, + esTreeNodeToTSNodeMap: context.parserServices.esTreeNodeToTSNodeMap, + }; + const typeChecker = services.program.getTypeChecker(); + + function isSignalOrStoreType(type: ts.Type): boolean { + if (!type.symbol) { + // For 'any' type, check if it's being used like a store/signal + if (type.flags & ts.TypeFlags.Any) { + return true; // Consider 'any' as potentially a store/signal + } + return false; + } + + // Check if it's a Signal or Store by name + // Note, we don't have a Store type but we might in the future + if (type.symbol.name === 'Signal' || type.symbol.name === 'Store') { + return true; + } + + // Check if it's a Signal by having a value property + const properties = type.getProperties(); + const hasValue = properties.some((p) => p.name === 'value'); + if (hasValue) { + return true; + } + + // Check if it's a Store by checking if it's an object with properties + if (type.flags & ts.TypeFlags.Object) { + // For object types, consider them stores if they have properties + // This catches both explicit Store types and plain objects used as stores + return properties.length > 0; + } + + return false; + } + + function collectSignalsFromFunction(node: any): Set { + const signals = new Set(); + const visited = new WeakSet(); + const functionParams = new Set(); + + // Collect function parameters first + if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') { + node.params.forEach((param: any) => { + if (param.type === 'Identifier') { + functionParams.add(param.name); + } + }); + } + + function isAssignmentTarget(node: any): boolean { + const parent = node.parent; + if (!parent) { + return false; + } + + // Check if it's the left side of an assignment + if (parent.type === 'AssignmentExpression' && parent.left === node) { + return true; + } + + // Check if it's the target of an update expression (++, --) + if (parent.type === 'UpdateExpression' && parent.argument === node) { + return true; + } + + return false; + } + + function visit(node: any) { + if (!node || typeof node !== 'object' || visited.has(node)) { + return; + } + visited.add(node); + + if (node.type === 'MemberExpression' && !isAssignmentTarget(node)) { + const obj = node.object; + if (obj?.type === 'Identifier' && !functionParams.has(obj.name)) { + const tsNode = services.esTreeNodeToTSNodeMap.get(obj); + if (tsNode) { + const type = typeChecker.getTypeAtLocation(tsNode); + if (isSignalOrStoreType(type)) { + if (type.flags & ts.TypeFlags.Object || type.flags & ts.TypeFlags.Any) { + // For stores, track each property access + const propName = node.property?.name; + if (propName) { + signals.add(`${obj.name}.${propName}`); + } + } else { + // For signals, track the whole signal + signals.add(obj.name); + } + } + } + } + } + + // Visit specific AST properties that can contain nodes + const properties = [ + 'body', + 'expression', + 'expressions', + 'argument', + 'arguments', + 'left', + 'right', + 'object', + 'property', + ]; + + for (const prop of properties) { + const value = node[prop]; + if (Array.isArray(value)) { + value.forEach((v) => visit(v)); + } else if (value && typeof value === 'object') { + value.parent = node; // Add parent reference for assignment checking + visit(value); + } + } + } + + const body = node.type === 'BlockStatement' ? node.body : [node]; + body.forEach(visit); + return signals; + } + + return { + CallExpression(node) { + if ( + node.callee.type === 'Identifier' && + (node.callee.name === 'useSerializer$' || node.callee.name === 'createSerializer$') + ) { + const arg = node.arguments[0]; + + // Check if the argument is a function that returns an object + if (arg?.type === 'ArrowFunctionExpression' || arg?.type === 'FunctionExpression') { + const returnValue = + arg.body.type === 'BlockStatement' + ? (arg.body.body.find((stmt: any) => stmt.type === 'ReturnStatement') as any) + ?.argument + : arg.body; + + if (returnValue?.type === 'ObjectExpression') { + const updateProp = returnValue.properties.find( + (p) => + p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'update' + ); + const deserializeProp = returnValue.properties.find( + (p) => + p.type === 'Property' && + p.key.type === 'Identifier' && + p.key.name === 'deserialize' + ); + + if (updateProp && deserializeProp) { + const updateFn = (updateProp as any).value; + const deserializeFn = (deserializeProp as any).value; + + const updateSignals = collectSignalsFromFunction(updateFn.body); + const deserializeSignals = collectSignalsFromFunction(deserializeFn.body); + + // Check both directions + const missingInDeserialize = Array.from(updateSignals).filter( + (signal) => !deserializeSignals.has(signal) + ); + const missingInUpdate = Array.from(deserializeSignals).filter( + (signal) => !updateSignals.has(signal) + ); + + const allMissingSignals = [...missingInDeserialize, ...missingInUpdate]; + + if (allMissingSignals.length > 0) { + context.report({ + node: updateProp, + messageId: 'serializerSignalMismatch', + data: { + signals: allMissingSignals.join(', '), + }, + }); + } + } + } + } + } + }, + }; + }, +}); + +const serializerSignalMismatchGood = ` +import { useSignal, useSerializer$ } from '@qwik.dev/core'; +import MyClass from './my-class'; + +export default component$(() => { + const countSignal = useSignal(0); + + useSerializer$(() => ({ + deserialize: () => new MyClass(countSignal.value), + update: (myClass) => { + myClass.count = countSignal.value; + return myClass; + } + })); + + return
{myClass.count}
; +});`.trim(); + +const serializerSignalMismatchBad = ` +import { useSignal, useSerializer$ } from '@qwik.dev/core'; +import MyClass from './my-class'; + +export default component$(() => { + const countSignal = useSignal(0); + + useSerializer$(() => ({ + deserialize: (count) => new MyClass(count), + initial: 2, + update: (myClass) => { + myClass.count = countSignal.value; + return myClass; + } + })); + + return
{myClass.count}
; +});`.trim(); + +export const serializerSignalUsageExamples: QwikEslintExamples = { + serializerSignalMismatch: { + good: [ + { + codeHighlight: '/countSignal/#a', + code: serializerSignalMismatchGood, + }, + ], + bad: [ + { + codeHighlight: '/countSignal/#a', + code: serializerSignalMismatchBad, + description: 'The countSignal is used in update() but not in deserialize()', + }, + ], + }, +}; diff --git a/packages/eslint-plugin-qwik/src/validLexicalScope.ts b/packages/eslint-plugin-qwik/src/validLexicalScope.ts index bb702076987..974d39f27ee 100644 --- a/packages/eslint-plugin-qwik/src/validLexicalScope.ts +++ b/packages/eslint-plugin-qwik/src/validLexicalScope.ts @@ -61,7 +61,6 @@ export const validLexicalScope = createRule({ const esTreeNodeToTSNodeMap = services.esTreeNodeToTSNodeMap; const typeChecker = services.program.getTypeChecker(); const relevantScopes: Map = new Map(); - let exports: ts.Symbol[] = []; function walkScope(scope: Scope.Scope) { scope.references.forEach((ref) => { @@ -287,7 +286,15 @@ function _isTypeCapturable( return; } seen.add(type); - if (type.getProperty('__no_serialize__') || type.getProperty('__qwik_serializable__')) { + if ( + type + .getProperties() + .some((p) => + /(__no_serialize__|__qwik_serializable__|NoSerializeSymbol|SerializerSymbol)/i.test( + p.escapedName as string + ) + ) + ) { return; } const isUnknown = type.flags & ts.TypeFlags.Unknown; diff --git a/packages/eslint-plugin-qwik/tests/serializer-signal-usage/invalid-missing-signal.tsx b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/invalid-missing-signal.tsx new file mode 100644 index 00000000000..955d51119eb --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/invalid-missing-signal.tsx @@ -0,0 +1,32 @@ +// @typescript-eslint/parser +// Expect error: { "messageId": "serializerSignalMismatch" } +import { component$, useSignal, useSerializer$ } from '@qwik.dev/core'; + +class MyClass { + constructor( + public count: number, + public name: string + ) {} +} + +export default component$(() => { + const countSignal = useSignal(0); + const nameSignal = useSignal('John'); + + const out = useSerializer$(() => ({ + deserialize: () => { + return new MyClass(countSignal.value, 'Fred'); + }, + update: (myClass) => { + myClass.count++; // countSignal not used here + myClass.name = nameSignal.value; // nameSignal used here but not in deserialize + return myClass; + }, + })); + + return ( +
+ {out.value.count} {out.value.name} +
+ ); +}); diff --git a/packages/eslint-plugin-qwik/tests/serializer-signal-usage/invalid-missing-store.tsx b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/invalid-missing-store.tsx new file mode 100644 index 00000000000..555fe692d35 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/invalid-missing-store.tsx @@ -0,0 +1,30 @@ +// Expect error: { "messageId": "serializerSignalMismatch" } +import { component$, useStore, useSerializer$ } from '@qwik.dev/core'; + +class MyClass { + constructor( + public count: number, + public name: string + ) {} +} + +export default component$(() => { + const store = useStore({ count: 0, name: 'John' }); + + const out = useSerializer$(() => ({ + deserialize: () => { + return new MyClass(store.count, store.name); + }, + update: (myClass) => { + myClass.count++; // store.count not used here + myClass.name = store.name; + return myClass; + }, + })); + + return ( +
+ {out.value.count} {out.value.name} +
+ ); +}); diff --git a/packages/eslint-plugin-qwik/tests/serializer-signal-usage/valid-signal-usage.tsx b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/valid-signal-usage.tsx new file mode 100644 index 00000000000..fd835a9eea2 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/valid-signal-usage.tsx @@ -0,0 +1,21 @@ +import { component$, useSignal, useSerializer$ } from '@qwik.dev/core'; + +class MyClass { + constructor(public count: number) {} +} + +export default component$(() => { + const countSignal = useSignal(0); + + const out = useSerializer$(() => ({ + deserialize: () => { + return new MyClass(countSignal.value); + }, + update: (myClass) => { + myClass.count = countSignal.value; + return myClass; + }, + })); + + return
{out.value.count}
; +}); diff --git a/packages/eslint-plugin-qwik/tests/serializer-signal-usage/valid-store-usage.tsx b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/valid-store-usage.tsx new file mode 100644 index 00000000000..01ea96ea3bb --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/serializer-signal-usage/valid-store-usage.tsx @@ -0,0 +1,29 @@ +import { component$, useStore, useSerializer$ } from '@qwik.dev/core'; + +class MyClass { + constructor( + public count: number, + public name: string + ) {} +} + +export default component$(() => { + const store = useStore({ count: 0, name: 'John' }); + + const out = useSerializer$(() => ({ + deserialize: () => { + return new MyClass(store.count, store.name); + }, + update: (myClass) => { + myClass.count = store.count; + myClass.name = store.name; + return myClass; + }, + })); + + return ( +
+ {out.value.count} {out.value.name} +
+ ); +}); diff --git a/packages/eslint-plugin-qwik/tests/valid-lexical-scope/valid-no-serialize-symbol.tsx b/packages/eslint-plugin-qwik/tests/valid-lexical-scope/valid-no-serialize-symbol.tsx new file mode 100644 index 00000000000..b96846fc8c0 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/valid-lexical-scope/valid-no-serialize-symbol.tsx @@ -0,0 +1,74 @@ +import { + component$, + NoSerializeSymbol, + SerializerSymbol, + useSerializer$, + useSignal, + useVisibleTask$, +} from '@qwik.dev/core'; + +// Instances of this class will be serialized as `undefined` +class NoSerSym { + [NoSerializeSymbol] = true; +} + +// Instances of this class will be serialized as the result of `toString()` +class SerSym { + constructor(public value: string) { + // do something with the value + } + [SerializerSymbol]() { + return this.toString(); + } +} + +export interface Value { + value: number; + obj1: NoSerSym; + obj2: SerSym; +} + +class Obj1 { + constructor(public value: string) {} + toString() { + return this.value; + } +} +const makeObj1 = (s: string) => new Obj1(s); + +class Obj2 { + constructor(public value: number) {} + toString() { + return this.value; + } +} +const makeObj2 = (n: number) => new Obj2(n); + +export const HelloWorld = component$(() => { + // Will be serialized as `{value: 12, obj1: undefined, obj2: 'hello'}` + const state: Value = { value: 12, obj1: new NoSerSym(), obj2: new SerSym('hello') }; + const ser1 = useSerializer$({ + deserialize: (data: string) => makeObj1(data), + serialize: (obj) => obj.toString(), + initial: 'hi', + }); + const sig = useSignal(123); + const ser2 = useSerializer$(() => ({ + deserialize: () => makeObj2(sig.value), + update: (obj) => { + // when the signal changes, update the object + obj.value = sig.value; + // return the updated object so the listeners are notified + return obj; + }, + // we only need the signal, so we don't need to serialize anything + // Not specifying the serialize method is the same as returning `undefined` + })); + + useVisibleTask$(() => { + // eslint-disable-next-line no-console + console.log('serialized', state, ser1.value, ser2.value); + }); + + return null; +}); diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts index d179152b023..fccc2f88fb8 100644 --- a/packages/qwik/public.d.ts +++ b/packages/qwik/public.d.ts @@ -6,6 +6,7 @@ export { ComputedSignal, ContextId, createComputed$, + createSerializer$, createContextId, createSignal, CSSProperties, @@ -28,6 +29,8 @@ export { JSXOutput, noSerialize, NoSerialize, + NoSerializeSymbol, + SerializerSymbol, OnVisibleTaskOptions, PrefetchGraph, PrefetchServiceWorker, @@ -61,6 +64,7 @@ export { useOnDocument, useOnWindow, useResource$, + useSerializer$, useServerData, useSignal, useStore, diff --git a/packages/qwik/src/core/api.md b/packages/qwik/src/core/api.md index b903e8d9f4b..26b74ba8ee0 100644 --- a/packages/qwik/src/core/api.md +++ b/packages/qwik/src/core/api.md @@ -111,14 +111,31 @@ export interface CorrectedToggleEvent extends Event { // @public export const createComputed$: (qrl: () => T) => T extends Promise ? never : ComputedSignal; +// Warning: (ae-forgotten-export) The symbol "ComputedSignalImpl" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "createComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const createComputedQrl: (qrl: QRL<() => T>) => T extends Promise ? never : ComputedSignal; +export const createComputedQrl: (qrl: QRL<() => T>) => ComputedSignalImpl; // @public export const createContextId: (name: string) => ContextId; +// Warning: (ae-forgotten-export) The symbol "SerializerArg" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "SerializerSignal" needs to be exported by the entry point index.d.ts +// +// @public +export const createSerializer$: (arg: SerializerArg) => T extends Promise ? never : SerializerSignal; + +// Warning: (ae-forgotten-export) The symbol "SerializerSignalImpl" needs to be exported by the entry point index.d.ts +// Warning: (ae-internal-missing-underscore) The name "createSerializerQrl" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export const createSerializerQrl: (arg: QRL<{ + serialize: (data: S | undefined) => T; + deserialize: (data: T) => S; + initial?: S; +}>) => SerializerSignalImpl; + // @public export const createSignal: { (): Signal; @@ -497,6 +514,9 @@ export type NoSerialize = (T & { // @public export const noSerialize: (input: T) => NoSerialize; +// @public +export const NoSerializeSymbol: unique symbol; + // @public (undocumented) export type OnRenderFn = (props: PROPS) => JSXOutput; @@ -815,6 +835,9 @@ export const _run: (...args: unknown[]) => ValueOrPromise; // @internal export function _serialize(data: unknown[]): Promise; +// @public +export const SerializerSymbol: unique symbol; + // @public export const setPlatform: (plt: CorePlatform) => CorePlatform; @@ -1621,6 +1644,14 @@ export const useResource$: (generatorFn: ResourceFn, opts?: ResourceOption // @internal (undocumented) export const useResourceQrl: (qrl: QRL>, opts?: ResourceOptions) => ResourceReturn; +// @public +export const useSerializer$: typeof createSerializer$; + +// Warning: (ae-internal-missing-underscore) The name "useSerializerQrl" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export const useSerializerQrl: (qrl: QRL>) => ReadonlySignal; + // @public (undocumented) export function useServerData(key: string): T | undefined; diff --git a/packages/qwik/src/core/debug.ts b/packages/qwik/src/core/debug.ts index 4abc3b46532..c3fe12a1d90 100644 --- a/packages/qwik/src/core/debug.ts +++ b/packages/qwik/src/core/debug.ts @@ -2,7 +2,7 @@ import { isQrl } from '../server/prefetch-strategy'; import { isJSXNode } from './shared/jsx/jsx-runtime'; import { isTask } from './use/use-task'; import { vnode_getProp, vnode_isVNode } from './client/vnode'; -import { ComputedSignal, WrappedSignal, isSignal } from './signal/signal'; +import { ComputedSignalImpl, WrappedSignal, isSignal } from './signal/signal'; import { isStore } from './signal/store'; import { DEBUG_TYPE } from './shared/types'; @@ -38,7 +38,7 @@ export function qwikDebugToString(value: any): any { } else if (isSignal(value)) { if (value instanceof WrappedSignal) { return 'WrappedSignal'; - } else if (value instanceof ComputedSignal) { + } else if (value instanceof ComputedSignalImpl) { return 'ComputedSignal'; } else { return 'Signal'; diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 79888d26eed..b4e5bd40805 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -111,6 +111,7 @@ export type { ContextId } from './use/use-context'; export type { UseStoreOptions } from './use/use-store.public'; export type { ComputedFn } from './use/use-computed'; export { useComputedQrl } from './use/use-computed'; +export { useSerializerQrl, useSerializer$ } from './use/use-serializer'; export type { OnVisibleTaskOptions, VisibleTaskStrategy } from './use/use-visible-task'; export { useVisibleTaskQrl } from './use/use-visible-task'; export type { TaskCtx, TaskFn, Tracker } from './use/use-task'; @@ -129,18 +130,29 @@ export { useResource$ } from './use/use-resource-dollar'; export { useTaskQrl } from './use/use-task'; export { useTask$ } from './use/use-task-dollar'; export { useVisibleTask$ } from './use/use-visible-task-dollar'; -export { useComputed$ } from './use/use-computed-dollar'; +export { useComputed$ } from './use/use-computed'; export { useErrorBoundary } from './use/use-error-boundary'; export type { ErrorBoundaryStore } from './shared/error/error-handling'; export { type ReadonlySignal, type Signal, type ComputedSignal } from './signal/signal.public'; -export { isSignal, createSignal, createComputedQrl, createComputed$ } from './signal/signal.public'; +export { + isSignal, + createSignal, + createComputedQrl, + createComputed$, + createSerializerQrl, + createSerializer$, +} from './signal/signal.public'; export { SubscriptionData as _EffectData } from './signal/signal'; ////////////////////////////////////////////////////////////////////////////////////////// // Developer Low-Level API ////////////////////////////////////////////////////////////////////////////////////////// export type { ValueOrPromise } from './shared/utils/types'; -export { type NoSerialize } from './shared/utils/serialize-utils'; +export { + NoSerializeSymbol, + SerializerSymbol, + type NoSerialize, +} from './shared/utils/serialize-utils'; export { noSerialize } from './shared/utils/serialize-utils'; export { version } from './version'; diff --git a/packages/qwik/src/core/shared/error/error.ts b/packages/qwik/src/core/shared/error/error.ts index 92254f548c1..90dac9d21ff 100644 --- a/packages/qwik/src/core/shared/error/error.ts +++ b/packages/qwik/src/core/shared/error/error.ts @@ -55,6 +55,7 @@ export const codeToText = (code: number, ...parts: any[]): string => { 'ComputedSignal is read-only', // 47 'WrappedSignal is read-only', // 48 'Attribute value is unsafe for SSR', // 49 + 'SerializerSymbol function returned rejected promise', // 50 ]; let text = MAP[code] ?? ''; if (parts.length) { @@ -124,6 +125,7 @@ export const enum QError { computedReadOnly = 47, wrappedReadOnly = 48, unsafeAttr = 49, + serializerSymbolRejectedPromise = 50, } export const qError = (code: number, errorMessageArgs: any[] = []): Error => { diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index fdb0a54c6ee..bc13216ba88 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -91,7 +91,7 @@ import { } from '../client/types'; import { VNodeJournalOpCode, vnode_isVNode, vnode_setAttr } from '../client/vnode'; import { vnode_diff } from '../client/vnode-diff'; -import { triggerEffects, type ComputedSignal, type WrappedSignal } from '../signal/signal'; +import { triggerEffects, type ComputedSignalImpl, type WrappedSignal } from '../signal/signal'; import { isSignal, type Signal } from '../signal/signal.public'; import type { TargetType } from '../signal/store'; import type { ISsrNode } from '../ssr/ssr-types'; @@ -471,7 +471,7 @@ export const createScheduler = ( } case ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS: { { - const target = chore.$target$ as ComputedSignal | WrappedSignal; + const target = chore.$target$ as ComputedSignalImpl | WrappedSignal; const forceRunEffects = target.$forceRunEffects$; target.$forceRunEffects$ = false; if (!target.$effects$?.size) { diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index abd8682cb24..975fce104d6 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -9,15 +9,18 @@ import type { VNode } from '../client/types'; import { vnode_getNode, vnode_isVNode, vnode_locate, vnode_toString } from '../client/vnode'; import { _EFFECT_BACK_REF, NEEDS_COMPUTATION } from '../signal/flags'; import { - ComputedSignal, + ComputedSignalImpl, type EffectProperty, type EffectSubscription, EffectSubscriptionProp, - Signal, + SerializerSignalImpl, + SignalImpl, SubscriptionData, WrappedSignal, + isSerializerObj, SignalFlags, type AllSignalFlags, + type SerializerArg, } from '../signal/signal'; import { getOrCreateStore, @@ -53,7 +56,7 @@ import { isElement, isNode } from './utils/element'; import { EMPTY_ARRAY, EMPTY_OBJ } from './utils/flyweight'; import { ELEMENT_ID } from './utils/markers'; import { isPromise } from './utils/promises'; -import { fastSkipSerialize } from './utils/serialize-utils'; +import { SerializerSymbol, fastSkipSerialize } from './utils/serialize-utils'; import { type ValueOrPromise } from './utils/types'; const deserializedProxyMap = new WeakMap(); @@ -260,7 +263,7 @@ const inflate = ( break; } case TypeIds.Signal: { - const signal = target as Signal; + const signal = target as SignalImpl; const d = data as [unknown, ...EffectSubscription[]]; signal.$untrackedValue$ = d[0]; signal.$effects$ = new Set(d.slice(1) as EffectSubscription[]); @@ -286,13 +289,20 @@ const inflate = ( signal.$effects$ = new Set(d.slice(6) as EffectSubscription[]); break; } + // Inflating a SerializerSignal is the same as inflating a ComputedSignal + case TypeIds.SerializerSignal: case TypeIds.ComputedSignal: { - const computed = target as ComputedSignal; - const d = data as [QRLInternal<() => {}>, any, unknown?]; + const computed = target as ComputedSignalImpl; + const d = data as [QRLInternal<() => {}>, EffectSubscription[] | null, unknown?]; computed.$computeQrl$ = d[0]; - computed.$effects$ = d[1]; - if (d.length === 3) { + computed.$effects$ = new Set(d[1]); + const hasValue = d.length > 2; + if (hasValue) { computed.$untrackedValue$ = d[2]; + // The serialized signal is always invalid so it can recreate the custom object + if (typeId === TypeIds.SerializerSignal) { + computed.$flags$ |= SignalFlags.INVALID; + } } else { computed.$flags$ |= SignalFlags.INVALID; /** @@ -478,11 +488,13 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow case TypeIds.Component: return componentQrl(null!); case TypeIds.Signal: - return new Signal(container as any, 0); + return new SignalImpl(container as any, 0); case TypeIds.WrappedSignal: return new WrappedSignal(container as any, null!, null!, null!); case TypeIds.ComputedSignal: - return new ComputedSignal(container as any, null!); + return new ComputedSignalImpl(container as any, null!); + case TypeIds.SerializerSignal: + return new SerializerSignalImpl(container as any, null!); case TypeIds.Store: case TypeIds.StoreArray: // ignore allocate, we need to assign target while creating store @@ -824,20 +836,40 @@ export const createSerializationContext = ( obj.forEach((v, k) => { discoveredValues.push(k, v); }); - } else if (obj instanceof Signal) { + } else if (obj instanceof SignalImpl) { /** - * WrappedSignal might not be calculated yet so we need to use `untrackedValue` to get the - * value. ComputedSignal can be left uncalculated. + * ComputedSignal can be left un-calculated if invalid. + * + * SerializerSignal is always serialized if it was already calculated. */ - const v = - obj instanceof WrappedSignal - ? obj.untrackedValue - : obj instanceof ComputedSignal && - (obj.$flags$ & SignalFlags.INVALID || fastSkipSerialize(obj)) - ? NEEDS_COMPUTATION - : obj.$untrackedValue$; - if (v !== NEEDS_COMPUTATION) { - discoveredValues.push(v); + const toSerialize = + obj instanceof ComputedSignalImpl && + !(obj instanceof SerializerSignalImpl) && + (obj.$flags$ & SignalFlags.INVALID || fastSkipSerialize(obj)) + ? NEEDS_COMPUTATION + : obj.$untrackedValue$; + if (toSerialize !== NEEDS_COMPUTATION) { + if (obj instanceof SerializerSignalImpl) { + promises.push( + (obj.$computeQrl$ as any as QRLInternal>) + .resolve() + .then((arg) => { + let data; + if ((arg as any).serialize) { + data = (arg as any).serialize(toSerialize); + } else if (SerializerSymbol in toSerialize) { + data = (toSerialize as any)[SerializerSymbol](toSerialize); + } + if (data === undefined) { + data = NEEDS_COMPUTATION; + } + serializationResults.set(obj, data); + discoveredValues.push(data); + }) + ); + } else { + discoveredValues.push(toSerialize); + } } if (obj.$effects$) { discoveredValues.push(obj.$effects$); @@ -851,7 +883,7 @@ export const createSerializationContext = ( if (obj.$hostElement$) { discoveredValues.push(obj.$hostElement$); } - } else if (obj instanceof ComputedSignal) { + } else if (obj instanceof ComputedSignalImpl) { discoverEffectBackRefs(obj[_EFFECT_BACK_REF], discoveredValues); discoveredValues.push(obj.$computeQrl$); } @@ -870,8 +902,6 @@ export const createSerializationContext = ( discoveredValues.push(obj.$ssrNode$.id); } else if (isJSXNode(obj)) { discoveredValues.push(obj.type, obj.props, obj.constProps, obj.children); - } else if (Array.isArray(obj)) { - discoveredValues.push(...obj); } else if (isQrl(obj)) { obj.$captureRef$ && obj.$captureRef$.length && discoveredValues.push(...obj.$captureRef$); } else if (isPropsProxy(obj)) { @@ -890,6 +920,12 @@ export const createSerializationContext = ( promises.push(obj); } else if (obj instanceof SubscriptionData) { discoveredValues.push(obj.data); + } else if (Array.isArray(obj)) { + discoveredValues.push(...obj); + } else if (isSerializerObj(obj)) { + const result = obj[SerializerSymbol](obj); + serializationResults.set(obj, result); + discoveredValues.push(result); } else if (isObjectLiteral(obj)) { Object.entries(obj).forEach(([key, value]) => { discoveredValues.push(key, value); @@ -954,7 +990,10 @@ const discoverEffectBackRefs = ( } }; +/** The results of Promises we encountered during serialization. */ const promiseResults = new WeakMap, [boolean, unknown]>(); +/** The results of custom serializing objects we encountered during serialization. */ +const serializationResults = new WeakMap(); /** * Format: @@ -1054,13 +1093,11 @@ function serialize(serializationContext: SerializationContext): void { output(TypeIds.Constant, Constants.EMPTY_ARRAY); } else if (value === EMPTY_OBJ) { output(TypeIds.Constant, Constants.EMPTY_OBJ); + } else if (value === null) { + output(TypeIds.Constant, Constants.Null); } else { depth++; - if (value === null) { - output(TypeIds.Constant, Constants.Null); - } else { - writeObjectValue(value, idx); - } + writeObjectValue(value, idx); depth--; } } else if (typeof value === 'string') { @@ -1151,6 +1188,20 @@ function serialize(serializationContext: SerializationContext): void { } output(Array.isArray(storeTarget) ? TypeIds.StoreArray : TypeIds.Store, out); } + } else if (isSerializerObj(value)) { + let result = serializationResults.get(value); + // special case: we unwrap Promises + if (isPromise(result)) { + const promiseResult = promiseResults.get(result)!; + if (!promiseResult[0]) { + console.error(promiseResult[1]); + throw qError(QError.serializerSymbolRejectedPromise); + } + result = promiseResult[1]; + } + depth--; + writeValue(result, idx); + depth++; } else if (isObjectLiteral(value)) { if (Array.isArray(value)) { output(TypeIds.Array, value); @@ -1170,13 +1221,15 @@ function serialize(serializationContext: SerializationContext): void { } else if ($isDomRef$(value)) { value.$ssrNode$.vnodeData[0] |= VNodeDataFlag.SERIALIZE; output(TypeIds.RefVNode, value.$ssrNode$.id); - } else if (value instanceof Signal) { + } else if (value instanceof SignalImpl) { /** * Special case: when a Signal value is an SSRNode, it always needs to be a DOM ref instead. * It can never be meant to become a vNode, because vNodes are internal only. */ - const v = - value instanceof ComputedSignal && + const isSerialized = value instanceof SerializerSignalImpl; + const v: unknown = + !isSerialized && + value instanceof ComputedSignalImpl && (value.$flags$ & SignalFlags.INVALID || fastSkipSerialize(value.$untrackedValue$)) ? NEEDS_COMPUTATION : value.$untrackedValue$; @@ -1190,16 +1243,20 @@ function serialize(serializationContext: SerializationContext): void { value.$hostElement$, ...(value.$effects$ || []), ]); - } else if (value instanceof ComputedSignal) { - const out = [ + } else if (value instanceof ComputedSignalImpl) { + const out: [QRLInternal, Set | null, unknown?] = [ value.$computeQrl$, // TODO check if we can use domVRef for effects value.$effects$, ]; if (v !== NEEDS_COMPUTATION) { - out.push(v); + if (isSerialized) { + out.push(serializationResults.get(value)); + } else { + out.push(v); + } } - output(TypeIds.ComputedSignal, out); + output(isSerialized ? TypeIds.SerializerSignal : TypeIds.ComputedSignal, out); } else { output(TypeIds.Signal, [v, ...(value.$effects$ || [])]); } @@ -1547,7 +1604,7 @@ const frameworkType = (obj: any) => { return ( (typeof obj === 'object' && obj !== null && - (obj instanceof Signal || obj instanceof Task || isJSXNode(obj))) || + (obj instanceof SignalImpl || obj instanceof Task || isJSXNode(obj))) || isQrl(obj) ); }; @@ -1658,6 +1715,7 @@ export const enum TypeIds { Signal, WrappedSignal, ComputedSignal, + SerializerSignal, Store, StoreArray, FormData, @@ -1691,6 +1749,7 @@ export const _typeIdNames = [ 'Signal', 'WrappedSignal', 'ComputedSignal', + 'SerializerSignal', 'Store', 'StoreArray', 'FormData', diff --git a/packages/qwik/src/core/shared/shared-serialization.unit.ts b/packages/qwik/src/core/shared/shared-serialization.unit.ts index 2e50971c723..c46fc170ced 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -1,8 +1,13 @@ -import { $, component$ } from '@qwik.dev/core'; -import { describe, expect, it } from 'vitest'; +import { $, component$, noSerialize } from '@qwik.dev/core'; +import { describe, expect, it, vi } from 'vitest'; import { _fnSignal, _wrapProp } from '../internal'; -import { SubscriptionData, type Signal } from '../signal/signal'; -import { createComputed$, createSignal, isSignal } from '../signal/signal.public'; +import { SubscriptionData, type SignalImpl } from '../signal/signal'; +import { + createComputed$, + createSerializer$, + createSignal, + isSignal, +} from '../signal/signal.public'; import { StoreFlags, createStore } from '../signal/store'; import { createResourceReturn } from '../use/use-resource'; import { Task } from '../use/use-task'; @@ -18,6 +23,7 @@ import { } from './shared-serialization'; import { EMPTY_ARRAY, EMPTY_OBJ } from './utils/flyweight'; import { isQrl } from './qrl/qrl-utils'; +import { NoSerializeSymbol, SerializerSymbol } from './utils/serialize-utils'; const DEBUG = false; @@ -353,9 +359,13 @@ describe('shared-serialization', () => { `); }); it(title(TypeIds.WrappedSignal), async () => { + const propSignal = _wrapProp({ foo: 3 }, 'foo'); + if (propSignal.value) { + Math.random(); + } const objs = await serialize( _fnSignal((p0) => p0 + 1, [3], '(p0)=>p0+1'), - _wrapProp({}) + propSignal ); expect(dumpState(objs)).toMatchInlineSnapshot(` " @@ -365,21 +375,26 @@ describe('shared-serialization', () => { Number 3 ] Constant null - Number 4 + Constant NEEDS_COMPUTATION Number 3 Constant null ] 1 WrappedSignal [ Number 1 Array [ - Object [] + Object [ + RootRef 2 + Number 3 + ] + RootRef 2 ] Constant null - Constant undefined + Number 3 Number 3 Constant null ] - (69 chars)" + 2 String "foo" + (88 chars)" `); }); it(title(TypeIds.ComputedSignal), async () => { @@ -408,6 +423,25 @@ describe('shared-serialization', () => { (186 chars)" `); }); + it(title(TypeIds.SerializerSignal), async () => { + const custom = createSerializer$({ + deserialize: (n?: number) => new MyCustomSerializable(n || 3), + serialize: (obj) => obj.n, + }); + // Force the value to be created + custom.value.inc(); + const objs = await serialize(custom); + expect(dumpState(objs)).toMatchInlineSnapshot(` + " + 0 SerializerSignal [ + QRL 1 + Constant null + Number 4 + ] + 1 String "mock-chunk#describe_describe_it_custom_createSerializer_CZt5uiK9L0Y" + (91 chars)" + `); + }); it(title(TypeIds.Store), async () => { expect(await dump(createStore(null, { a: { b: true } }, StoreFlags.RECURSIVE))) .toMatchInlineSnapshot(` @@ -425,6 +459,24 @@ describe('shared-serialization', () => { (36 chars)" `); }); + it(title(TypeIds.StoreArray), async () => { + expect(await dump(createStore(null, [1, { b: true }, 3], StoreFlags.NONE))) + .toMatchInlineSnapshot(` + " + 0 StoreArray [ + Array [ + Number 1 + Object [ + String "b" + Constant true + ] + Number 3 + ] + Number 0 + ] + (37 chars)" + `); + }); it.todo(title(TypeIds.FormData)); it.todo(title(TypeIds.JSXNode)); it.todo(title(TypeIds.PropsProxy)); @@ -529,8 +581,8 @@ describe('shared-serialization', () => { it(title(TypeIds.Promise), async () => { const objs = await serialize(Promise.resolve(shared1), Promise.reject(shared1), shared1); const [p1, p2, shared] = deserialize(objs); - expect(p1).resolves.toBe(shared); - expect(p2).rejects.toBe(shared); + await expect(p1).resolves.toBe(shared); + await expect(p2).rejects.toBe(shared); }); it(title(TypeIds.Set), async () => { const objs = await serialize(shared1, new Set([shared1, ['hi']])); @@ -606,12 +658,13 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.Component)); it(title(TypeIds.Signal), async () => { const objs = await serialize(createSignal('hi')); - const signal = deserialize(objs)[0] as Signal; + const signal = deserialize(objs)[0] as SignalImpl; expect(isSignal(signal)).toBeTruthy(); expect(signal.value).toBe('hi'); }); it.todo(title(TypeIds.WrappedSignal)); it.todo(title(TypeIds.ComputedSignal)); + it.todo(title(TypeIds.SerializerSignal)); // this requires a domcontainer it.skip(title(TypeIds.Store), async () => { const objs = await serialize(createStore(null, { a: { b: true } }, StoreFlags.RECURSIVE)); @@ -619,6 +672,7 @@ describe('shared-serialization', () => { expect(store).toHaveProperty('a'); expect(store.a).toHaveProperty('b', true); }); + it.todo(title(TypeIds.StoreArray)); it.todo(title(TypeIds.FormData)); it.todo(title(TypeIds.JSXNode)); it.todo(title(TypeIds.PropsProxy)); @@ -781,6 +835,85 @@ describe('shared-serialization', () => { expect((obj as any).shared).toBe(newValue); }); }); + + describe('custom serialization', () => { + it('should ignore noSerialize', async () => { + const obj = { hi: true }; + const state = await serialize(noSerialize(obj)); + expect(dumpState(state)).toMatchInlineSnapshot(` + " + 0 Constant undefined + (5 chars)" + `); + }); + it('should ignore NoSerializeSymbol', async () => { + const obj = { hi: true, [NoSerializeSymbol]: true }; + const state = await serialize(obj); + expect(dumpState(state)).toMatchInlineSnapshot(` + " + 0 Constant undefined + (5 chars)" + `); + }); + it('should use SerializerSymbol', async () => { + const obj = { hi: 'obj', [SerializerSymbol]: (o: any) => o.hi }; + class Foo { + hi = 'class'; + [SerializerSymbol]() { + return this.hi; + } + } + const state = await serialize([obj, new Foo(), new MyCustomSerializable(1)]); + expect(dumpState(state)).toMatchInlineSnapshot(` + " + 0 Array [ + String "obj" + String "class" + Number 1 + ] + (27 chars)" + `); + }); + it('should not use SerializerSymbol if not function', async () => { + const obj = { hi: 'orig', [SerializerSymbol]: 'hey' }; + const state = await serialize(obj); + expect(dumpState(state)).toMatchInlineSnapshot(` + " + 0 Object [ + String "hi" + String "orig" + ] + (22 chars)" + `); + }); + it('should unwrap promises from SerializerSymbol', async () => { + class Foo { + hi = 'promise'; + async [SerializerSymbol]() { + return Promise.resolve(this.hi); + } + } + const state = await serialize(new Foo()); + expect(dumpState(state)).toMatchInlineSnapshot(` + " + 0 String "promise" + (13 chars)" + `); + }); + }); + it('should throw rejected promises from SerializerSymbol', async () => { + const consoleSpy = vi.spyOn(console, 'error'); + + class Foo { + hi = 'promise'; + async [SerializerSymbol]() { + throw 'oh no'; + } + } + await expect(serialize(new Foo())).rejects.toThrow('Q50'); + expect(consoleSpy).toHaveBeenCalledWith('oh no'); + consoleSpy.mockRestore(); + }); }); async function serialize(...roots: any[]): Promise { @@ -803,3 +936,13 @@ async function serialize(...roots: any[]): Promise { DEBUG && console.log(objs); return objs; } + +class MyCustomSerializable { + constructor(public n: number) {} + inc() { + this.n++; + } + [SerializerSymbol]() { + return this.n; + } +} diff --git a/packages/qwik/src/core/shared/utils/serialize-utils.ts b/packages/qwik/src/core/shared/utils/serialize-utils.ts index 7fc47b88c11..e40842b9cd9 100644 --- a/packages/qwik/src/core/shared/utils/serialize-utils.ts +++ b/packages/qwik/src/core/shared/utils/serialize-utils.ts @@ -100,7 +100,7 @@ export const shouldSerialize = (obj: unknown): boolean => { }; export const fastSkipSerialize = (obj: object): boolean => { - return noSerializeSet.has(obj); + return typeof obj === 'object' && obj && (NoSerializeSymbol in obj || noSerializeSet.has(obj)); }; export const fastWeakSerialize = (obj: object): boolean => { @@ -147,3 +147,28 @@ export const _weakSerialize = (input: T): Partial => { weakSerializeSet.add(input); return input as any; }; + +/** + * If an object has this property, it will not be serialized. Use this on prototypes to avoid having + * to call `noSerialize()` on every object. + * + * @public + */ +export const NoSerializeSymbol = Symbol('noSerialize'); +/** + * If an object has this property as a function, it will be called with the object and should return + * a serializable value. + * + * This can be used to clean up, integrate with other libraries, etc. + * + * The type your object should conform to is: + * + * ```ts + * { + * [SerializerSymbol]: (this: YourType, toSerialize: YourType) => YourSerializableType; + * } + * ``` + * + * @public + */ +export const SerializerSymbol = Symbol('serialize'); diff --git a/packages/qwik/src/core/signal/signal-api.ts b/packages/qwik/src/core/signal/signal-api.ts index 0bcdad2922f..ebad8c1de3a 100644 --- a/packages/qwik/src/core/signal/signal-api.ts +++ b/packages/qwik/src/core/signal/signal-api.ts @@ -1,12 +1,33 @@ import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; -import { ComputedSignal, Signal, throwIfQRLNotResolved } from './signal'; +import { + ComputedSignalImpl, + SerializerSignalImpl, + SignalImpl, + throwIfQRLNotResolved, + type SerializerArg, +} from './signal'; +import type { Signal } from './signal.public'; -export const createSignal = (value?: T) => { - return new Signal(null, value); +/** @internal */ +export const createSignal = (value?: T): Signal => { + return new SignalImpl(null, value as T) as Signal; }; -export const createComputedSignal = (qrl: QRL<() => T>) => { +/** @internal */ +export const createComputedSignal = (qrl: QRL<() => T>): ComputedSignalImpl => { throwIfQRLNotResolved(qrl); - return new ComputedSignal(null, qrl as QRLInternal<() => T>); + return new ComputedSignalImpl(null, qrl as QRLInternal<() => T>); +}; + +/** @internal */ +export const createSerializerSignal = ( + arg: QRL<{ + serialize: (data: S | undefined) => T; + deserialize: (data: T) => S; + initial?: S; + }> +) => { + throwIfQRLNotResolved(arg); + return new SerializerSignalImpl(null, arg as any as QRLInternal>); }; diff --git a/packages/qwik/src/core/signal/signal-cleanup.ts b/packages/qwik/src/core/signal/signal-cleanup.ts index 574285fbbcd..73598b7e384 100644 --- a/packages/qwik/src/core/signal/signal-cleanup.ts +++ b/packages/qwik/src/core/signal/signal-cleanup.ts @@ -2,7 +2,7 @@ import { EffectSubscriptionProp, WrappedSignal, type EffectSubscription, - Signal, + SignalImpl, type EffectProperty, type Consumer, } from './signal'; @@ -30,7 +30,7 @@ export function clearAllEffects(container: Container, consumer: Consumer): void return; } for (const producer of backRefs) { - if (producer instanceof Signal) { + if (producer instanceof SignalImpl) { clearSignal(container, producer, effect); } else if (container.$storeProxyMap$.has(producer)) { const target = container.$storeProxyMap$.get(producer)!; @@ -41,7 +41,7 @@ export function clearAllEffects(container: Container, consumer: Consumer): void } } -function clearSignal(container: Container, producer: Signal, effect: EffectSubscription) { +function clearSignal(container: Container, producer: SignalImpl, effect: EffectSubscription) { const effects = producer.$effects$; if (effects) { effects.delete(effect); diff --git a/packages/qwik/src/core/signal/signal.public.ts b/packages/qwik/src/core/signal/signal.public.ts index a03bf31519d..dab0acba909 100644 --- a/packages/qwik/src/core/signal/signal.public.ts +++ b/packages/qwik/src/core/signal/signal.public.ts @@ -1,8 +1,9 @@ import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; -import type { QRL } from '../shared/qrl/qrl.public'; +import type { SerializerArg } from './signal'; import { createSignal as _createSignal, - createComputedSignal as _createComputedSignal, + createComputedSignal as createComputedQrl, + createSerializerSignal as createSerializerQrl, } from './signal-api'; export { isSignal } from './signal'; @@ -43,6 +44,16 @@ export interface ComputedSignal extends ReadonlySignal { force(): void; } +/** + * A serializer signal holds a custom serializable value. See `useSerializer$` for more details. + * + * @public + */ +export interface SerializerSignal extends ComputedSignal { + /** Fake property to make the serialization linter happy */ + __no_serialize__: true; +} + /** * Creates a Signal with the given value. If no value is given, the signal is created with * `undefined`. @@ -54,21 +65,34 @@ export const createSignal: { (value: T): Signal; } = _createSignal; -/** @internal */ -export const createComputedQrl: ( - qrl: QRL<() => T> -) => T extends Promise ? never : ComputedSignal = _createComputedSignal as any; - /** * Create a computed signal which is calculated from the given QRL. A computed signal is a signal * which is calculated from other signals. When the signals change, the computed signal is * recalculated. * * The QRL must be a function which returns the value of the signal. The function must not have side - * effects, and it mus be synchronous. + * effects, and it must be synchronous. * * If you need the function to be async, use `useSignal` and `useTask$` instead. * * @public */ -export const createComputed$ = /*#__PURE__*/ implicit$FirstArg(createComputedQrl); +export const createComputed$: ( + qrl: () => T +) => T extends Promise ? never : ComputedSignal = /*#__PURE__*/ implicit$FirstArg( + createComputedQrl as any +); +export { createComputedQrl }; + +/** + * Create a signal that holds a custom serializable value. See {@link useSerializer$} for more + * details. + * + * @public + */ +export const createSerializer$: ( + arg: SerializerArg +) => T extends Promise ? never : SerializerSignal = implicit$FirstArg( + createSerializerQrl as any +); +export { createSerializerQrl }; diff --git a/packages/qwik/src/core/signal/signal.ts b/packages/qwik/src/core/signal/signal.ts index d90836b2e7b..724e7ca8d07 100644 --- a/packages/qwik/src/core/signal/signal.ts +++ b/packages/qwik/src/core/signal/signal.ts @@ -11,28 +11,29 @@ * - It needs to store a function which needs to re-run. * - It is `Readonly` because it is computed. */ +import { isDomContainer } from '../client/dom-container'; +import type { VNode } from '../client/types'; import { pad, qwikDebugToString } from '../debug'; +import type { OnRenderFn } from '../shared/component.public'; import { assertDefined, assertFalse, assertTrue } from '../shared/error/assert'; +import { QError, qError } from '../shared/error/error'; +import type { Props } from '../shared/jsx/jsx-runtime'; import { type QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; -import { trackSignal, tryGetInvokeContext } from '../use/use-core'; -import { isTask, Task, TaskFlags } from '../use/use-task'; +import { type NodePropData, type NodePropPayload } from '../shared/scheduler'; +import type { Container, HostElement } from '../shared/types'; +import { ChoreType } from '../shared/util-chore-type'; import { ELEMENT_PROPS, OnRenderProp } from '../shared/utils/markers'; import { isPromise } from '../shared/utils/promises'; import { qDev } from '../shared/utils/qdev'; -import type { VNode } from '../client/types'; -import { type NodePropData, type NodePropPayload } from '../shared/scheduler'; -import { ChoreType } from '../shared/util-chore-type'; -import type { Container, HostElement } from '../shared/types'; +import { SerializerSymbol } from '../shared/utils/serialize-utils'; import type { ISsrNode, SSRContainer } from '../ssr/ssr-types'; -import type { ReadonlySignal, Signal as ISignal } from './signal.public'; -import type { TargetType } from './store'; -import type { Props } from '../shared/jsx/jsx-runtime'; -import type { OnRenderFn } from '../shared/component.public'; -import { _EFFECT_BACK_REF, NEEDS_COMPUTATION } from './flags'; -import { QError, qError } from '../shared/error/error'; -import { isDomContainer } from '../client/dom-container'; +import { trackSignal, tryGetInvokeContext } from '../use/use-core'; +import { Task, TaskFlags, isTask } from '../use/use-task'; +import { NEEDS_COMPUTATION, _EFFECT_BACK_REF } from './flags'; import { type BackRef } from './signal-cleanup'; +import type { Signal, ReadonlySignal } from './signal.public'; +import type { TargetType } from './store'; import { getSubscriber } from './subscriber'; const DEBUG = false; @@ -60,7 +61,7 @@ export const enum WrappedSignalFlags { export type AllSignalFlags = SignalFlags | WrappedSignalFlags; -export const throwIfQRLNotResolved = (qrl: QRL<() => T>) => { +export const throwIfQRLNotResolved = (qrl: QRL) => { const resolved = qrl.resolved; if (!resolved) { // When we are creating a signal using a use method, we need to ensure @@ -73,8 +74,8 @@ export const throwIfQRLNotResolved = (qrl: QRL<() => T>) => { }; /** @public */ -export const isSignal = (value: any): value is ISignal => { - return value instanceof Signal; +export const isSignal = (value: any): value is Signal => { + return value instanceof SignalImpl; }; /** @@ -86,7 +87,7 @@ export const isSignal = (value: any): value is ISignal => { * - `VNode` and `ISsrNode`: Either a component or `` * - `Signal2`: A derived signal which contains a computation function. */ -export type Consumer = Task | VNode | ISsrNode | Signal; +export type Consumer = Task | VNode | ISsrNode | SignalImpl; /** @internal */ export class SubscriptionData { @@ -136,7 +137,7 @@ export class SubscriptionData { export type EffectSubscription = [ Consumer, // EffectSubscriptionProp.CONSUMER EffectProperty | string, // EffectSubscriptionProp.PROPERTY or string for attributes - Set | null, // EffectSubscriptionProp.BACK_REF + Set | null, // EffectSubscriptionProp.BACK_REF SubscriptionData | null, // EffectSubscriptionProp.DATA ]; @@ -152,7 +153,7 @@ export const enum EffectProperty { VNODE = '.', } -export class Signal implements ISignal { +export class SignalImpl implements Signal { $untrackedValue$: T; /** Store a list of effects which are dependent on this signal. */ @@ -207,7 +208,6 @@ export class Signal implements ISignal { } return this.untrackedValue; } - set value(value) { if (value !== this.$untrackedValue$) { DEBUG && @@ -260,7 +260,7 @@ export const addQrlToSerializationCtx = ( let qrl: QRL | null = null; if (isTask(effect)) { qrl = effect.$qrl$; - } else if (effect instanceof ComputedSignal) { + } else if (effect instanceof ComputedSignalImpl) { qrl = effect.$computeQrl$; } else if (property === EffectProperty.COMPONENT) { qrl = container.getHostProp(effect as ISsrNode, OnRenderProp); @@ -273,7 +273,7 @@ export const addQrlToSerializationCtx = ( export const triggerEffects = ( container: Container | null, - signal: Signal | TargetType, + signal: SignalImpl | TargetType, effects: Set | null ) => { const isBrowser = isDomContainer(container); @@ -290,10 +290,10 @@ export const triggerEffects = ( choreType = ChoreType.VISIBLE; } container.$scheduler$(choreType, consumer); - } else if (consumer instanceof Signal) { + } else if (consumer instanceof SignalImpl) { // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and // and schedule the signals effects (recursively) - if (consumer instanceof ComputedSignal) { + if (consumer instanceof ComputedSignalImpl) { // Ensure that the computed signal's QRL is resolved. // If not resolved schedule it to be resolved. if (!consumer.$computeQrl$.resolved) { @@ -301,7 +301,7 @@ export const triggerEffects = ( } } - (consumer as ComputedSignal | WrappedSignal).$invalidate$(); + (consumer as ComputedSignalImpl | WrappedSignal).$invalidate$(); } else if (property === EffectProperty.COMPONENT) { const host: HostElement = consumer as any; const qrl = container.getHostProp>>(host, OnRenderProp); @@ -311,7 +311,7 @@ export const triggerEffects = ( } else if (isBrowser) { if (property === EffectProperty.VNODE) { const host: HostElement = consumer; - container.$scheduler$(ChoreType.NODE_DIFF, host, host, signal as Signal); + container.$scheduler$(ChoreType.NODE_DIFF, host, host, signal as SignalImpl); } else { const host: HostElement = consumer; const effectData = effectSubscription[EffectSubscriptionProp.DATA]; @@ -319,7 +319,7 @@ export const triggerEffects = ( const data = effectData.data; const payload: NodePropPayload = { ...data, - $value$: signal as Signal, + $value$: signal as SignalImpl, }; container.$scheduler$(ChoreType.NODE_PROP, host, property, payload); } @@ -334,26 +334,28 @@ export const triggerEffects = ( DEBUG && log('done scheduling'); }; +type ComputeQRL = QRLInternal<() => T>; + /** * A signal which is computed from other signals. * * The value is available synchronously, but the computation is done lazily. */ -export class ComputedSignal extends Signal implements BackRef { +export class ComputedSignalImpl extends SignalImpl implements BackRef { /** * The compute function is stored here. * * The computed functions must be executed synchronously (because of this we need to eagerly * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) */ - $computeQrl$: QRLInternal<() => T>; + $computeQrl$: ComputeQRL; $flags$: SignalFlags; $forceRunEffects$: boolean = false; [_EFFECT_BACK_REF]: Map | null = null; constructor( container: Container | null, - fn: QRLInternal<() => T>, + fn: ComputeQRL, // We need a separate flag to know when the computation needs running because // we need the old value to know if effects need running after computation flags = SignalFlags.INVALID @@ -368,8 +370,6 @@ export class ComputedSignal extends Signal implements BackRef { $invalidate$() { this.$flags$ |= SignalFlags.INVALID; this.$forceRunEffects$ = false; - // We should only call subscribers if the calculation actually changed. - // Therefore, we need to calculate the value now. this.$container$?.$scheduler$(ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this); } @@ -378,9 +378,8 @@ export class ComputedSignal extends Signal implements BackRef { * remained the same object */ force() { - this.$flags$ |= SignalFlags.INVALID; - this.$forceRunEffects$ = false; - triggerEffects(this.$container$, this, this.$effects$); + this.$forceRunEffects$ = true; + this.$container$?.$scheduler$(ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this); } get untrackedValue() { @@ -426,17 +425,17 @@ export class ComputedSignal extends Signal implements BackRef { } } - // Getters don't get inherited - get value() { - return super.value; - } - + // Make this signal read-only set value(_: any) { throw qError(QError.computedReadOnly); } + // Getters don't get inherited when overriding a setter + get value() { + return super.value; + } } -export class WrappedSignal extends Signal implements BackRef { +export class WrappedSignal extends SignalImpl implements BackRef { $args$: any[]; $func$: (...args: any[]) => T; $funcStr$: string | null; @@ -476,7 +475,7 @@ export class WrappedSignal extends Signal implements BackRef { /** * Use this to force running subscribers, for example when the calculated value has mutated but - * remained the same object + * remained the same object. */ force() { this.$flags$ |= SignalFlags.INVALID; @@ -511,13 +510,118 @@ export class WrappedSignal extends Signal implements BackRef { } return didChange; } - - // Getters don't get inherited + // Make this signal read-only + set value(_: any) { + throw qError(QError.wrappedReadOnly); + } + // Getters don't get inherited when overriding a setter get value() { return super.value; } +} - set value(_: any) { - throw qError(QError.wrappedReadOnly); +/** @public */ +export type SerializerArgObject = { + /** + * This will be called with initial or serialized data to reconstruct an object. If no + * `initialData` is provided, it will be called with `undefined`. + * + * This must not return a Promise. + */ + deserialize: (data: Awaited) => T; + /** The initial value to use when deserializing. */ + initial?: S | undefined; + /** + * This will be called with the object to get the serialized data. You can return a Promise if you + * need to do async work. + * + * The result may be anything that Qwik can serialize. + * + * If you do not provide it, the object will be serialized as `undefined`. However, if the object + * has a `[SerializerSymbol]` property, that will be used as the serializer instead. + */ + serialize?: (obj: T) => S; +}; + +/** + * Serialize and deserialize custom objects. + * + * If you need to use scoped state, you can pass a function instead of an object. The function will + * be called with the current value, and you can return a new value. + * + * @public + */ +export type SerializerArg = + | SerializerArgObject + | (() => SerializerArgObject & { + /** + * This gets called when reactive state used during `deserialize` changes. You may mutate the + * current object, or return a new object. + * + * If it returns a value, that will be used as the new value, and listeners will be triggered. + * If no change happened, don't return anything. + * + * If you mutate the current object, you must return it so that it will trigger listeners. + */ + update?: (current: T) => T | void; + }); + +/** + * A signal which provides a non-serializable value. It works like a computed signal, but it is + * handled slightly differently during serdes. + * + * @public + */ +export class SerializerSignalImpl extends ComputedSignalImpl { + constructor(container: Container | null, argQrl: QRLInternal>) { + super(container, argQrl as unknown as ComputeQRL); + } + $didInitialize$: boolean = false; + + $computeIfNeeded$(): boolean { + if (!(this.$flags$ & SignalFlags.INVALID)) { + return false; + } + throwIfQRLNotResolved(this.$computeQrl$); + let arg = (this.$computeQrl$ as any as QRLInternal>).resolved!; + if (typeof arg === 'function') { + arg = arg(); + } + const { deserialize, initial } = arg; + const update = (arg as any).update as ((current: T) => T) | undefined; + const currentValue = + this.$untrackedValue$ === NEEDS_COMPUTATION ? initial : this.$untrackedValue$; + const untrackedValue = trackSignal( + () => + this.$didInitialize$ + ? update?.(currentValue as T) + : deserialize(currentValue as Awaited), + this, + EffectProperty.VNODE, + this.$container$! + ); + DEBUG && log('SerializerSignal.$compute$', untrackedValue); + const didChange = + (this.$didInitialize$ && untrackedValue !== 'undefined') || + untrackedValue !== this.$untrackedValue$; + this.$flags$ &= ~SignalFlags.INVALID; + this.$didInitialize$ = true; + if (didChange) { + this.$untrackedValue$ = untrackedValue as T; + } + return didChange; } } + +// TODO move to serializer +export type CustomSerializable any }, S> = { + [SerializerSymbol]: (obj: T) => S; +}; +/** @internal */ +export const isSerializerObj = any }, S>( + obj: unknown +): obj is CustomSerializable => { + return ( + typeof obj === 'object' && obj !== null && typeof (obj as any)[SerializerSymbol] === 'function' + ); +}; diff --git a/packages/qwik/src/core/signal/signal.unit.tsx b/packages/qwik/src/core/signal/signal.unit.tsx index b84c3a21973..22ff7952157 100644 --- a/packages/qwik/src/core/signal/signal.unit.tsx +++ b/packages/qwik/src/core/signal/signal.unit.tsx @@ -1,6 +1,6 @@ -import { $, type ValueOrPromise } from '@qwik.dev/core'; +import { $, isBrowser, type ValueOrPromise } from '@qwik.dev/core'; import { createDocument, getTestPlatform } from '@qwik.dev/core/testing'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from 'vitest'; import { getDomContainer } from '../client/dom-container'; import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; import { inlinedQrl } from '../shared/qrl/qrl'; @@ -14,14 +14,99 @@ import { Task } from '../use/use-task'; import { EffectProperty, SignalFlags, - type ComputedSignal, type InternalReadonlySignal, type InternalSignal, } from './signal'; -import { createComputedQrl, createSignal } from './signal.public'; +import { + createComputed$, + createComputedQrl, + createSerializer$, + createSignal, + type ComputedSignal, + type SerializerSignal, + type Signal, +} from './signal.public'; import { getSubscriber } from './subscriber'; -describe('v2-signal', () => { +class Foo { + constructor(public val: number = 0) {} + update(val: number) { + this.val = val; + } +} + +describe('signal types', () => { + it('Signal', () => () => { + const signal = createSignal(1); + expectTypeOf(signal).toEqualTypeOf>(); + }); + it('ComputedSignal', () => () => { + const signal = createComputed$(() => 1); + expectTypeOf(signal).toEqualTypeOf>(); + const signal2 = createComputed$(() => 1); + expectTypeOf(signal2).toEqualTypeOf>(); + }); + it('SerializerSignal', () => () => { + { + const signal = createSerializer$({ + deserialize: () => new Foo(), + serialize: (obj) => { + expect(obj).toBeInstanceOf(Foo); + return 1; + }, + }); + expectTypeOf(signal).toEqualTypeOf>(); + expectTypeOf(signal.value).toEqualTypeOf(); + } + { + const stuff = createSignal(1); + const signal = createSerializer$(() => ({ + deserialize: () => (isBrowser ? new Foo(stuff.value) : undefined), + update: (foo) => { + if (foo!.val !== stuff.value) { + return; + } + foo!.update(stuff.value); + return foo; + }, + })); + expectTypeOf(signal).toEqualTypeOf | SerializerSignal>(); + expectTypeOf(signal.value).toEqualTypeOf(); + } + { + const signal = createSerializer$({ + // We have to specify the type here, sadly + deserialize: (data?: number) => { + expectTypeOf(data).toEqualTypeOf(); + return new Foo(); + }, + serialize: (obj) => { + expect(obj).toBeInstanceOf(Foo); + return 1; + }, + }); + expectTypeOf(signal).toEqualTypeOf>(); + expectTypeOf(signal.value).toEqualTypeOf(); + } + { + const signal = createSerializer$({ + deserialize: (data) => { + expectTypeOf(data).toEqualTypeOf(); + return new Foo(); + }, + initial: 3, + serialize: (obj) => { + expect(obj).toBeInstanceOf(Foo); + return 1; + }, + }); + expectTypeOf(signal).toEqualTypeOf>(); + expectTypeOf(signal.value).toEqualTypeOf(); + } + }); +}); + +describe('signal', () => { const log: any[] = []; const delayMap = new Map(); let container: Container = null!; @@ -99,7 +184,6 @@ describe('v2-signal', () => { expect(log).toEqual([12, 23]); }); }); - // using .only because otherwise there's a function-not-the-same issue it('force', () => withContainer(async () => { const obj = { count: 0 }; @@ -120,15 +204,15 @@ describe('v2-signal', () => { expect(log).toEqual([1]); expect(obj.count).toBe(1); // mark dirty but value remains shallow same after calc - (computed as ComputedSignal).$flags$ |= SignalFlags.INVALID; + computed.$flags$ |= SignalFlags.INVALID; computed.value.count; await flushSignals(); expect(log).toEqual([1]); expect(obj.count).toBe(2); - // force recalculation+notify + // force notify computed.force(); await flushSignals(); - expect(log).toEqual([1, 3]); + expect(log).toEqual([1, 2]); })); }); //////////////////////////////////////// diff --git a/packages/qwik/src/core/tests/use-computed.spec.tsx b/packages/qwik/src/core/tests/use-computed.spec.tsx index dafe7a0e81b..264a809c970 100644 --- a/packages/qwik/src/core/tests/use-computed.spec.tsx +++ b/packages/qwik/src/core/tests/use-computed.spec.tsx @@ -17,8 +17,8 @@ import { import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; import { describe, expect, it, vi } from 'vitest'; import { ErrorProvider } from '../../testing/rendering.unit-util'; -import { QError } from '../shared/error/error'; import * as qError from '../shared/error/error'; +import { QError } from '../shared/error/error'; const debug = false; //true; Error.stackTraceLimit = 100; diff --git a/packages/qwik/src/core/tests/use-serialized.spec.tsx b/packages/qwik/src/core/tests/use-serialized.spec.tsx new file mode 100644 index 00000000000..209dc8b9807 --- /dev/null +++ b/packages/qwik/src/core/tests/use-serialized.spec.tsx @@ -0,0 +1,246 @@ +import { + SerializerSymbol, + Fragment, + Fragment as Signal, + Fragment as Component, + component$, + useSignal, +} from '@qwik.dev/core'; +import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; +import { describe, expect, it } from 'vitest'; +import { useSerializer$ } from '../use/use-serializer'; + +const debug = false; //true; +Error.stackTraceLimit = 100; + +// This is almost the same as useComputed, so we only test the custom serialization +describe.each([ + { render: ssrRenderToDom }, // + { render: domRender }, // +])('$render.name: useSerializer$', ({ render }) => { + it('should do custom serialization', async () => { + const Counter = component$(() => { + const myCount = useSerializer$({ + deserialize: (count) => new CustomSerialized(count), + serialize: (data) => data.count, + initial: 2, + }); + const spy = useSignal(myCount.value.count); + return ( + + ); + }); + + const { vNode, container } = await render(, { debug }); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + }); + it('should update reactively', async () => { + const Counter = component$(() => { + const sig = useSignal(1); + const myCount = useSerializer$(() => ({ + deserialize: () => new CustomSerialized(sig.value * 2), + update: (current) => { + current.count = sig.value * 2; + return current; + }, + })); + return ( + + ); + }); + + const { vNode, container } = await render(, { debug }); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + // We need to click again because after SSR the first click will run the deserialize, not the update + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + }); + it('should support [SerializerSymbol]', async () => { + const Counter = component$(() => { + const count = useSerializer$({ + deserialize: (data: number) => new WithSerialize(data), + }); + return ( + + ); + }); + + const { vNode, container } = await render(, { debug }); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + }); + + it('should not crash when used many times', async () => { + // We don't have the Signal type here + const MyComponent = component$(({ foo }: { foo: { value: number } }) => { + const custom = useSerializer$(() => ({ + initial: { bar: 'bar' }, + serialize: (c: Custom) => { + return { foo: c.foo, bar: c.bar }; + }, + deserialize: (d) => new Custom(foo.value, d.bar), + update: (c) => { + c.foo = foo.value; + return c; + }, + })); + + return ( + + ); + }); + + const App = component$(() => { + const foo = useSignal(0); + return ; + }); + + const { vNode, container } = await render(, { debug }); + expect(vNode).toMatchVDOM( + + + + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + + + + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + + + + + + ); + }); +}); + +class CustomSerialized { + constructor(public count = 0) {} + inc() { + this.count++; + } +} + +class WithSerialize { + constructor(public count = 0) {} + inc() { + this.count++; + } + [SerializerSymbol](obj: this) { + return obj.count; + } +} + +class Custom { + constructor( + private _foo: number, + private _bar: string + ) {} + get foo() { + return this._foo; + } + set foo(value) { + this._foo = value; + } + get bar() { + return this._bar; + } + set bar(value) { + this._bar = value; + } +} diff --git a/packages/qwik/src/core/use/use-computed-dollar.ts b/packages/qwik/src/core/use/use-computed-dollar.ts deleted file mode 100644 index 0248eace03a..00000000000 --- a/packages/qwik/src/core/use/use-computed-dollar.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; -import { useComputedQrl } from './use-computed'; - -/** - * Creates a computed signal which is calculated from the given function. A computed signal is a - * signal which is calculated from other signals. When the signals change, the computed signal is - * recalculated, and if the result changed, all tasks which are tracking the signal will be re-run - * and all components that read the signal will be re-rendered. - * - * The function must be synchronous and must not have any side effects. - * - * @public - */ -export const useComputed$ = implicit$FirstArg(useComputedQrl); diff --git a/packages/qwik/src/core/use/use-computed.ts b/packages/qwik/src/core/use/use-computed.ts index fce8e5e093d..0178ad80b12 100644 --- a/packages/qwik/src/core/use/use-computed.ts +++ b/packages/qwik/src/core/use/use-computed.ts @@ -1,22 +1,23 @@ +import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; import { assertQrl } from '../shared/qrl/qrl-utils'; import type { QRL } from '../shared/qrl/qrl.public'; -import { ComputedSignal, throwIfQRLNotResolved } from '../signal/signal'; +import { ComputedSignalImpl, throwIfQRLNotResolved } from '../signal/signal'; import type { ReadonlySignal, Signal } from '../signal/signal.public'; import { useSequentialScope } from './use-sequential-scope'; /** @public */ export type ComputedFn = () => T; -/** @internal */ -export const useComputedQrl = ( - qrl: QRL> +export const useComputedCommon = ( + qrl: QRL>, + Class: typeof ComputedSignalImpl ): T extends Promise ? never : ReadonlySignal => { const { val, set } = useSequentialScope>(); if (val) { return val as any; } assertQrl(qrl); - const signal = new ComputedSignal(null, qrl); + const signal = new Class(null, qrl); set(signal); // Note that we first save the signal @@ -25,3 +26,22 @@ export const useComputedQrl = ( throwIfQRLNotResolved(qrl); return signal as any; }; + +/** @internal */ +export const useComputedQrl = ( + qrl: QRL> +): T extends Promise ? never : ReadonlySignal => { + return useComputedCommon(qrl, ComputedSignalImpl); +}; + +/** + * Creates a computed signal which is calculated from the given function. A computed signal is a + * signal which is calculated from other signals. When the signals change, the computed signal is + * recalculated, and if the result changed, all tasks which are tracking the signal will be re-run + * and all components that read the signal will be re-rendered. + * + * The function must be synchronous and must not have any side effects. + * + * @public + */ +export const useComputed$ = implicit$FirstArg(useComputedQrl); diff --git a/packages/qwik/src/core/use/use-serializer.ts b/packages/qwik/src/core/use/use-serializer.ts new file mode 100644 index 00000000000..15e0ec43b28 --- /dev/null +++ b/packages/qwik/src/core/use/use-serializer.ts @@ -0,0 +1,76 @@ +import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; +import type { QRL } from '../shared/qrl/qrl.public'; +import { + SerializerSignalImpl, + type ComputedSignalImpl, + type SerializerArg, +} from '../signal/signal'; +import type { createSerializer$ } from '../signal/signal.public'; +import { useComputedCommon } from './use-computed'; + +/** @internal */ +export const useSerializerQrl = (qrl: QRL>) => + useComputedCommon(qrl as any, SerializerSignalImpl as typeof ComputedSignalImpl); + +/** + * Creates a signal which holds a custom serializable value. It requires that the value implements + * the `CustomSerializable` type, which means having a function under the `[SerializeSymbol]` + * property that returns a serializable value when called. + * + * The `fn` you pass is called with the result of the serialization (in the browser, only when the + * value is needed), or `undefined` when not yet initialized. If you refer to other signals, `fn` + * will be called when those change just like computed signals, and then the argument will be the + * previous output, not the serialized result. + * + * This is useful when using third party libraries that use custom objects that are not + * serializable. + * + * Note that the `fn` is called lazily, so it won't impact container resume. + * + * @example + * + * ```tsx + * class MyCustomSerializable { + * constructor(public n: number) {} + * inc() { + * this.n++; + * } + * } + * const Cmp = component$(() => { + * const custom = useSerializer$({ + * deserialize: (data) => new MyCustomSerializable(data), + * serialize: (data) => data.n, + * initial: 2, + * }); + * return
custom.value.inc()}>{custom.value.n}
; + * }); + * ``` + * + * @example + * + * When using a Signal as the data to create the object, you need to pass the configuration as a + * function, and you can then also provide the `update` function to update the object when the + * signal changes. + * + * By returning an object from `update`, you signal that the listeners have to be notified. You can + * mutate the current object but you should return it so that it will trigger listeners. + * + * ```tsx + * const Cmp = component$(() => { + * const n = useSignal(2); + * const custom = useSerializer$(() => + * ({ + * deserialize: () => new MyCustomSerializable(n.value), + * update: (current) => { + * current.n = n.value; + * return current; + * } + * }) + * ); + * return
n.value++}>{custom.value.n}
; + * }); + * ``` + * + * @public + */ +export const useSerializer$: typeof createSerializer$ = implicit$FirstArg(useSerializerQrl as any); diff --git a/packages/qwik/src/optimizer/src/api.md b/packages/qwik/src/optimizer/src/api.md index f6db688255a..4a00eb2e97b 100644 --- a/packages/qwik/src/optimizer/src/api.md +++ b/packages/qwik/src/optimizer/src/api.md @@ -26,7 +26,7 @@ export interface Diagnostic { // (undocumented) file: string; // (undocumented) - highlights: SourceLocation[]; + highlights: SourceLocation[] | null; // (undocumented) message: string; // (undocumented) diff --git a/packages/qwik/src/optimizer/src/plugins/rollup.ts b/packages/qwik/src/optimizer/src/plugins/rollup.ts index d1b7b00db78..b47fde1bab2 100644 --- a/packages/qwik/src/optimizer/src/plugins/rollup.ts +++ b/packages/qwik/src/optimizer/src/plugins/rollup.ts @@ -247,11 +247,11 @@ export function normalizeRollupOutputOptionsObject( } export function createRollupError(id: string, diagnostic: Diagnostic) { - const loc = diagnostic.highlights[0] ?? {}; + const loc = diagnostic.highlights?.[0]; const err: Rollup.RollupError = Object.assign(new Error(diagnostic.message), { id, plugin: 'qwik', - loc: { + loc: loc && { column: loc.startCol, line: loc.startLine, }, diff --git a/packages/qwik/src/optimizer/src/types.ts b/packages/qwik/src/optimizer/src/types.ts index b4d64dff8de..35ad531b21a 100644 --- a/packages/qwik/src/optimizer/src/types.ts +++ b/packages/qwik/src/optimizer/src/types.ts @@ -120,7 +120,7 @@ export interface Diagnostic { code: string | null; file: string; message: string; - highlights: SourceLocation[]; + highlights: SourceLocation[] | null; suggestions: string[] | null; } diff --git a/scripts/eslint-docs.ts b/scripts/eslint-docs.ts index c9193915eb5..44849f1939b 100644 --- a/scripts/eslint-docs.ts +++ b/scripts/eslint-docs.ts @@ -3,13 +3,41 @@ import { resolve } from 'path'; import { rules, configs } from '../packages/eslint-plugin-qwik/index'; import { examples, type QwikEslintExample } from '../packages/eslint-plugin-qwik/examples'; -const mdx = []; +const mdx: string[] = []; const outputPathMdx = resolve( process.cwd(), 'packages/docs/src/routes/docs/(qwik)/advanced/eslint/index.mdx' ); +// Function to extract frontmatter from existing file +function extractFrontmatter(filePath: string): string[] { + if (!fs.existsSync(filePath)) { + return []; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + // Check if the file has frontmatter (starts with ---) + if (lines[0] !== '---') { + return []; + } + + // Extract all frontmatter content until the closing --- + const frontmatterLines: string[] = ['---']; + let i = 1; + while (i < lines.length && lines[i] !== '---') { + frontmatterLines.push(lines[i]); + i++; + } + if (i === lines.length) { + return []; + } + frontmatterLines.push('---'); + return frontmatterLines; +} + function escapeHtml(htmlStr: string) { return htmlStr .replace(/&/g, '&') @@ -57,6 +85,12 @@ const rulesMap = Object.keys(rules).map((ruleName) => { }; }); +// Extract frontmatter from existing file if it exists +const frontmatterLines = extractFrontmatter(outputPathMdx); +if (frontmatterLines.length > 0) { + mdx.push(...frontmatterLines); +} + mdx.push(` [//]: <> (--------------------------------------) [//]: <> (......THIS FILE IS AUTOGENERATED......) @@ -64,6 +98,7 @@ mdx.push(` [//]: <> ( to update run: pnpm eslint.update ) [//]: <> ( after changing the rule metadata on ) [//]: <> ( packages/eslint-plugin-qwik/index.ts ) +[//]: <> ( and update the frontmatter if needed ) [//]: <> (--------------------------------------) `);