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
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\nParameter\n\n\n
\n\nType\n\n\n
\n\nDescription\n\n\n
\n
\n\nqrl\n\n\n
\n\n() => T\n\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\nParameter\n\n\n
\n\nType\n\n\n
\n\nDescription\n\n\n
\n
\n\nqrl\n\n\n
\n\n() => T\n\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**Returns:**\n\nvalue is [ISignal](#signal)<unknown>",
+ "content": "```typescript\nisSignal: (value: any) => value is Signal\n```\n\n\n
\n\nParameter\n\n\n
\n\nType\n\n\n
\n\nDescription\n\n\n
\n
\n\nvalue\n\n\n
\n\nany\n\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**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\nParameter\n\n\n
\n\nType\n\n\n
\n\nDescription\n\n\n
\n
\n\nqrl\n\n\n
\n\n[ComputedFn](#computedfn)<T>\n\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
```
**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
\ 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