From 804f01d832753e2a014071b466de4a0cb9159043 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Tue, 28 Jan 2025 00:30:27 +0100 Subject: [PATCH] chore(serdes): rename serialize(d=>r), new api --- .changeset/nasty-planes-jam.md | 4 +- packages/docs/src/routes/api/qwik/api.json | 24 ++-- packages/docs/src/routes/api/qwik/index.md | 50 +++++--- packages/qwik/public.d.ts | 4 +- packages/qwik/src/core/api.md | 25 ++-- packages/qwik/src/core/index.ts | 6 +- .../qwik/src/core/shared/serialization.md | 14 +++ .../src/core/shared/shared-serialization.ts | 53 +++++--- .../core/shared/shared-serialization.unit.ts | 17 +-- packages/qwik/src/core/signal/signal-api.ts | 17 +-- .../qwik/src/core/signal/signal.public.ts | 25 ++-- packages/qwik/src/core/signal/signal.ts | 116 +++++++++++++----- packages/qwik/src/core/signal/signal.unit.tsx | 68 ++++++---- .../src/core/tests/use-serialized.spec.tsx | 48 ++++++-- packages/qwik/src/core/use/use-serialized.ts | 47 +++++-- 15 files changed, 345 insertions(+), 173 deletions(-) diff --git a/.changeset/nasty-planes-jam.md b/.changeset/nasty-planes-jam.md index 2f467df3db7..02fb8bf5b8d 100644 --- a/.changeset/nasty-planes-jam.md +++ b/.changeset/nasty-planes-jam.md @@ -2,8 +2,8 @@ '@qwik.dev/core': minor --- -FEAT: `useSerialized$(fn)` and `createSerialized$(fn)` allow serializing custom objects. You must provide a +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|createSerialized$(fn)` with the function that creates the custom object +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/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index fec819df56e..d93d5efe514 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -222,18 +222,18 @@ "mdFile": "core.createcontextid.md" }, { - "name": "createSerialized$", - "id": "createserialized_", + "name": "createSerializer$", + "id": "createserializer_", "hierarchy": [ { - "name": "createSerialized$", - "id": "createserialized_" + "name": "createSerializer$", + "id": "createserializer_" } ], "kind": "Function", - "content": "Create a signal that holds a custom serializable value. See [useSerialized$](#useserialized_) for more details.\n\n\n```typescript\ncreateSerialized$: , S = T extends {\n [SerializerSymbol]: (obj: any) => infer U;\n} ? U : unknown>(qrl: (data: S | undefined) => T) => T extends Promise ? never : SerializedSignal\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(data: S \\| undefined) => T\n\n\n\n\n\n
\n**Returns:**\n\nT extends Promise<any> ? never : SerializedSignal<T>", + "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.createserialized_.md" + "mdFile": "core.createserializer_.md" }, { "name": "createSignal", @@ -2046,18 +2046,18 @@ "mdFile": "core.useresource_.md" }, { - "name": "useSerialized$", - "id": "useserialized_", + "name": "useSerializer$", + "id": "useserializer_", "hierarchy": [ { - "name": "useSerialized$", - "id": "useserialized_" + "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\nuseSerialized$: typeof createSerialized$\n```\n\n\n\n```tsx\nclass MyCustomSerializable {\n constructor(public n: number) {}\n inc() {\n this.n++;\n }\n [SerializeSymbol]() {\n return this.n;\n }\n}\nconst Cmp = component$(() => {\n const custom = useSerialized$(\n (prev) =>\n new MyCustomSerializable(prev instanceof MyCustomSerializable ? prev : (prev ?? 3))\n );\n return
custom.value.inc()}>{custom.value.n}
;\n});\n```", + "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.useserialized_.md" + "mdFile": "core.useserializer_.md" }, { "name": "useServerData", diff --git a/packages/docs/src/routes/api/qwik/index.md b/packages/docs/src/routes/api/qwik/index.md index 0c99a78ca7b..13252d1145d 100644 --- a/packages/docs/src/routes/api/qwik/index.md +++ b/packages/docs/src/routes/api/qwik/index.md @@ -723,14 +723,12 @@ The name of the context. [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-context.ts) -## createSerialized$ +## createSerializer$ -Create a signal that holds a custom serializable value. See [useSerialized$](#useserialized_) for more details. +Create a signal that holds a custom serializable value. See [useSerializer$](#useserializer_) for more details. ```typescript -createSerialized$: , S = T extends { - [SerializerSymbol]: (obj: any) => infer U; -} ? U : unknown>(qrl: (data: S | undefined) => T) => T extends Promise ? never : SerializedSignal +createSerializer$: (arg: SerializerArg) => T extends Promise ? never : SerializerSignal ```
@@ -748,11 +746,11 @@ Description
-qrl +arg -(data: S \| undefined) => T +SerializerArg<T, S> @@ -760,7 +758,7 @@ qrl
**Returns:** -T extends Promise<any> ? never : SerializedSignal<T> +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) @@ -4946,7 +4944,7 @@ _(Optional)_ [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-resource-dollar.ts) -## useSerialized$ +## 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. @@ -4957,7 +4955,7 @@ This is useful when using third party libraries that use custom objects that are Note that the `fn` is called lazily, so it won't impact container resume. ```typescript -useSerialized$: typeof createSerialized$; +useSerializer$: typeof createSerializer$; ``` ```tsx @@ -4966,21 +4964,35 @@ class MyCustomSerializable { inc() { this.n++; } - [SerializeSymbol]() { - return this.n; - } } const Cmp = component$(() => { - const custom = useSerialized$( - (prev) => - new MyCustomSerializable( - prev instanceof MyCustomSerializable ? prev : (prev ?? 3), - ), - ); + 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 diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts index 50ad8d46ff2..8c931183e4d 100644 --- a/packages/qwik/public.d.ts +++ b/packages/qwik/public.d.ts @@ -6,7 +6,7 @@ export { ComputedSignal, ContextId, createComputed$, - createSerialized$, + createSerializer$, createContextId, createSignal, CSSProperties, @@ -61,7 +61,7 @@ export { useOnDocument, useOnWindow, useResource$, - useSerialized$, + useSerializer$, useServerData, useSignal, useStore, diff --git a/packages/qwik/src/core/api.md b/packages/qwik/src/core/api.md index 3f78dbaaae1..bcc193cfc99 100644 --- a/packages/qwik/src/core/api.md +++ b/packages/qwik/src/core/api.md @@ -120,20 +120,21 @@ export const createComputedQrl: (qrl: QRL<() => T>) => ComputedSignal_2; // @public export const createContextId: (name: string) => ContextId; -// Warning: (ae-forgotten-export) The symbol "CustomSerializable" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "SerializedSignal" needs to be exported by the entry point index.d.ts +// 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 createSerialized$: , S = T extends { - [SerializerSymbol]: (obj: any) => infer U; -} ? U : unknown>(qrl: (data: S | undefined) => T) => T extends Promise ? never : SerializedSignal; +export const createSerializer$: (arg: SerializerArg) => T extends Promise ? never : SerializerSignal; -// Warning: (ae-forgotten-export) The symbol "ConstructorFn" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "SerializedSignal_2" needs to be exported by the entry point index.d.ts -// Warning: (ae-internal-missing-underscore) The name "createSerializedQrl" should be prefixed with an underscore because the declaration is marked as @internal +// 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 createSerializedQrl: , S>(qrl: QRL>) => SerializedSignal_2; +export const createSerializerQrl: (arg: QRL<{ + serialize: (data: S | undefined) => T; + deserialize: (data: T) => S; + initial?: S; +}>) => SerializerSignal_2; // @public export const createSignal: { @@ -1109,12 +1110,12 @@ export const useResource$: (generatorFn: ResourceFn, opts?: ResourceOption export const useResourceQrl: (qrl: QRL>, opts?: ResourceOptions) => ResourceReturn; // @public -export const useSerialized$: typeof createSerialized$; +export const useSerializer$: typeof createSerializer$; -// Warning: (ae-internal-missing-underscore) The name "useSerializedQrl" should be prefixed with an underscore because the declaration is marked as @internal +// 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 useSerializedQrl: >(qrl: QRL) => ReadonlySignal; +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 f672f399180..31e6256a233 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -110,7 +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 { useSerializedQrl, useSerialized$ } from './use/use-serialized'; +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'; @@ -138,8 +138,8 @@ export { createSignal, createComputedQrl, createComputed$, - createSerializedQrl, - createSerialized$, + createSerializerQrl, + createSerializer$, } from './signal/signal.public'; export { EffectPropData as _EffectData } from './signal/signal'; 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 a4e4ee0002f..6e77c6b0a09 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -11,11 +11,12 @@ import { NEEDS_COMPUTATION } from '../signal/flags'; import { ComputedSignal, EffectPropData, - SerializedSignal, + SerializerSignal, Signal, WrappedSignal, isSerializerObj, type EffectSubscriptions, + type SerializerArg, } from '../signal/signal'; import type { Subscriber } from '../signal/signal-subscriber'; import { @@ -291,7 +292,8 @@ const inflate = ( signal.$effects$ = d.slice(5); break; } - case TypeIds.SerializedSignal: + // 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<() => {}>, EffectSubscriptions[] | null, unknown?]; @@ -300,7 +302,7 @@ const inflate = ( 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.SerializedSignal; + computed.$invalid$ = typeId === TypeIds.SerializerSignal; } else { computed.$invalid$ = true; /** @@ -489,8 +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.SerializedSignal: - return new SerializedSignal(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 @@ -841,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$); @@ -1189,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 isSerialized = value instanceof SerializerSignal; const v: unknown = + !isSerialized && value instanceof ComputedSignal && (value.$invalid$ || fastSkipSerialize(value.$untrackedValue$)) ? NEEDS_COMPUTATION @@ -1210,12 +1234,13 @@ function serialize(serializationContext: SerializationContext): void { value.$effects$, ]; if (v !== NEEDS_COMPUTATION) { - out.push(v); + if (isSerialized) { + out.push(serializationResults.get(value)); + } else { + out.push(v); + } } - output( - value instanceof SerializedSignal ? TypeIds.SerializedSignal : TypeIds.ComputedSignal, - out - ); + output(isSerialized ? TypeIds.SerializerSignal : TypeIds.ComputedSignal, out); } else { output(TypeIds.Signal, [v, ...(value.$effects$ || [])]); } @@ -1655,7 +1680,7 @@ export const enum TypeIds { Signal, WrappedSignal, ComputedSignal, - SerializedSignal, + SerializerSignal, Store, StoreArray, FormData, @@ -1689,7 +1714,7 @@ export const _typeIdNames = [ 'Signal', 'WrappedSignal', 'ComputedSignal', - 'SerializedSignal', + '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 81631e68173..58bc08c929f 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -4,7 +4,7 @@ import { _fnSignal, _wrapProp } from '../internal'; import { EffectPropData, type Signal } from '../signal/signal'; import { createComputed$, - createSerialized$, + createSerializer$, createSignal, isSignal, } from '../signal/signal.public'; @@ -410,21 +410,22 @@ describe('shared-serialization', () => { (186 chars)" `); }); - it(title(TypeIds.SerializedSignal), async () => { - const custom = createSerialized$( - (prev) => new MyCustomSerializable((prev as number) || 3) - ); + 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 SerializedSignal [ + 0 SerializerSignal [ QRL 1 Constant null Number 4 ] - 1 String "mock-chunk#describe_describe_it_custom_createSerialized_RQFR5EU0bpE" + 1 String "mock-chunk#describe_describe_it_custom_createSerializer_CZt5uiK9L0Y" (91 chars)" `); }); @@ -650,7 +651,7 @@ describe('shared-serialization', () => { }); it.todo(title(TypeIds.WrappedSignal)); it.todo(title(TypeIds.ComputedSignal)); - it.todo(title(TypeIds.SerializedSignal)); + 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)); diff --git a/packages/qwik/src/core/signal/signal-api.ts b/packages/qwik/src/core/signal/signal-api.ts index e78b9067b62..ef6ff3ef88e 100644 --- a/packages/qwik/src/core/signal/signal-api.ts +++ b/packages/qwik/src/core/signal/signal-api.ts @@ -2,11 +2,10 @@ import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { ComputedSignal, - SerializedSignal, + SerializerSignal, Signal as SignalImpl, throwIfQRLNotResolved, - type ConstructorFn, - type CustomSerializable, + type SerializerArg, } from './signal'; import type { Signal } from './signal.public'; @@ -22,9 +21,13 @@ export const createComputedSignal = (qrl: QRL<() => T>): ComputedSignal => }; /** @internal */ -export const createSerializedSignal = , S>( - qrl: QRL> +export const createSerializerSignal = ( + arg: QRL<{ + serialize: (data: S | undefined) => T; + deserialize: (data: T) => S; + initial?: S; + }> ) => { - throwIfQRLNotResolved(qrl); - return new SerializedSignal(null, qrl); + 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 97e35575933..b52ef1a99bb 100644 --- a/packages/qwik/src/core/signal/signal.public.ts +++ b/packages/qwik/src/core/signal/signal.public.ts @@ -1,10 +1,9 @@ import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; -import { SerializerSymbol } from '../shared/utils/serialize-utils'; -import type { CustomSerializable } from './signal'; +import type { SerializerArg } from './signal'; import { createSignal as _createSignal, createComputedSignal as createComputedQrl, - createSerializedSignal as createSerializedQrl, + createSerializerSignal as createSerializerQrl, } from './signal-api'; export { isSignal } from './signal'; @@ -46,11 +45,11 @@ export interface ComputedSignal extends ReadonlySignal { } /** - * A serialized signal holds a custom serializable value. See `useSerialized$` for more details. + * A serializer signal holds a custom serializable value. See `useSerializer$` for more details. * * @public */ -export interface SerializedSignal extends ComputedSignal {} +export interface SerializerSignal extends ComputedSignal {} /** * Creates a Signal with the given value. If no value is given, the signal is created with @@ -83,19 +82,15 @@ export const createComputed$: ( export { createComputedQrl }; /** - * Create a signal that holds a custom serializable value. See {@link useSerialized$} for more + * Create a signal that holds a custom serializable value. See {@link useSerializer$} for more * details. * * @public */ -export const createSerialized$: < - T extends CustomSerializable, - S = T extends { [SerializerSymbol]: (obj: any) => infer U } ? U : unknown, ->( +export const createSerializer$: ( // We want to also add T as a possible parameter type, but that breaks type inference - // The - qrl: (data: S | undefined) => T -) => T extends Promise ? never : SerializedSignal = implicit$FirstArg( - createSerializedQrl as any + arg: SerializerArg +) => T extends Promise ? never : SerializerSignal = implicit$FirstArg( + createSerializerQrl as any ); -export { createSerializedQrl }; +export { createSerializerQrl }; diff --git a/packages/qwik/src/core/signal/signal.ts b/packages/qwik/src/core/signal/signal.ts index 96dff9a3db7..15a3983bad5 100644 --- a/packages/qwik/src/core/signal/signal.ts +++ b/packages/qwik/src/core/signal/signal.ts @@ -11,28 +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'; -import { SerializerSymbol } from '../shared/utils/serialize-utils'; const DEBUG = false; @@ -374,7 +374,7 @@ export const triggerEffects = ( DEBUG && log('done scheduling'); }; -type ComputeQRL = QRLInternal<(prev: T | undefined) => T>; +type ComputeQRL = QRLInternal<() => T>; /** * A signal which is computed from other signals. @@ -415,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$); } @@ -439,9 +440,7 @@ export class ComputedSignal extends Signal { const previousEffectSubscription = ctx?.$effectSubscriber$; ctx && (ctx.$effectSubscriber$ = [this, EffectProperty.VNODE]); try { - const untrackedValue = computeQrl.getFn(ctx)( - this.$untrackedValue$ === NEEDS_COMPUTATION ? undefined : this.$untrackedValue$ - ) as T; + const untrackedValue = computeQrl.getFn(ctx)() as T; if (isPromise(untrackedValue)) { throw qError(QError.computedNotSync, [ computeQrl.dev ? computeQrl.dev.file : '', @@ -554,24 +553,39 @@ export class WrappedSignal extends Signal implements Subscriber { } } -export type CustomSerializable any }, S> = { - [SerializerSymbol]: (obj: T) => S; -}; /** - * Called with serialized data to reconstruct an object. If it uses signals or stores, it will be - * called when these change, and then the argument will be the previously constructed object. - * - * The constructed object should provide a `[SerializerSymbol]` method which provides the serialized - * data. + * Serialize and deserialize custom objects. * - * This function may not return a promise. + * If you pass a function, it will be used as the `deserialize` function. * * @public */ -export type ConstructorFn< - T extends CustomSerializable, - S = ReturnType, -> = ((data: S | undefined) => T) | ((data: S | undefined | T) => T); +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 @@ -579,12 +593,50 @@ export type ConstructorFn< * * @public */ -export class SerializedSignal> extends ComputedSignal { - constructor(container: Container | null, fn: QRL>) { - super(container, fn as unknown as ComputeQRL); +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 diff --git a/packages/qwik/src/core/signal/signal.unit.tsx b/packages/qwik/src/core/signal/signal.unit.tsx index 3a8e167a407..942f7dcc8f5 100644 --- a/packages/qwik/src/core/signal/signal.unit.tsx +++ b/packages/qwik/src/core/signal/signal.unit.tsx @@ -1,4 +1,4 @@ -import { $, SerializerSymbol, type ValueOrPromise } from '@qwik.dev/core'; +import { $, type ValueOrPromise } from '@qwik.dev/core'; import { createDocument, getTestPlatform } from '@qwik.dev/core/testing'; import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from 'vitest'; import { getDomContainer } from '../client/dom-container'; @@ -18,15 +18,17 @@ import { type InternalSignal, } from './signal'; import { + createComputed$, createComputedQrl, + createSerializer$, createSignal, - type Signal, type ComputedSignal, - type SerializedSignal, - createComputed$, - createSerialized$, + type SerializerSignal, + type Signal, } from './signal.public'; +class Foo {} + describe('signal types', () => { it('Signal', () => () => { const signal = createSignal(1); @@ -38,40 +40,52 @@ describe('signal types', () => { const signal2 = createComputed$(() => 1); expectTypeOf(signal2).toEqualTypeOf>(); }); - it('SerializedSignal', () => () => { - class Foo { - [SerializerSymbol]() { - return 1; - } - } + it('SerializerSignal', () => () => { { - const signal = createSerialized$(() => new Foo()); - expectTypeOf(signal).toEqualTypeOf>(); + const signal = createSerializer$({ + deserialize: () => new Foo(), + serialize: (obj) => { + expect(obj).toBeInstanceOf(Foo); + return 1; + }, + }); + expectTypeOf(signal).toEqualTypeOf>(); expectTypeOf(signal.value).toEqualTypeOf(); } { - const signal = createSerialized$((prev) => { - // sadly we can't do better here - expectTypeOf(prev).toBeUnknown(); - return new Foo(); - }); - expectTypeOf(signal).toEqualTypeOf>(); + const signal = createSerializer$(() => new Foo()); + expectTypeOf(signal).toEqualTypeOf>(); expectTypeOf(signal.value).toEqualTypeOf(); } { - const signal = createSerialized$((prev?: number | Foo) => { - expectTypeOf(prev).toEqualTypeOf(); - return new Foo(); + 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).toEqualTypeOf>(); expectTypeOf(signal.value).toEqualTypeOf(); } { - const signal = createSerialized$((prev) => { - expectTypeOf(prev).toEqualTypeOf(); - return new Foo(); + 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).toEqualTypeOf>(); expectTypeOf(signal.value).toEqualTypeOf(); } }); diff --git a/packages/qwik/src/core/tests/use-serialized.spec.tsx b/packages/qwik/src/core/tests/use-serialized.spec.tsx index ff9211001ce..9c4ea50cc12 100644 --- a/packages/qwik/src/core/tests/use-serialized.spec.tsx +++ b/packages/qwik/src/core/tests/use-serialized.spec.tsx @@ -1,8 +1,7 @@ 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 { SerializerSymbol } from '../shared/utils/serialize-utils'; -import { useSerialized$ } from '../use/use-serialized'; +import { useSerializer$ } from '../use/use-serialized'; const debug = false; //true; Error.stackTraceLimit = 100; @@ -11,10 +10,14 @@ Error.stackTraceLimit = 100; describe.each([ { render: ssrRenderToDom }, // { render: domRender }, // -])('$render.name: useSerialized$', ({ render }) => { +])('$render.name: useSerializer$', ({ render }) => { it('should do custom serialization', async () => { const Counter = component$(() => { - const myCount = useSerialized$((count) => new CustomSerialized((count as number) || 2)); + const myCount = useSerializer$({ + deserialize: (count?: number) => new CustomSerialized(count), + serialize: (data) => data.count, + initial: 2, + }); const spy = useSignal(myCount.value.count); return ( + + ); + 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( <> @@ -52,7 +89,4 @@ class CustomSerialized { inc() { this.count++; } - [SerializerSymbol]() { - return this.count; - } } diff --git a/packages/qwik/src/core/use/use-serialized.ts b/packages/qwik/src/core/use/use-serialized.ts index 7f142b08454..198fe68ed44 100644 --- a/packages/qwik/src/core/use/use-serialized.ts +++ b/packages/qwik/src/core/use/use-serialized.ts @@ -1,16 +1,16 @@ import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; import type { QRL } from '../shared/qrl/qrl.public'; import { - SerializedSignal as SerializedSignalImpl, + SerializerSignal as SerializerSignalImpl, type ComputedSignal, - type ConstructorFn, + type SerializerArg, } from '../signal/signal'; -import type { createSerialized$ } from '../signal/signal.public'; +import type { createSerializer$ } from '../signal/signal.public'; import { useComputedCommon } from './use-computed'; /** @internal */ -export const useSerializedQrl = >(qrl: QRL) => - useComputedCommon(qrl as any, SerializedSignalImpl as typeof ComputedSignal); +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 @@ -35,19 +35,40 @@ export const useSerializedQrl = >(qrl: QRL) * inc() { * this.n++; * } - * [SerializeSymbol]() { - * return this.n; - * } * } * const Cmp = component$(() => { - * const custom = useSerialized$( - * (prev) => - * new MyCustomSerializable(prev instanceof MyCustomSerializable ? prev : (prev ?? 3)) - * ); + * 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 useSerialized$: typeof createSerialized$ = implicit$FirstArg(useSerializedQrl as any); +export const useSerializer$: typeof createSerializer$ = implicit$FirstArg(useSerializerQrl as any);