diff --git a/.changeset/nasty-planes-jam.md b/.changeset/nasty-planes-jam.md new file mode 100644 index 00000000000..02fb8bf5b8d --- /dev/null +++ b/.changeset/nasty-planes-jam.md @@ -0,0 +1,9 @@ +--- +'@qwik.dev/core': minor +--- + +FEAT: `useSerializer$(fn)` and `createSerializer$(fn)` allow serializing custom objects. You must provide a +function that converts the custom object to a serializable one via the `[SerializerSymbol]` +property, and then provide `use|createSerializer$(fn)` with the function that creates the custom object +from the serialized data. This will lazily create the value when needed. Note that the serializer +function may return a Promise, which will be awaited. The deserializer must not return a Promise. diff --git a/.changeset/tricky-peaches-buy.md b/.changeset/tricky-peaches-buy.md new file mode 100644 index 00000000000..5d3bd106966 --- /dev/null +++ b/.changeset/tricky-peaches-buy.md @@ -0,0 +1,8 @@ +--- +'@qwik.dev/core': minor +--- + +FEAT: `NoSerializeSymbol`: objects that have this defined will not be serialized + +FEAT: `SerializerSymbol`: objects that have this defined as a function will get it called with the object as a parameter during serialization. The function should return the data that should be serialized. +Use this to remove cached data, consolidate things 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 3595bd3ec82..d93d5efe514 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -203,7 +203,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" }, @@ -221,6 +221,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", @@ -841,6 +855,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\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", @@ -1541,6 +1569,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 etc.\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", @@ -1873,8 +1915,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" }, { @@ -2003,6 +2045,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 may not need `serialize`. Furthermore, when the signal is updated, the serializer will be updated as well, and the current object will be passed as the second argument.\n\n```tsx\nconst Cmp = component$(() => {\n const n = useSignal(2);\n const custom = useSerializer$((_data, current) => {\n if (current) {\n current.n = n.value;\n return current;\n }\n return new MyCustomSerializable(n.value);\n});\n return
n.value++}>{custom.value.n}
;\n});\n```\n(note that in this example, the `{custom.value.n}` is not reactive, so the div text will not update)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-serialized.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 769e7349aac..13252d1145d 100644 --- a/packages/docs/src/routes/api/qwik/index.md +++ b/packages/docs/src/routes/api/qwik/index.md @@ -603,7 +603,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. @@ -723,6 +723,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`. @@ -1855,6 +1894,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 + +```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 @@ -3549,6 +3598,18 @@ 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 etc. + +```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`. @@ -4394,7 +4455,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 ```
@@ -4416,7 +4477,7 @@ qrl -import("./use-computed").[ComputedFn](#computedfn)<T> +[ComputedFn](#computedfn)<T> @@ -4424,9 +4485,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 @@ -4883,6 +4944,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 may not need `serialize`. Furthermore, when the signal is updated, the serializer will be updated as well, and the current object will be passed as the second argument. + +```tsx +const Cmp = component$(() => { + const n = useSignal(2); + const custom = useSerializer$((_data, current) => { + if (current) { + current.n = n.value; + return current; + } + return new MyCustomSerializable(n.value); + }); + return
n.value++}>{custom.value.n}
; +}); +``` + +(note that in this example, the `{custom.value.n}` is not reactive, so the div text will not update) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-serialized.ts) + ## useServerData ```typescript diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts index 0208f05569b..8c931183e4d 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, @@ -60,6 +61,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 95487f0d77a..bcc193cfc99 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 "ComputedSignal_2" 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>) => ComputedSignal_2; // @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 "SerializerSignal_2" 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; +}>) => SerializerSignal_2; + // @public export const createSignal: { (): Signal; @@ -498,6 +515,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; @@ -811,6 +831,9 @@ export const _restProps: (props: Record, omit: string[], target?: { // @internal export function _serialize(data: unknown[]): Promise; +// @public +export const SerializerSymbol: unique symbol; + // @public export const setPlatform: (plt: CorePlatform) => CorePlatform; @@ -1086,6 +1109,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/index.ts b/packages/qwik/src/core/index.ts index 9ae8ab7acf2..31e6256a233 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -110,6 +110,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-serialized'; export type { OnVisibleTaskOptions, VisibleTaskStrategy } from './use/use-visible-task'; export { useVisibleTaskQrl } from './use/use-visible-task'; export type { EagernessOptions, TaskCtx, TaskFn, Tracker, UseTaskOptions } from './use/use-task'; @@ -128,18 +129,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 { EffectPropData 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 409a03042f4..9ad201bcf8c 100644 --- a/packages/qwik/src/core/shared/error/error.ts +++ b/packages/qwik/src/core/shared/error/error.ts @@ -56,6 +56,7 @@ export const codeToText = (code: number, ...parts: any[]): string => { 'WrappedSignal is read-only', // 49 'SsrError: Promises not expected here.', // 50 'Attribute value is unsafe for SSR', // 51 + 'SerializerSymbol function returned rejected promise', // 52 ]; let text = MAP[code] ?? ''; if (parts.length) { @@ -126,6 +127,7 @@ export const enum QError { wrappedReadOnly = 48, promisesNotExpected = 49, unsafeAttr = 50, + serializerSymbolRejectedPromise = 52, } export const qError = (code: number, errorMessageArgs: any[] = []): Error => { diff --git a/packages/qwik/src/core/shared/serialization.md b/packages/qwik/src/core/shared/serialization.md index 3a136193f31..9cb3d414239 100644 --- a/packages/qwik/src/core/shared/serialization.md +++ b/packages/qwik/src/core/shared/serialization.md @@ -37,3 +37,17 @@ To avoid blocking the main thread on wake, we lazily restore the roots, with cac The serialized text is first parsed to get an array of encoded root data. Then, a proxy gets the raw data and returns an array that deserializes properties on demand and caches them. Objects are also lazily restored. + +## Out-of-order streaming + +when we allow out-of-order streaming but we want interactivity while the page is still streaming, we can't scan the state once for cycles/Promises/references, because later state might refer to earlier state. + +Therefore we must make the serializer single-pass. To do this, we could store each object as a root, but that will make references take more bytes. Instead, we could implement sub-paths for references, and when we encounter a reference, we output that instead. + +For later references it would be best to have a root reference, so we'd probably add a root for the sub-reference and later we can refer to that. + +We'll still need to keep track of Promises. We can do this by waiting, but that halts streaming. Instead, we could write a forward reference id, and when the promise is resolved, we store the result as the next root item. At the end of the stream we can emit a mapping from forward references to root index. + +Then later, when we send out-of-order state, it will append to the existing state on the client. + +This single-pass approach might just be better in general, because several bugs were found due to differences between the first and second pass code, and it allows streaming the data while the promises are being resolved. diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 2f41639f106..6e77c6b0a09 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -8,7 +8,16 @@ import { type DomContainer } from '../client/dom-container'; import type { VNode } from '../client/types'; import { vnode_getNode, vnode_isVNode, vnode_locate, vnode_toString } from '../client/vnode'; import { NEEDS_COMPUTATION } from '../signal/flags'; -import { ComputedSignal, EffectPropData, Signal, WrappedSignal } from '../signal/signal'; +import { + ComputedSignal, + EffectPropData, + SerializerSignal, + Signal, + WrappedSignal, + isSerializerObj, + type EffectSubscriptions, + type SerializerArg, +} from '../signal/signal'; import type { Subscriber } from '../signal/signal-subscriber'; import { STORE_ARRAY_PROP, @@ -48,7 +57,7 @@ import { isElement, isNode } from './utils/element'; import { EMPTY_ARRAY, EMPTY_OBJ } from './utils/flyweight'; import { ELEMENT_ID, ELEMENT_KEY } 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(); @@ -283,13 +292,17 @@ const inflate = ( signal.$effects$ = d.slice(5); 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 d = data as [QRLInternal<() => {}>, EffectSubscriptions[] | null, unknown?]; computed.$computeQrl$ = d[0]; computed.$effects$ = d[1]; - if (d.length === 3) { + if (d.length >= 3) { computed.$untrackedValue$ = d[2]; + // The serialized signal is always invalid so it can recreate the custom object + computed.$invalid$ = typeId === TypeIds.SerializerSignal; } else { computed.$invalid$ = true; /** @@ -478,6 +491,8 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow return new WrappedSignal(container as any, null!, null!, null!); case TypeIds.ComputedSignal: return new ComputedSignal(container as any, null!); + case TypeIds.SerializerSignal: + return new SerializerSignal(container as any, null!); case TypeIds.Store: case TypeIds.StoreArray: // ignore allocate, we need to assign target while creating store @@ -828,11 +843,31 @@ export const createSerializationContext = ( const v = obj instanceof WrappedSignal ? obj.untrackedValue - : obj instanceof ComputedSignal && (obj.$invalid$ || fastSkipSerialize(obj)) + : obj instanceof ComputedSignal && + !(obj instanceof SerializerSignal) && + (obj.$invalid$ || fastSkipSerialize(obj)) ? NEEDS_COMPUTATION : obj.$untrackedValue$; if (v !== NEEDS_COMPUTATION) { - discoveredValues.push(v); + if (obj instanceof SerializerSignal) { + promises.push( + (obj.$computeQrl$ as any as QRLInternal>) + .resolve() + .then((arg) => { + let data; + if ((arg as any).serialize) { + data = (arg as any).serialize(v); + } + if (data === undefined) { + data = NEEDS_COMPUTATION; + } + serializationResults.set(obj, data); + discoveredValues.push(data); + }) + ); + } else { + discoveredValues.push(v); + } } if (obj.$effects$) { discoveredValues.push(...obj.$effects$); @@ -865,8 +900,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)) { @@ -885,6 +918,12 @@ export const createSerializationContext = ( promises.push(obj); } else if (obj instanceof EffectPropData) { 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); @@ -941,6 +980,7 @@ const discoverValuesForVNodeData = (vnodeData: VNodeData, discoveredValues: unkn }; const promiseResults = new WeakMap, [boolean, unknown]>(); +const serializationResults = new WeakMap(); /** * Format: @@ -1040,13 +1080,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') { @@ -1135,6 +1173,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); @@ -1159,7 +1211,9 @@ function serialize(serializationContext: SerializationContext): void { * 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 = + const isSerialized = value instanceof SerializerSignal; + const v: unknown = + !isSerialized && value instanceof ComputedSignal && (value.$invalid$ || fastSkipSerialize(value.$untrackedValue$)) ? NEEDS_COMPUTATION @@ -1174,15 +1228,19 @@ function serialize(serializationContext: SerializationContext): void { ...(value.$effects$ || []), ]); } else if (value instanceof ComputedSignal) { - const out = [ + const out: [QRLInternal, EffectSubscriptions[] | 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$ || [])]); } @@ -1622,6 +1680,7 @@ export const enum TypeIds { Signal, WrappedSignal, ComputedSignal, + SerializerSignal, Store, StoreArray, FormData, @@ -1655,6 +1714,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 d4bb7812df8..58bc08c929f 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 { EffectPropData, type Signal } from '../signal/signal'; -import { createComputed$, createSignal, isSignal } from '../signal/signal.public'; +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'; @@ -17,6 +22,7 @@ import { dumpState, } from './shared-serialization'; import { EMPTY_ARRAY, EMPTY_OBJ } from './utils/flyweight'; +import { NoSerializeSymbol, SerializerSymbol } from './utils/serialize-utils'; const DEBUG = false; @@ -404,6 +410,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(` @@ -421,6 +446,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)); @@ -608,6 +651,7 @@ describe('shared-serialization', () => { }); 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)); @@ -615,6 +659,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)); @@ -780,6 +825,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('Q52'); + expect(consoleSpy).toHaveBeenCalledWith('oh no'); + consoleSpy.mockRestore(); + }); }); async function serialize(...roots: any[]): Promise { @@ -802,3 +926,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 1208bb816e9..020a4cb73d5 100644 --- a/packages/qwik/src/core/shared/utils/serialize-utils.ts +++ b/packages/qwik/src/core/shared/utils/serialize-utils.ts @@ -93,7 +93,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 => { @@ -140,3 +140,19 @@ export const _weakSerialize = (input: T): Partial => { weakSerializeSet.add(input); return input as any; }; + +/** + * If an object has this property, it will not be serialized + * + * @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 etc. + * + * @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..ef6ff3ef88e 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 { + ComputedSignal, + SerializerSignal, + Signal as 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>): ComputedSignal => { throwIfQRLNotResolved(qrl); - return new ComputedSignal(null, qrl as QRLInternal<() => T>); + return new ComputedSignal(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 SerializerSignal(null, arg as any as QRLInternal>); }; diff --git a/packages/qwik/src/core/signal/signal.public.ts b/packages/qwik/src/core/signal/signal.public.ts index a03bf31519d..b52ef1a99bb 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,13 @@ 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 {} + /** * Creates a Signal with the given value. If no value is given, the signal is created with * `undefined`. @@ -54,21 +62,35 @@ 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$: ( + // We want to also add T as a possible parameter type, but that breaks type inference + 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 9cfdf15d09d..15a3983bad5 100644 --- a/packages/qwik/src/core/signal/signal.ts +++ b/packages/qwik/src/core/signal/signal.ts @@ -11,27 +11,28 @@ * - It needs to store a function which needs to re-run. * - It is `Readonly` because it is computed. */ +import type { VNode } from '../client/types'; +import { vnode_getProp, vnode_isTextVNode, vnode_isVNode, vnode_setProp } from '../client/vnode'; 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 { Task, TaskFlags, isTask } from '../use/use-task'; +import { ChoreType, type NodePropData, type NodePropPayload } from '../shared/scheduler'; +import type { Container, HostElement } from '../shared/types'; import { ELEMENT_PROPS, OnRenderProp, QSubscribers } from '../shared/utils/markers'; import { isPromise } from '../shared/utils/promises'; import { qDev } from '../shared/utils/qdev'; -import type { VNode } from '../client/types'; -import { vnode_getProp, vnode_isTextVNode, vnode_isVNode, vnode_setProp } from '../client/vnode'; -import { ChoreType, type NodePropData, type NodePropPayload } from '../shared/scheduler'; -import type { Container, HostElement } from '../shared/types'; +import { SerializerSymbol } from '../shared/utils/serialize-utils'; import type { ISsrNode } from '../ssr/ssr-types'; +import { trackSignal, tryGetInvokeContext } from '../use/use-core'; +import { Task, TaskFlags, isTask } from '../use/use-task'; +import { NEEDS_COMPUTATION } from './flags'; +import { Subscriber, isSubscriber } from './signal-subscriber'; import type { Signal as ISignal, ReadonlySignal } from './signal.public'; import type { TargetType } from './store'; -import { isSubscriber, Subscriber } from './signal-subscriber'; -import type { Props } from '../shared/jsx/jsx-runtime'; -import type { OnRenderFn } from '../shared/component.public'; -import { NEEDS_COMPUTATION } from './flags'; -import { QError, qError } from '../shared/error/error'; const DEBUG = false; @@ -47,7 +48,7 @@ export interface InternalSignal extends InternalReadonlySignal { untrackedValue: T; } -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 @@ -205,7 +206,6 @@ export class Signal implements ISignal { } return this.untrackedValue; } - set value(value) { if (value !== this.$untrackedValue$) { DEBUG && @@ -374,6 +374,8 @@ export const triggerEffects = ( DEBUG && log('done scheduling'); }; +type ComputeQRL = QRLInternal<() => T>; + /** * A signal which is computed from other signals. * @@ -386,13 +388,13 @@ export class ComputedSignal extends Signal { * 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; // 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 $invalid$: boolean = true; $forceRunEffects$: boolean = false; - constructor(container: Container | null, fn: QRLInternal<() => T>) { + constructor(container: Container | null, fn: ComputeQRL) { // The value is used for comparison when signals trigger, which can only happen // when it was calculated before. Therefore we can pass whatever we like. super(container, NEEDS_COMPUTATION); @@ -413,6 +415,7 @@ export class ComputedSignal extends Signal { */ force() { this.$invalid$ = true; + // TODO shouldn't force be set to true, invalid left alone and the effects scheduled? this.$forceRunEffects$ = false; triggerEffects(this.$container$, this, this.$effects$); } @@ -459,14 +462,14 @@ export class ComputedSignal extends Signal { } } - // 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 Subscriber { @@ -540,13 +543,105 @@ export class WrappedSignal extends Signal implements Subscriber { } 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); +/** + * Serialize and deserialize custom objects. + * + * If you pass a function, it will be used as the `deserialize` function. + * + * @public + */ +export type SerializerArg = + | { + /** + * This function will be called with serialized data to reconstruct an object. + * + * If it is created for the first time, it will get the `initial` data or `undefined`. + * + * If it uses signals or stores, it will be called when these change, and then the second + * argument will be the previously constructed object. + * + * This function must not return a promise. + */ + deserialize: (data: S | undefined, previous: T | undefined) => T; + /** + * This function will be called with the custom 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`. + */ + serialize?: (customObject: T) => S | Promise; + /** The initial value to use when deserializing. */ + initial?: S; + } + | ((data: S | undefined, previous: T | undefined) => T); + +/** + * 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 SerializerSignal extends ComputedSignal { + constructor(container: Container | null, argQrl: QRLInternal>) { + super(container, argQrl as unknown as ComputeQRL); + } + $didInitialize$: boolean = false; + + $computeIfNeeded$(): boolean { + if (!this.$invalid$) { + return false; + } + throwIfQRLNotResolved(this.$computeQrl$); + const arg = (this.$computeQrl$ as any as QRLInternal>).resolved!; + let deserialize, initial; + if (typeof arg === 'function') { + deserialize = arg; + } else { + deserialize = arg.deserialize; + initial = arg.initial; + } + const currentValue = + this.$untrackedValue$ === NEEDS_COMPUTATION ? initial : this.$untrackedValue$; + const untrackedValue = trackSignal( + () => + this.$didInitialize$ + ? deserialize(undefined, currentValue as T) + : deserialize(currentValue as S, undefined), + this, + EffectProperty.VNODE, + this.$container$! + ); + DEBUG && log('SerializerSignal.$compute$', untrackedValue); + this.$invalid$ = false; + const didChange = untrackedValue !== this.$untrackedValue$; + if (didChange) { + this.$untrackedValue$ = untrackedValue; + } + 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 c0bb23cfa6f..942f7dcc8f5 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 { 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'; @@ -17,9 +17,81 @@ import { 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'; + +class Foo {} + +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 signal = createSerializer$(() => new Foo()); + expectTypeOf(signal).toEqualTypeOf>(); + expectTypeOf(signal.value).toEqualTypeOf(); + } + { + const signal = createSerializer$({ + deserialize: (data, prev) => { + expectTypeOf(data).toEqualTypeOf(); + expectTypeOf(prev).toEqualTypeOf(); + return new Foo(); + }, + serialize: (obj) => { + expect(obj).toBeInstanceOf(Foo); + return 1; + }, + }); + expectTypeOf(signal).toEqualTypeOf>(); + expectTypeOf(signal.value).toEqualTypeOf(); + } + { + const signal = createSerializer$({ + deserialize: (data, prev) => { + expectTypeOf(data).toEqualTypeOf(); + expectTypeOf(prev).toEqualTypeOf(); + return new Foo(); + }, + // you only have to specify the type on the serialize function + serialize: (obj: Foo) => { + expect(obj).toBeInstanceOf(Foo); + return 1; + }, + }); + expectTypeOf(signal).toEqualTypeOf>(); + expectTypeOf(signal.value).toEqualTypeOf(); + } + }); +}); -describe('v2-signal', () => { +describe('signal', () => { const log: any[] = []; const delayMap = new Map(); let container: Container = null!; 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..9c4ea50cc12 --- /dev/null +++ b/packages/qwik/src/core/tests/use-serialized.spec.tsx @@ -0,0 +1,92 @@ +import { Fragment as Signal, 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-serialized'; + +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?: number) => 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(2); + const myCount = useSerializer$(() => new CustomSerialized(sig.value)); + 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( + <> + + + ); + }); +}); + +class CustomSerialized { + constructor(public count = 0) {} + inc() { + this.count++; + } +} 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 9b3d3309af5..ebb485f1755 100644 --- a/packages/qwik/src/core/use/use-computed.ts +++ b/packages/qwik/src/core/use/use-computed.ts @@ -1,3 +1,4 @@ +import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; import { assertQrl } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { ComputedSignal, throwIfQRLNotResolved } from '../signal/signal'; @@ -7,16 +8,16 @@ import { useSequentialScope } from './use-sequential-scope'; /** @public */ export type ComputedFn = () => T; -/** @internal */ -export const useComputedQrl = ( - qrl: QRL> +export const useComputedCommon = ( + qrl: QRL>, + Class: typeof ComputedSignal ): 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, ComputedSignal); +}; + +/** + * 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-serialized.ts b/packages/qwik/src/core/use/use-serialized.ts new file mode 100644 index 00000000000..198fe68ed44 --- /dev/null +++ b/packages/qwik/src/core/use/use-serialized.ts @@ -0,0 +1,74 @@ +import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; +import type { QRL } from '../shared/qrl/qrl.public'; +import { + SerializerSignal as SerializerSignalImpl, + type ComputedSignal, + 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 ComputedSignal); + +/** + * 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 may not need `serialize`. Furthermore, + * when the signal is updated, the serializer will be updated as well, and the current object will + * be passed as the second argument. + * + * ```tsx + * const Cmp = component$(() => { + * const n = useSignal(2); + * const custom = useSerializer$((_data, current) => { + * if (current) { + * current.n = n.value; + * return current; + * } + * return new MyCustomSerializable(n.value); + * }); + * return
n.value++}>{custom.value.n}
; + * }); + * ``` + * + * (note that in this example, the `{custom.value.n}` is not reactive, so the div text will not + * update) + * + * @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 2a1ea9ab202..8f7dfb59626 100644 --- a/packages/qwik/src/optimizer/src/plugins/rollup.ts +++ b/packages/qwik/src/optimizer/src/plugins/rollup.ts @@ -246,11 +246,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; }