diff --git a/.changeset/dirty-lines-march.md b/.changeset/dirty-lines-march.md new file mode 100644 index 00000000000..55aa90a1bc8 --- /dev/null +++ b/.changeset/dirty-lines-march.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: implement new SerializationWeakRef class for values that can be not serialized diff --git a/.changeset/eighty-points-argue.md b/.changeset/eighty-points-argue.md new file mode 100644 index 00000000000..21f11f3f004 --- /dev/null +++ b/.changeset/eighty-points-argue.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: async computed signal promise rejection diff --git a/.changeset/famous-numbers-kneel.md b/.changeset/famous-numbers-kneel.md new file mode 100644 index 00000000000..bc08f538218 --- /dev/null +++ b/.changeset/famous-numbers-kneel.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +feat: expose invalidate method for computed-like signals diff --git a/.changeset/old-mangos-return.md b/.changeset/old-mangos-return.md new file mode 100644 index 00000000000..2869373840c --- /dev/null +++ b/.changeset/old-mangos-return.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: maximum component rerender retries diff --git a/.changeset/proud-houses-fix.md b/.changeset/proud-houses-fix.md new file mode 100644 index 00000000000..39a63045930 --- /dev/null +++ b/.changeset/proud-houses-fix.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: expose option to never or always serialize computed-like signal value diff --git a/.changeset/sixty-grapes-beam.md b/.changeset/sixty-grapes-beam.md new file mode 100644 index 00000000000..5469f057e85 --- /dev/null +++ b/.changeset/sixty-grapes-beam.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: serializer symbol value recalculate without update function diff --git a/.changeset/tall-rivers-appear.md b/.changeset/tall-rivers-appear.md new file mode 100644 index 00000000000..0bb352eca06 --- /dev/null +++ b/.changeset/tall-rivers-appear.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': minor +--- + +feat: implement route loaders serialization RFC with the correct "data shaken" diff --git a/.changeset/wide-boats-pump.md b/.changeset/wide-boats-pump.md new file mode 100644 index 00000000000..73e844b19f4 --- /dev/null +++ b/.changeset/wide-boats-pump.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: async computed correctly handle returning falsy value diff --git a/package.json b/package.json index 236297c69f1..3dfb90e7613 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "build.cli": "tsx --require ./scripts/runBefore.ts scripts/index.ts --cli --dev", "build.cli.prod": "tsx --require ./scripts/runBefore.ts scripts/index.ts --cli", "build.core": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --qwik --insights --qwikrouter --api --platform-binding", + "build.router": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --qwikrouter --api", "build.eslint": "tsx --require ./scripts/runBefore.ts scripts/index.ts --eslint", "build.full": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --tsc-docs --qwik --insights --supabaseauthhelpers --api --eslint --qwikrouter --qwikworker --qwikreact --cli --platform-binding --wasm", "build.local": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --tsc-docs --qwik --insights --supabaseauthhelpers --api --eslint --qwikrouter --qwikworker --qwikreact --cli --platform-binding-wasm-copy", diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 56675927b46..55b49caf435 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -244,7 +244,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface AsyncComputedReadonlySignal extends ReadonlySignal \n```\n**Extends:** [ReadonlySignal](#readonlysignal)<T>", + "content": "```typescript\nexport interface AsyncComputedReadonlySignal extends ComputedSignal \n```\n**Extends:** [ComputedSignal](#computedsignal)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.asynccomputedreadonlysignal.md" }, @@ -366,6 +366,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts", "mdFile": "core.computedfn.md" }, + { + "name": "ComputedOptions", + "id": "computedoptions", + "hierarchy": [ + { + "name": "ComputedOptions", + "id": "computedoptions" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface ComputedOptions \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[container?](#)\n\n\n\n\n\n\n\nContainer\n\n\n\n\n_(Optional)_\n\n\n
\n\n[serializationStrategy?](#)\n\n\n\n\n\n\n\n[SerializationStrategy](#serializationstrategy)\n\n\n\n\n_(Optional)_\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/types.ts", + "mdFile": "core.computedoptions.md" + }, { "name": "ComputedReturnType", "id": "computedreturntype", @@ -376,7 +390,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type ComputedReturnType = T extends Promise ? never : ReadonlySignal;\n```\n**References:** [ReadonlySignal](#readonlysignal)", + "content": "```typescript\nexport type ComputedReturnType = T extends Promise ? never : ComputedSignal;\n```\n**References:** [ComputedSignal](#computedsignal)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts", "mdFile": "core.computedreturntype.md" }, @@ -390,7 +404,7 @@ } ], "kind": "Interface", - "content": "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\n\n```typescript\nexport interface ComputedSignal extends ReadonlySignal \n```\n**Extends:** [ReadonlySignal](#readonlysignal)<T>\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[force()](#computedsignal-force)\n\n\n\n\nUse this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries.\n\n\n
", + "content": "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\n\n```typescript\nexport interface ComputedSignal extends ReadonlySignal \n```\n**Extends:** [ReadonlySignal](#readonlysignal)<T>\n\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[force()](#computedsignal-force)\n\n\n\n\nUse this to force running subscribers, for example when the calculated value mutates but remains the same object.\n\n\n
\n\n[invalidate()](#computedsignal-invalidate)\n\n\n\n\nUse this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.computedsignal.md" }, @@ -436,6 +450,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-qwik-attributes.ts", "mdFile": "core.correctedtoggleevent.md" }, + { + "name": "createAsyncComputed$", + "id": "createasynccomputed_", + "hierarchy": [ + { + "name": "createAsyncComputed$", + "id": "createasynccomputed_" + } + ], + "kind": "Function", + "content": "Create an async computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals or async operation. 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 can be async.\n\n\n```typescript\ncreateAsyncComputed$: (qrl: () => Promise, options?: ComputedOptions) => AsyncComputedReturnType\n```\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() => Promise<T>\n\n\n\n\n\n
\n\noptions\n\n\n\n\n[ComputedOptions](#computedoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\n[AsyncComputedReturnType](#asynccomputedreturntype)<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", + "mdFile": "core.createasynccomputed_.md" + }, { "name": "createComputed$", "id": "createcomputed_", @@ -446,7 +474,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 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>", + "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 `useAsyncComputed$` instead.\n\n\n```typescript\ncreateComputed$: (qrl: () => T, options?: ComputedOptions) => ComputedReturnType\n```\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\noptions\n\n\n\n\n[ComputedOptions](#computedoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\n[ComputedReturnType](#computedreturntype)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.createcomputed_.md" }, @@ -641,7 +669,7 @@ } ], "kind": "MethodSignature", - "content": "Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries.\n\n\n```typescript\nforce(): void;\n```\n**Returns:**\n\nvoid", + "content": "Use this to force running subscribers, for example when the calculated value mutates but remains the same object.\n\n\n```typescript\nforce(): void;\n```\n**Returns:**\n\nvoid", "mdFile": "core.computedsignal.force.md" }, { @@ -728,20 +756,6 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/jsx-runtime.ts", "mdFile": "core.h.md" }, - { - "name": "HTMLElementAttrs", - "id": "htmlelementattrs", - "hierarchy": [ - { - "name": "HTMLElementAttrs", - "id": "htmlelementattrs" - } - ], - "kind": "Interface", - "content": "```typescript\nexport interface HTMLElementAttrs extends HTMLAttributesBase, FilterBase \n```\n**Extends:** HTMLAttributesBase, FilterBase<HTMLElement>", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts", - "mdFile": "core.htmlelementattrs.md" - }, { "name": "implicit$FirstArg", "id": "implicit_firstarg", @@ -790,6 +804,23 @@ "content": "```typescript\ninterface IntrinsicElements extends LenientQwikElements \n```\n**Extends:** LenientQwikElements", "mdFile": "core.qwikjsx.intrinsicelements.md" }, + { + "name": "invalidate", + "id": "computedsignal-invalidate", + "hierarchy": [ + { + "name": "ComputedSignal", + "id": "computedsignal-invalidate" + }, + { + "name": "invalidate", + "id": "computedsignal-invalidate" + } + ], + "kind": "MethodSignature", + "content": "Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object.\n\n\n```typescript\ninvalidate(): void;\n```\n**Returns:**\n\nvoid", + "mdFile": "core.computedsignal.invalidate.md" + }, { "name": "isSignal", "id": "issignal", @@ -1374,7 +1405,7 @@ } ], "kind": "TypeAlias", - "content": "The DOM props without plain handlers, for use inside functions\n\n\n```typescript\nexport type QwikHTMLElements = {\n [tag in keyof HTMLElementTagNameMap]: Augmented & HTMLElementAttrs & QwikAttributes;\n};\n```\n**References:** [HTMLElementAttrs](#htmlelementattrs), [QwikAttributes](#qwikattributes)", + "content": "The DOM props without plain handlers, for use inside functions\n\n\n```typescript\nexport type QwikHTMLElements = {\n [tag in keyof HTMLElementTagNameMap]: Augmented & HTMLElementAttrs & QwikAttributes;\n};\n```\n**References:** [QwikAttributes](#qwikattributes)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts", "mdFile": "core.qwikhtmlelements.md" }, @@ -1514,7 +1545,7 @@ } ], "kind": "TypeAlias", - "content": "The SVG props without plain handlers, for use inside functions\n\n\n```typescript\nexport type QwikSVGElements = {\n [K in keyof Omit]: SVGProps;\n};\n```\n**References:** [SVGProps](#svgprops)", + "content": "The SVG props without plain handlers, for use inside functions\n\n\n```typescript\nexport type QwikSVGElements = {\n [K in keyof Omit]: SVGProps;\n};\n```", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts", "mdFile": "core.qwiksvgelements.md" }, @@ -1812,6 +1843,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-resource.ts", "mdFile": "core.resourcereturn.md" }, + { + "name": "SerializationStrategy", + "id": "serializationstrategy", + "hierarchy": [ + { + "name": "SerializationStrategy", + "id": "serializationstrategy" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type SerializationStrategy = 'never' | 'always';\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/types.ts", + "mdFile": "core.serializationstrategy.md" + }, { "name": "SerializerSymbol", "id": "serializersymbol", @@ -2064,20 +2109,6 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts", "mdFile": "core.svgattributes.md" }, - { - "name": "SVGProps", - "id": "svgprops", - "hierarchy": [ - { - "name": "SVGProps", - "id": "svgprops" - } - ], - "kind": "Interface", - "content": "```typescript\nexport interface SVGProps extends SVGAttributes, QwikAttributes \n```\n**Extends:** [SVGAttributes](#svgattributes), [QwikAttributes](#qwikattributes)<T>", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts", - "mdFile": "core.svgprops.md" - }, { "name": "sync$", "id": "sync_", @@ -2186,7 +2217,7 @@ } ], "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\nuseAsyncComputed$: (qrl: AsyncComputedFn) => AsyncComputedReturnType\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[AsyncComputedFn](#asynccomputedfn)<T>\n\n\n\n\n\n
\n**Returns:**\n\n[AsyncComputedReturnType](#asynccomputedreturntype)<T>", + "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\nuseAsyncComputed$: (qrl: AsyncComputedFn, options?: ComputedOptions | undefined) => AsyncComputedReturnType\n```\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[AsyncComputedFn](#asynccomputedfn)<T>\n\n\n\n\n\n
\n\noptions\n\n\n\n\n[ComputedOptions](#computedoptions) \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\n[AsyncComputedReturnType](#asynccomputedreturntype)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async-computed.ts", "mdFile": "core.useasynccomputed_.md" }, @@ -2200,7 +2231,7 @@ } ], "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: ComputedFn) => ComputedReturnType\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\n[ComputedReturnType](#computedreturntype)<T>", + "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, options?: ComputedOptions | undefined) => ComputedReturnType\n```\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\noptions\n\n\n\n\n[ComputedOptions](#computedoptions) \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\n[ComputedReturnType](#computedreturntype)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts", "mdFile": "core.usecomputed_.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 765d989748d..284d5abd3e4 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -130,10 +130,10 @@ export type AsyncComputedFn = (ctx: AsyncComputedCtx) => Promise; ## AsyncComputedReadonlySignal ```typescript -export interface AsyncComputedReadonlySignal extends ReadonlySignal +export interface AsyncComputedReadonlySignal extends ComputedSignal ``` -**Extends:** [ReadonlySignal](#readonlysignal)<T> +**Extends:** [ComputedSignal](#computedsignal)<T> [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts) @@ -384,14 +384,71 @@ export type ComputedFn = () => T; [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts) +## ComputedOptions + +```typescript +export interface ComputedOptions +``` + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +[container?](#) + + + + + +Container + + + +_(Optional)_ + +
+ +[serializationStrategy?](#) + + + + + +[SerializationStrategy](#serializationstrategy) + + + +_(Optional)_ + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/types.ts) + ## ComputedReturnType ```typescript export type ComputedReturnType = - T extends Promise ? never : ReadonlySignal; + T extends Promise ? never : ComputedSignal; ``` -**References:** [ReadonlySignal](#readonlysignal) +**References:** [ComputedSignal](#computedsignal) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts) @@ -420,7 +477,16 @@ Description -Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries. +Use this to force running subscribers, for example when the calculated value mutates but remains the same object. + + + + +[invalidate()](#computedsignal-invalidate) + + + +Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. @@ -707,16 +773,72 @@ Description [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-qwik-attributes.ts) +## createAsyncComputed$ + +Create an async computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals or async operation. 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 can be async. + +```typescript +createAsyncComputed$: (qrl: () => Promise, options?: ComputedOptions) => + AsyncComputedReturnType; +``` + + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +qrl + + + +() => Promise<T> + + + +
+ +options + + + +[ComputedOptions](#computedoptions) + + + +_(Optional)_ + +
+**Returns:** + +[AsyncComputedReturnType](#asynccomputedreturntype)<T> + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts) + ## createComputed$ 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 must be synchronous. -If you need the function to be async, use `useSignal` and `useTask$` instead. +If you need the function to be async, use `useAsyncComputed$` instead. ```typescript -createComputed$: (qrl: () => T) => T extends Promise ? never : ComputedSignal +createComputed$: (qrl: () => T, options?: ComputedOptions) => + ComputedReturnType; ``` +
@@ -742,11 +864,24 @@ qrl +
+ +options + + + +[ComputedOptions](#computedoptions) + + + +_(Optional)_ +
**Returns:** -T extends Promise<any> ? never : [ComputedSignal](#computedsignal)<T> +[ComputedReturnType](#computedreturntype)<T> [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts) @@ -1165,7 +1300,7 @@ export type EventHandler = { ## force -Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries. +Use this to force running subscribers, for example when the calculated value mutates but remains the same object. ```typescript force(): void; @@ -1375,16 +1510,6 @@ any[] [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/jsx-runtime.ts) -## HTMLElementAttrs - -```typescript -export interface HTMLElementAttrs extends HTMLAttributesBase, FilterBase -``` - -**Extends:** HTMLAttributesBase, FilterBase<HTMLElement> - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts) - ## implicit$FirstArg Create a `____$(...)` convenience method from `___(...)`. @@ -1473,6 +1598,18 @@ interface IntrinsicElements extends LenientQwikElements **Extends:** LenientQwikElements +## invalidate + +Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. + +```typescript +invalidate(): void; +``` + +**Returns:** + +void + ## isSignal ```typescript @@ -2628,7 +2765,7 @@ export type QwikHTMLElements = { }; ``` -**References:** [HTMLElementAttrs](#htmlelementattrs), [QwikAttributes](#qwikattributes) +**References:** [QwikAttributes](#qwikattributes) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts) @@ -2826,8 +2963,6 @@ export type QwikSVGElements = { }; ``` -**References:** [SVGProps](#svgprops) - [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts) ## QwikSymbolEvent @@ -3704,6 +3839,14 @@ export type ResourceReturn = [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-resource.ts) +## SerializationStrategy + +```typescript +export type SerializationStrategy = "never" | "always"; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/types.ts) + ## SerializerSymbol If an object has this property as a function, it will be called with the object and should return a serializable value. @@ -8163,16 +8306,6 @@ _(Optional)_ [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts) -## SVGProps - -```typescript -export interface SVGProps extends SVGAttributes, QwikAttributes -``` - -**Extends:** [SVGAttributes](#svgattributes), [QwikAttributes](#qwikattributes)<T> - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/types/jsx-generated.ts) - ## sync$ Extract function into a synchronously loadable QRL. @@ -8477,7 +8610,10 @@ 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 -useAsyncComputed$: (qrl: AsyncComputedFn) => AsyncComputedReturnType; +useAsyncComputed$: ( + qrl: AsyncComputedFn, + options?: ComputedOptions | undefined, +) => AsyncComputedReturnType; ``` +
@@ -8503,6 +8639,19 @@ qrl +
+ +options + + + +[ComputedOptions](#computedoptions) \| undefined + + + +_(Optional)_ +
**Returns:** @@ -8518,7 +8667,8 @@ 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: ComputedFn) => ComputedReturnType; +useComputed$: (qrl: ComputedFn, options?: ComputedOptions | undefined) => + ComputedReturnType; ``` +
@@ -8544,6 +8694,19 @@ qrl +
+ +options + + + +[ComputedOptions](#computedoptions) \| undefined + + + +_(Optional)_ +
**Returns:** diff --git a/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx index 05159b616ea..90185a613c1 100644 --- a/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx +++ b/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx @@ -14,6 +14,7 @@ contributors: - mjschwanitz - adamdbradley - gioboa + - Varixo updated_at: '2023-12-15T11:00:00Z' created_at: '2023-03-20T23:45:13Z' --- @@ -124,6 +125,96 @@ export default component$(() => { The above example shows two `routeLoader$`s being used in the same file. A generic `useLoginStatus` loader is used to check if the user is logged in, and a more specific `useCurrentUser` loader is used to retrieve the user data. +## Loaders data serialization + +By default, route loader data is not serialized and sent to the client. Instead, it is discarded after server-side rendering (SSR). If a component on the client requires the data, it will be refetched (lazy-loaded). + +You can customize this behavior using the `serializationStrategy` option in the `routeLoader$()`. This option accepts one of three values: +- `never` (Default): The data is never serialized. It is discarded after SSR and refetched on the client when needed. +- `always`: The data is always serialized and included in the initial HTML response. It is immediately available on the client without needing to be refetched. +- `auto`: Qwik automatically decides whether to serialize the data based on its size. If the data is small, it will be serialized. If it exceeds a certain threshold, it will be discarded and lazy-loaded on the client as needed. + +```tsx title="src/routes/product/[user]/index.tsx" +import { routeLoader$ } from '@qwik.dev/router'; + +export const useProductDetails = routeLoader$(async (requestEvent) => { + const res = await fetch( + `https://.../products/${requestEvent.params.productId}` + ); + const product = await res.json(); + return product; +}, { + serializationStrategy: 'always', // 'never' | 'always' | 'auto' +}); +``` + +### Handling loading state for lazy loaded route loader data + +When using lazy-loaded data, you can handle the loading state in your Qwik Components by checking if the data is available. If not, you can render a loading indicator or placeholder content. + +```tsx title="src/routes/product/[user]/index.tsx" +import { component$, useSignal } from '@qwik.dev/core'; +import { routeLoader$ } from '@qwik.dev/router'; + +export const useProductDetails = routeLoader$(async (requestEvent) => { + const res = await fetch( + `https://.../products/${requestEvent.params.productId}` + ); + const product = await res.json(); + return product; +}); + +export default component$(() => { + const data = useProductDetails(); + const condition = useSignal(false); + return ( + <> + Product name: {data.value.product.name} + + {condition.value ? :
} + + ); +}); + +// Will use lazy loaded data +export const Child = component$(() => { + const data = useProductDetails(); + return ( +
+ {data.loading ? ( +

Loading product details...

+ ) : ( +

Product description: {data.value.product.description}

+ )} +
+ ); +}); +``` + +### Global default serialization strategy +It is possible to set a global default serialization strategy for all `routeLoader$`s in your application. This can be done by configuring the `qwikRouter` Vite plugin. +Default value is `never`. + +```ts title="vite.config.ts" +import { qwikRouter } from '@qwik.dev/router/vite'; + +export default () => { + return { + //... + plugins: [ + //... + qwikRouter({ + // Set the default serialization strategy for all route loaders + defaultLoadersSerializationStrategy: 'always', // 'never' | 'always' | 'auto' + }), + //... + ], + } +}; +``` + ## RequestEvent Just like [middleware](/docs/middleware/) or [endpoint](/docs/endpoints/) `onRequest` and `onGet`, `routeLoader$`s have access to the [`RequestEvent`](/docs/middleware#requestevent) API which includes information about the current HTTP request. diff --git a/packages/qwik-router/global.d.ts b/packages/qwik-router/global.d.ts index 31c129b25f3..08bc95c6520 100644 --- a/packages/qwik-router/global.d.ts +++ b/packages/qwik-router/global.d.ts @@ -4,6 +4,7 @@ type RequestEventInternal = import('./middleware/request-handler/request-event').RequestEventInternal; type AsyncStore = import('node:async_hooks').AsyncLocalStorage; +type SerializationStrategy = import('@qwik.dev/core/internal').SerializationStrategy; declare var qcAsyncRequestStore: AsyncStore | undefined; declare var _qwikActionsMap: Map | undefined; @@ -18,3 +19,5 @@ type ExperimentalFeatures = import('@qwik.dev/core/optimizer').ExperimentalFeatu declare var __EXPERIMENTAL__: { [K in ExperimentalFeatures]: boolean; }; + +declare var __DEFAULT_LOADERS_SERIALIZATION_STRATEGY__: SerializationStrategy; diff --git a/packages/qwik-router/src/buildtime/markdown/markdown-url.unit.ts b/packages/qwik-router/src/buildtime/markdown/markdown-url.unit.ts index 2629915f085..7a5757e3295 100644 --- a/packages/qwik-router/src/buildtime/markdown/markdown-url.unit.ts +++ b/packages/qwik-router/src/buildtime/markdown/markdown-url.unit.ts @@ -80,6 +80,7 @@ const menuFilePath = join(routesDir, 'docs', 'menu.md'); mdx: {}, platform: {}, rewriteRoutes: [], + defaultLoadersSerializationStrategy: 'never', }; assert.equal(getMarkdownRelativeUrl(opts, menuFilePath, t.href), t.expect); }); diff --git a/packages/qwik-router/src/buildtime/routing/resolve-source-file.unit.ts b/packages/qwik-router/src/buildtime/routing/resolve-source-file.unit.ts index d778a48b36a..f699938adf6 100644 --- a/packages/qwik-router/src/buildtime/routing/resolve-source-file.unit.ts +++ b/packages/qwik-router/src/buildtime/routing/resolve-source-file.unit.ts @@ -49,6 +49,7 @@ test('resolveLayout', () => { mdx: {}, platform: {}, rewriteRoutes: [], + defaultLoadersSerializationStrategy: 'never', }; const sourceFile: RouteSourceFile = { ...getSourceFile(c.fileName)!, diff --git a/packages/qwik-router/src/buildtime/types.ts b/packages/qwik-router/src/buildtime/types.ts index ca423cc0415..7f6473a11d8 100644 --- a/packages/qwik-router/src/buildtime/types.ts +++ b/packages/qwik-router/src/buildtime/types.ts @@ -1,3 +1,5 @@ +import type { SerializationStrategy } from '@qwik.dev/core/internal'; + export interface BuildContext { rootDir: string; opts: NormalizedPluginOptions; @@ -133,6 +135,8 @@ export interface PluginOptions { platform?: Record; /** Configuration to rewrite url paths */ rewriteRoutes?: RewriteRouteOption[]; + /** The serialization strategy for route loaders. Defaults to `never`. */ + defaultLoadersSerializationStrategy?: SerializationStrategy; } export interface MdxPlugins { diff --git a/packages/qwik-router/src/buildtime/vite/dev-server.ts b/packages/qwik-router/src/buildtime/vite/dev-server.ts index 248523736df..a8d5abcc502 100644 --- a/packages/qwik-router/src/buildtime/vite/dev-server.ts +++ b/packages/qwik-router/src/buildtime/vite/dev-server.ts @@ -34,6 +34,7 @@ import { getExtension, normalizePath } from '../../utils/fs'; import { updateBuildContext } from '../build'; import type { BuildContext, BuildRoute } from '../types'; import { formatError } from './format-error'; +import { RequestEvShareServerTiming } from '../../middleware/request-handler/request-event'; export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) { const matchRouteRequest = (pathname: string) => { @@ -189,13 +190,13 @@ export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) { res.setHeader('Set-Cookie', cookieHeaders); } - const serverTiming = requestEv.sharedMap.get('@serverTiming') as + const serverTiming = requestEv.sharedMap.get(RequestEvShareServerTiming) as | [string, number][] | undefined; if (serverTiming) { res.setHeader( 'Server-Timing', - serverTiming.map((a) => `${a[0]};dur=${a[1]}`).join(',') + serverTiming.map(([name, duration]) => `${name};dur=${duration}`).join(',') ); } (res as QwikViteDevResponse)._qwikEnvData = { diff --git a/packages/qwik-router/src/buildtime/vite/plugin.ts b/packages/qwik-router/src/buildtime/vite/plugin.ts index de3e037c2a1..c28e83a8fca 100644 --- a/packages/qwik-router/src/buildtime/vite/plugin.ts +++ b/packages/qwik-router/src/buildtime/vite/plugin.ts @@ -78,6 +78,11 @@ function qwikRouterPlugin(userOpts?: QwikRouterVitePluginOptions): any { async config() { const updatedViteConfig: UserConfig = { + define: { + 'globalThis.__DEFAULT_LOADERS_SERIALIZATION_STRATEGY__': JSON.stringify( + userOpts?.defaultLoadersSerializationStrategy || 'never' + ), + }, appType: 'custom', resolve: { alias: [ diff --git a/packages/qwik-router/src/buildtime/vite/plugin.unit.ts b/packages/qwik-router/src/buildtime/vite/plugin.unit.ts new file mode 100644 index 00000000000..9aa1fd85eb5 --- /dev/null +++ b/packages/qwik-router/src/buildtime/vite/plugin.unit.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { qwikRouter } from './plugin'; + +describe('qwikRouter plugin', () => { + describe('defaultLoadersSerializationStrategy', () => { + it('should set the defaultLoadersSerializationStrategy to "never" when not provided', async () => { + const plugins = qwikRouter(); + + await expect((plugins[0] as any)?.config?.()).resolves.toMatchObject({ + define: { + 'globalThis.__DEFAULT_LOADERS_SERIALIZATION_STRATEGY__': '"never"', + }, + }); + }); + + it('should set the defaultLoadersSerializationStrategy to "always" when provided', async () => { + const plugins = qwikRouter({ + defaultLoadersSerializationStrategy: 'always', + }); + + await expect((plugins[0] as any)?.config?.()).resolves.toMatchObject({ + define: { + 'globalThis.__DEFAULT_LOADERS_SERIALIZATION_STRATEGY__': '"always"', + }, + }); + }); + }); +}); diff --git a/packages/qwik-router/src/buildtime/vite/qwik-router.buildtime.api.md b/packages/qwik-router/src/buildtime/vite/qwik-router.buildtime.api.md index fcdfce67084..45b8ce8ea23 100644 --- a/packages/qwik-router/src/buildtime/vite/qwik-router.buildtime.api.md +++ b/packages/qwik-router/src/buildtime/vite/qwik-router.buildtime.api.md @@ -10,6 +10,7 @@ import type { Config } from 'svgo'; import { ConfigEnv } from 'vite'; import type { Plugin as Plugin_2 } from 'vite'; import type { PluginOption } from 'vite'; +import type { SerializationStrategy } from '@qwik.dev/core/internal'; import { UserConfigExport } from 'vite'; // @public (undocumented) diff --git a/packages/qwik-router/src/middleware/request-handler/index.ts b/packages/qwik-router/src/middleware/request-handler/index.ts index 80a3e6dcbff..88e7cda2efd 100644 --- a/packages/qwik-router/src/middleware/request-handler/index.ts +++ b/packages/qwik-router/src/middleware/request-handler/index.ts @@ -3,6 +3,7 @@ export { mergeHeadersCookies } from './cookie'; export { AbortMessage, RedirectMessage } from './redirect-handler'; export { RewriteMessage } from './rewrite-handler'; export { requestHandler } from './request-handler'; +export { RequestEvShareQData } from './request-event'; export { _TextEncoderStream_polyfill } from './polyfill'; export type { CacheControl, diff --git a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md index 5da40e59478..dbb64c181ac 100644 --- a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md +++ b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md @@ -17,6 +17,7 @@ import type { RenderOptions } from '@qwik.dev/core/server'; import { RequestEvent as RequestEvent_2 } from '@qwik.dev/router/middleware/request-handler'; import type { RequestHandler as RequestHandler_2 } from '@qwik.dev/router/middleware/request-handler'; import type { ResolveSyncValue as ResolveSyncValue_2 } from '@qwik.dev/router/middleware/request-handler'; +import type { SerializationStrategy } from '@qwik.dev/core/internal'; import type { _serialize } from '@qwik.dev/core/internal'; import type { ValueOrPromise } from '@qwik.dev/core'; import type { _verifySerializable } from '@qwik.dev/core/internal'; @@ -152,6 +153,11 @@ export interface RequestEventLoader extends Reque resolveValue: ResolveValue; } +// Warning: (ae-internal-missing-underscore) The name "RequestEvShareQData" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export const RequestEvShareQData = "qData"; + // @public (undocumented) export type RequestHandler = (ev: RequestEvent) => Promise | void; diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index 1f86b9042d5..5257ba14956 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -1,19 +1,21 @@ import type { ValueOrPromise } from '@qwik.dev/core'; +import type { SerializationStrategy } from '@qwik.dev/core/internal'; import { QDATA_KEY } from '../../runtime/src/constants'; -import type { - ActionInternal, - FailReturn, - JSONValue, - LoadedRoute, - LoaderInternal, +import { + LoadedRouteProp, + type ActionInternal, + type FailReturn, + type JSONValue, + type LoadedRoute, + type LoaderInternal, } from '../../runtime/src/types'; import { isPromise } from '../../runtime/src/utils'; import { createCacheControl } from './cache-control'; import { Cookie } from './cookie'; import { ServerError } from './error-handler'; import { AbortMessage, RedirectMessage } from './redirect-handler'; -import { RewriteMessage } from './rewrite-handler'; import { encoder } from './resolve-request-handlers'; +import { RewriteMessage } from './rewrite-handler'; import type { CacheControl, CacheControlTarget, @@ -32,12 +34,18 @@ const RequestEvLoaders = Symbol('RequestEvLoaders'); const RequestEvMode = Symbol('RequestEvMode'); const RequestEvRoute = Symbol('RequestEvRoute'); export const RequestEvQwikSerializer = Symbol('RequestEvQwikSerializer'); +export const RequestEvLoaderSerializationStrategyMap = Symbol( + 'RequestEvLoaderSerializationStrategyMap' +); export const RequestEvTrailingSlash = Symbol('RequestEvTrailingSlash'); export const RequestRouteName = '@routeName'; export const RequestEvSharedActionId = '@actionId'; export const RequestEvSharedActionFormData = '@actionFormData'; export const RequestEvSharedNonce = '@nonce'; export const RequestEvIsRewrite = '@rewrite'; +export const RequestEvShareServerTiming = '@serverTiming'; +/** @internal */ +export const RequestEvShareQData = 'qData'; export function createRequestEvent( serverRequestEv: ServerRequestEvent, @@ -145,6 +153,7 @@ export function createRequestEvent( const loaders: Record> = {}; const requestEv: RequestEventInternal = { [RequestEvLoaders]: loaders, + [RequestEvLoaderSerializationStrategyMap]: new Map(), [RequestEvMode]: serverRequestEv.mode, [RequestEvTrailingSlash]: trailingSlash, get [RequestEvRoute]() { @@ -158,7 +167,7 @@ export function createRequestEvent( signal: request.signal, originalUrl: new URL(url), get params() { - return loadedRoute?.[1] ?? {}; + return loadedRoute?.[LoadedRouteProp.Params] ?? {}; }, get pathname() { return url.pathname; @@ -298,9 +307,14 @@ export function createRequestEvent( getWritableStream: () => { if (writableStream === null) { if (serverRequestEv.mode === 'dev') { - const serverTiming = sharedMap.get('@serverTiming') as [string, number][] | undefined; + const serverTiming = sharedMap.get(RequestEvShareServerTiming) as + | [string, number][] + | undefined; if (serverTiming) { - headers.set('Server-Timing', serverTiming.map((a) => `${a[0]};dur=${a[1]}`).join(',')); + headers.set( + 'Server-Timing', + serverTiming.map(([name, duration]) => `${name};dur=${duration}`).join(',') + ); } } writableStream = serverRequestEv.getWritableStream( @@ -319,6 +333,7 @@ export function createRequestEvent( export interface RequestEventInternal extends RequestEvent, RequestEventLoader { [RequestEvLoaders]: Record | undefined>; + [RequestEvLoaderSerializationStrategyMap]: Map; [RequestEvMode]: ServerRequestMode; [RequestEvTrailingSlash]: boolean; [RequestEvRoute]: LoadedRoute | null; @@ -349,6 +364,10 @@ export function getRequestLoaders(requestEv: RequestEventCommon) { return (requestEv as RequestEventInternal)[RequestEvLoaders]; } +export function getRequestLoaderSerializationStrategyMap(requestEv: RequestEventCommon) { + return (requestEv as RequestEventInternal)[RequestEvLoaderSerializationStrategyMap]; +} + export function getRequestTrailingSlash(requestEv: RequestEventCommon) { return (requestEv as RequestEventInternal)[RequestEvTrailingSlash]; } diff --git a/packages/qwik-router/src/middleware/request-handler/request-handler.ts b/packages/qwik-router/src/middleware/request-handler/request-handler.ts index 3b80c18e6a6..d5d29c51daf 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-handler.ts @@ -1,6 +1,6 @@ import type { Render } from '@qwik.dev/core/server'; import { loadRoute } from '../../runtime/src/routing'; -import type { RebuildRouteInfoInternal, QwikRouterConfig } from '../../runtime/src/types'; +import type { QwikRouterConfig, RebuildRouteInfoInternal } from '../../runtime/src/types'; import { renderQwikMiddleware, resolveRequestHandlers } from './resolve-request-handlers'; import type { QwikSerializer, ServerRenderOptions, ServerRequestEvent } from './types'; import { getRouteMatchPathname, runQwikRouter, type QwikRouterRun } from './user-response'; diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index 338c0cb397a..61c9130dbcc 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -1,25 +1,30 @@ -import type { QRL } from '@qwik.dev/core'; +import { type QRL } from '@qwik.dev/core'; import type { Render, RenderToStringResult } from '@qwik.dev/core/server'; -import { QACTION_KEY, QFN_KEY } from '../../runtime/src/constants'; -import type { - ActionInternal, - ClientPageData, - DataValidator, - JSONObject, - LoadedRoute, - LoaderInternal, - PageModule, - RouteModule, - ValidatorReturn, +import { QACTION_KEY, QFN_KEY, QLOADER_KEY } from '../../runtime/src/constants'; +import { + LoadedRouteProp, + type ActionInternal, + type ClientPageData, + type DataValidator, + type JSONObject, + type LoadedRoute, + type LoaderInternal, + type PageModule, + type RouteModule, + type ValidatorReturn, } from '../../runtime/src/types'; +import { ServerError } from './error-handler'; import { HttpStatus } from './http-status-codes'; import { RedirectMessage } from './redirect-handler'; import { - RequestEvQwikSerializer, RequestEvIsRewrite, + RequestEvQwikSerializer, + RequestEvShareQData, + RequestEvShareServerTiming, RequestEvSharedActionId, RequestRouteName, getRequestLoaders, + getRequestLoaderSerializationStrategyMap, getRequestMode, getRequestTrailingSlash, type RequestEventInternal, @@ -33,7 +38,7 @@ import type { RequestHandler, } from './types'; import { IsQData, QDATA_JSON } from './user-response'; -import { ServerError } from './error-handler'; +import { _UNINITIALIZED } from 'packages/qwik/core-internal'; export const resolveRequestHandlers = ( serverPlugins: RouteModule[] | undefined, @@ -47,8 +52,7 @@ export const resolveRequestHandlers = ( const requestHandlers: RequestHandler[] = []; - const isPageRoute = !!(route && isLastModulePageRoute(route[2])); - + const isPageRoute = !!(route && isLastModulePageRoute(route[LoadedRouteProp.Mods])); if (serverPlugins) { _resolveRequestHandlers( routeLoaders, @@ -61,7 +65,7 @@ export const resolveRequestHandlers = ( } if (route) { - const routeName = route[0]; + const routeName = route[LoadedRouteProp.RouteName]; if ( checkOrigin && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') @@ -77,7 +81,7 @@ export const resolveRequestHandlers = ( requestHandlers.push(fixTrailingSlash); requestHandlers.push(renderQData); } - const routeModules = route[2]; + const routeModules = route[LoadedRouteProp.Mods]; requestHandlers.push(handleRedirect); _resolveRequestHandlers( routeLoaders, @@ -92,7 +96,8 @@ export const resolveRequestHandlers = ( // Set the current route name ev.sharedMap.set(RequestRouteName, routeName); }); - requestHandlers.push(actionsMiddleware(routeActions, routeLoaders) as any); + requestHandlers.push(actionsMiddleware(routeActions)); + requestHandlers.push(loadersMiddleware(routeLoaders)); requestHandlers.push(renderHandler); } } @@ -171,8 +176,9 @@ export const checkBrand = (obj: any, brand: string) => { return obj && typeof obj === 'function' && obj.__brand === brand; }; -export function actionsMiddleware(routeActions: ActionInternal[], routeLoaders: LoaderInternal[]) { - return async (requestEv: RequestEventInternal) => { +export function actionsMiddleware(routeActions: ActionInternal[]): RequestHandler { + return async (requestEvent: RequestEvent) => { + const requestEv = requestEvent as RequestEventInternal; if (requestEv.headersSent) { requestEv.exit(); return; @@ -210,7 +216,7 @@ export function actionsMiddleware(routeActions: ActionInternal[], routeLoaders: loaders[selectedActionId] = requestEv.fail(result.status ?? 500, result.error); } else { const actionResolved = isDev - ? await measure(requestEv, action.__qrl.getSymbol().split('_', 1)[0], () => + ? await measure(requestEv, action.__qrl.getHash(), () => action.__qrl.call(requestEv, result.data as JSONObject, requestEv) ) : await action.__qrl.call(requestEv, result.data as JSONObject, requestEv); @@ -222,51 +228,82 @@ export function actionsMiddleware(routeActions: ActionInternal[], routeLoaders: } } } + }; +} +export function loadersMiddleware(routeLoaders: LoaderInternal[]): RequestHandler { + return async (requestEvent: RequestEvent) => { + const requestEv = requestEvent as RequestEventInternal; + if (requestEv.headersSent) { + requestEv.exit(); + return; + } + const loaders = getRequestLoaders(requestEv); + const isDev = getRequestMode(requestEv) === 'dev'; + const qwikSerializer = requestEv[RequestEvQwikSerializer]; if (routeLoaders.length > 0) { - const resolvedLoadersPromises = routeLoaders.map((loader) => { - const loaderId = loader.__id; - loaders[loaderId] = runValidators( - requestEv, - loader.__validators, - undefined, // data - isDev - ) - .then((res) => { - if (res.success) { - if (isDev) { - return measure>( - requestEv, - loader.__qrl.getSymbol().split('_', 1)[0], - () => loader.__qrl.call(requestEv, requestEv) - ); - } else { - return loader.__qrl.call(requestEv, requestEv); - } - } else { - return requestEv.fail(res.status ?? 500, res.error); - } - }) - .then((resolvedLoader) => { - if (typeof resolvedLoader === 'function') { - loaders[loaderId] = resolvedLoader(); - } else { - if (isDev) { - verifySerializable(qwikSerializer, resolvedLoader, loader.__qrl); - } - loaders[loaderId] = resolvedLoader; - } - return resolvedLoader; - }); - - return loaders[loaderId]; - }); - + let currentLoaders: LoaderInternal[] = []; + if (requestEv.query.has(QLOADER_KEY)) { + const selectedLoaderIds = requestEv.query.getAll(QLOADER_KEY); + for (const loader of routeLoaders) { + if (selectedLoaderIds.includes(loader.__id)) { + currentLoaders.push(loader); + } + } + } else { + currentLoaders = routeLoaders; + } + const resolvedLoadersPromises = currentLoaders.map((loader) => + getRouteLoaderPromise(loader, loaders, requestEv, isDev, qwikSerializer) + ); await Promise.all(resolvedLoadersPromises); } }; } +async function getRouteLoaderPromise( + loader: LoaderInternal, + loaders: Record, + requestEv: RequestEventInternal, + isDev: boolean, + qwikSerializer: QwikSerializer +) { + const loaderId = loader.__id; + loaders[loaderId] = runValidators( + requestEv, + loader.__validators, + undefined, // data + isDev + ) + .then((res) => { + if (res.success) { + if (isDev) { + return measure>(requestEv, loader.__qrl.getHash(), () => + loader.__qrl.call(requestEv, requestEv) + ); + } else { + return loader.__qrl.call(requestEv, requestEv); + } + } else { + return requestEv.fail(res.status ?? 500, res.error); + } + }) + .then((resolvedLoader) => { + if (typeof resolvedLoader === 'function') { + loaders[loaderId] = resolvedLoader(); + } else { + if (isDev) { + verifySerializable(qwikSerializer, resolvedLoader, loader.__qrl); + } + loaders[loaderId] = resolvedLoader; + } + return resolvedLoader; + }); + const loadersSerializationStrategy = getRequestLoaderSerializationStrategyMap(requestEv); + loadersSerializationStrategy.set(loaderId, loader.__serializationStrategy); + return loaders[loaderId]; +} + async function runValidators( requestEv: RequestEvent, validators: DataValidator[] | undefined, @@ -418,7 +455,7 @@ export function getPathname(url: URL, trailingSlash: boolean | undefined) { } } // strip internal search params - const search = url.search.slice(1).replaceAll(/&?q(action|data|func)=[^&]+/g, ''); + const search = url.search.slice(1).replaceAll(/&?q(action|data|func|loaders)=[^&]+/g, ''); return `${url.pathname}${search ? `?${search}` : ''}${url.hash}`; } @@ -490,7 +527,7 @@ export function renderQwikMiddleware(render: Render) { // write the already completed html to the stream await stream.write((result as any as RenderToStringResult).html); } - requestEv.sharedMap.set('qData', qData); + requestEv.sharedMap.set(RequestEvShareQData, qData); } finally { await stream.ready; await stream.close(); @@ -554,20 +591,41 @@ export async function renderQData(requestEv: RequestEvent) { requestEv.request.headers.forEach((value, key) => (requestHeaders[key] = value)); requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); - const qData: ClientPageData = { - loaders: getRequestLoaders(requestEv), - action: requestEv.sharedMap.get(RequestEvSharedActionId), - status: status !== 200 ? status : 200, - href: getPathname(requestEv.url, trailingSlash), - redirect: redirectLocation ?? undefined, - isRewrite: requestEv.sharedMap.get(RequestEvIsRewrite), - }; + let loaders = getRequestLoaders(requestEv); + const selectedLoaderIds = requestEv.query.getAll(QLOADER_KEY); + + const hasCustomLoaders = selectedLoaderIds.length > 0; + + if (hasCustomLoaders) { + const selectedLoaders: Record = {}; + for (const loaderId of selectedLoaderIds) { + const loader = loaders[loaderId]; + selectedLoaders[loaderId] = loader; + } + loaders = selectedLoaders; + } + + const qData: ClientPageData = hasCustomLoaders + ? { + // send minimal data to the client + loaders, + status: status !== 200 ? status : 200, + href: getPathname(requestEv.url, trailingSlash), + } + : { + loaders, + action: requestEv.sharedMap.get(RequestEvSharedActionId), + status: status !== 200 ? status : 200, + href: getPathname(requestEv.url, trailingSlash), + redirect: redirectLocation ?? undefined, + isRewrite: requestEv.sharedMap.get(RequestEvIsRewrite), + }; const writer = requestEv.getWritableStream().getWriter(); const qwikSerializer = (requestEv as RequestEventInternal)[RequestEvQwikSerializer]; // write just the page json data to the response body const data = await qwikSerializer._serialize([qData]); writer.write(encoder.encode(data)); - requestEv.sharedMap.set('qData', qData); + requestEv.sharedMap.set(RequestEvShareQData, qData); writer.close(); } @@ -598,9 +656,9 @@ export async function measure( return await fn(); } finally { const duration = now() - start; - let measurements = requestEv.sharedMap.get('@serverTiming'); + let measurements = requestEv.sharedMap.get(RequestEvShareServerTiming); if (!measurements) { - requestEv.sharedMap.set('@serverTiming', (measurements = [])); + requestEv.sharedMap.set(RequestEvShareServerTiming, (measurements = [])); } measurements.push([name, duration]); } diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts index fd6e49bfaf6..ef3de3c1e0a 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts @@ -28,6 +28,9 @@ describe('resolve-request-handler', () => { expect(getPathname(new URL('http://server/path?foo=1&qfunc=f&bar=2'), false)).toBe( '/path?foo=1&bar=2' ); + expect(getPathname(new URL('http://server/path?foo=1&qloaders=f&bar=2'), false)).toBe( + '/path?foo=1&bar=2' + ); }); }); diff --git a/packages/qwik-router/src/middleware/request-handler/response-page.ts b/packages/qwik-router/src/middleware/request-handler/response-page.ts index ae03bd4961b..657ea919f1c 100644 --- a/packages/qwik-router/src/middleware/request-handler/response-page.ts +++ b/packages/qwik-router/src/middleware/request-handler/response-page.ts @@ -1,6 +1,8 @@ +import { Q_ROUTE } from '../../runtime/src/constants'; import type { QwikRouterEnvData } from '../../runtime/src/types'; import { getRequestLoaders, + getRequestLoaderSerializationStrategyMap, getRequestRoute, RequestEvSharedActionFormData, RequestEvSharedActionId, @@ -30,13 +32,16 @@ export function getQwikRouterServerData(requestEv: RequestEvent) { reconstructedUrl.protocol = protocol; } + const loaders = getRequestLoaders(requestEv); + const loadersSerializationStrategy = getRequestLoaderSerializationStrategyMap(requestEv); + return { url: reconstructedUrl.href, requestHeaders, locale: locale(), nonce, containerAttributes: { - 'q:route': routeName, + [Q_ROUTE]: routeName, }, qwikrouter: { routeName, @@ -45,7 +50,8 @@ export function getQwikRouterServerData(requestEv: RequestEvent) { loadedRoute: getRequestRoute(requestEv), response: { status: status(), - loaders: getRequestLoaders(requestEv), + loaders, + loadersSerializationStrategy, action, formData, }, diff --git a/packages/qwik-router/src/runtime/src/constants.ts b/packages/qwik-router/src/runtime/src/constants.ts index f5d9842f2cc..a4c847bec7a 100644 --- a/packages/qwik-router/src/runtime/src/constants.ts +++ b/packages/qwik-router/src/runtime/src/constants.ts @@ -1,4 +1,5 @@ import type { ClientPageData } from './types'; +import type { SerializationStrategy } from '@qwik.dev/core/internal'; export const MODULE_CACHE = /*#__PURE__*/ new WeakMap(); @@ -6,6 +7,13 @@ export const CLIENT_DATA_CACHE = new Map>('qc-s'); +export const RouteStateContext = + /*#__PURE__*/ createContextId>>('qc-s'); export const ContentContext = /*#__PURE__*/ createContextId('qc-c'); export const ContentInternalContext = diff --git a/packages/qwik-router/src/runtime/src/link-component.tsx b/packages/qwik-router/src/runtime/src/link-component.tsx index 06467ae1a87..7995a26c453 100644 --- a/packages/qwik-router/src/runtime/src/link-component.tsx +++ b/packages/qwik-router/src/runtime/src/link-component.tsx @@ -7,8 +7,8 @@ import { untrack, useSignal, useVisibleTask$, - type QwikIntrinsicElements, type EventHandler, + type QwikIntrinsicElements, type QwikVisibleEvent, } from '@qwik.dev/core'; import { prefetchSymbols } from './client-navigate'; @@ -63,7 +63,7 @@ export const Link = component$((props) => { }) : undefined; const preventDefault = clientNavPath - ? sync$((event: MouseEvent, target: HTMLAnchorElement) => { + ? sync$((event: MouseEvent) => { if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { event.preventDefault(); } diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index 612e7015ac9..a3b729afd25 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -17,14 +17,18 @@ import { type QRL, } from '@qwik.dev/core'; import { + _getContextContainer, _getContextElement, _getQContainerElement, + _UNINITIALIZED, _waitUntilRendered, - _weakSerialize, + SerializerSymbol, type _ElementVNode, + type AsyncComputedReadonlySignal, + type SerializationStrategy, } from '@qwik.dev/core/internal'; import { clientNavigate } from './client-navigate'; -import { CLIENT_DATA_CACHE } from './constants'; +import { CLIENT_DATA_CACHE, DEFAULT_LOADERS_SERIALIZATION_STRATEGY, Q_ROUTE } from './constants'; import { ContentContext, ContentInternalContext, @@ -65,7 +69,7 @@ import type { } from './types'; import { loadClientData } from './use-endpoint'; import { useQwikRouterEnv } from './use-functions'; -import { isSameOrigin, isSamePath, toUrl } from './utils'; +import { createLoaderSignal, isSameOrigin, isSamePath, toUrl } from './utils'; import { startViewTransition } from './view-transition'; /** @@ -100,7 +104,7 @@ export interface QwikRouterProps { * * @see https://github.com/WICG/view-transitions/blob/main/explainer.md * @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API - * @see https://caniuse.com/mdn-api_viewtransition + * @see https://caniuse.com/mdn_api_viewtransition */ viewTransition?: boolean; } @@ -169,7 +173,43 @@ export const QwikRouterProvider = component$((props) => { { deep: false } ); const navResolver: { r?: () => void } = {}; - const loaderState = _weakSerialize(useStore(env.response.loaders, { deep: false })); + const container = _getContextContainer(); + const getSerializationStrategy = (loaderId: string): SerializationStrategy => { + return ( + env.response.loadersSerializationStrategy.get(loaderId) || + DEFAULT_LOADERS_SERIALIZATION_STRATEGY + ); + }; + + // On server this object contains the all the loaders data + // On client after resuming this object contains only keys and _UNINITIALIZED as values + // Thanks to this we can use this object as a capture ref and not to serialize unneeded data + // While resolving the loaders we will override the _UNINITIALIZED with the actual data + const loadersObject: Record = {}; + + // This object contains the signals for the loaders + // It is used for the loaders context RouteStateContext + const loaderState: Record> = {}; + + for (const [key, value] of Object.entries(env.response.loaders)) { + loadersObject[key] = value; + loaderState[key] = createLoaderSignal( + loadersObject, + key, + url, + getSerializationStrategy(key), + container + ); + } + // Serialize it as keys and _UNINITIALIZED as values + (loadersObject as any)[SerializerSymbol] = (obj: Record) => { + const loadersSerializationObject: Record = {}; + for (const [k, v] of Object.entries(obj)) { + loadersSerializationObject[k] = getSerializationStrategy(k) === 'always' ? v : _UNINITIALIZED; + } + return loadersSerializationObject; + }; + const routeInternal = useSignal({ type: 'initial', dest: url, @@ -302,7 +342,7 @@ export const QwikRouterProvider = component$((props) => { let scroller = document.getElementById(QWIK_ROUTER_SCROLLER); if (!scroller) { scroller = document.getElementById(QWIK_CITY_SCROLLER); - if (scroller) { + if (scroller && isDev) { console.warn( `Please update your scroller ID to "${QWIK_ROUTER_SCROLLER}" as "${QWIK_CITY_SCROLLER}" is deprecated and will be removed in V3` ); @@ -483,12 +523,28 @@ export const QwikRouterProvider = component$((props) => { } const loaders = clientPageData?.loaders; - const win = window as ClientSPAWindow; if (loaders) { - Object.assign(loaderState, loaders); + const container = _getContextContainer(); + for (const [key, value] of Object.entries(loaders)) { + const signal = loaderState[key]; + const awaitedValue = await value; + loadersObject[key] = awaitedValue; + if (!signal) { + loaderState[key] = createLoaderSignal( + loadersObject, + key, + trackUrl, + DEFAULT_LOADERS_SERIALIZATION_STRATEGY, + container + ); + } else { + signal.invalidate(); + } + } } CLIENT_DATA_CACHE.clear(); + const win = window as ClientSPAWindow; if (!win._qRouterSPA) { // only add event listener once win._qRouterSPA = true; @@ -681,7 +737,7 @@ export const QwikRouterProvider = component$((props) => { }; _waitNextPage().then(() => { const container = _getQContainerElement(elm as _ElementVNode)!; - container.setAttribute('q:route', routeName); + container.setAttribute(Q_ROUTE, routeName); const scrollState = currentScrollState(scroller); saveScrollHistory(scrollState); win._qRouterScrollEnabled = true; @@ -739,7 +795,7 @@ export const QwikRouterMockProvider = component$((props) => { deep: false } ); - const loaderState = useSignal({}); + const loaderState = {}; const routeInternal = useSignal({ type: 'initial', dest: url }); const goto: RouteNavigate = diff --git a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md index 63053ca355f..ba9e8ef0089 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md +++ b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md @@ -23,6 +23,7 @@ import { RequestEventCommon } from '@qwik.dev/router/middleware/request-handler' import { RequestEventLoader } from '@qwik.dev/router/middleware/request-handler'; import { RequestHandler } from '@qwik.dev/router/middleware/request-handler'; import type { ResolveSyncValue } from '@qwik.dev/router/middleware/request-handler'; +import type { SerializationStrategy } from '@qwik.dev/core/internal'; import type * as v from 'valibot'; import type { ValueOrPromise } from '@qwik.dev/core'; import { z } from 'zod'; diff --git a/packages/qwik-router/src/runtime/src/routing.ts b/packages/qwik-router/src/runtime/src/routing.ts index 2fbe62ba5e3..0412bb8ed95 100644 --- a/packages/qwik-router/src/runtime/src/routing.ts +++ b/packages/qwik-router/src/runtime/src/routing.ts @@ -1,17 +1,18 @@ import { MODULE_CACHE } from './constants'; import { matchRoute } from './route-matcher'; -import type { - ContentMenu, - LoadedRoute, - MenuData, - MenuModule, - ModuleLoader, - RouteData, - RouteModule, +import { + type ContentMenu, + type LoadedRoute, + type MenuData, + MenuDataProp, + type MenuModule, + type ModuleLoader, + type RouteData, + RouteDataProp, + type RouteModule, } from './types'; import { deepFreeze } from './utils'; -export const CACHE = new Map>(); /** LoadRoute() runs in both client and server. */ export const loadRoute = async ( routes: RouteData[] | undefined, @@ -23,13 +24,13 @@ export const loadRoute = async ( return null; } for (const routeData of routes) { - const routeName = routeData[0]; + const routeName = routeData[RouteDataProp.RouteName]; const params = matchRoute(routeName, pathname); if (!params) { continue; } - const loaders = routeData[1]; - const routeBundleNames = routeData[3]; + const loaders = routeData[RouteDataProp.Loaders]; + const routeBundleNames = routeData[RouteDataProp.RouteBundleNames]; const modules: RouteModule[] = new Array(loaders.length); const pendingLoads: Promise[] = []; @@ -93,10 +94,12 @@ export const getMenuLoader = (menus: MenuData[] | undefined, pathname: string) = if (menus) { pathname = pathname.endsWith('/') ? pathname : pathname + '/'; const menu = menus.find( - (m) => m[0] === pathname || pathname.startsWith(m[0] + (pathname.endsWith('/') ? '' : '/')) + (m) => + m[MenuDataProp.Pathname] === pathname || + pathname.startsWith(m[MenuDataProp.Pathname] + (pathname.endsWith('/') ? '' : '/')) ); if (menu) { - return menu[1]; + return menu[MenuDataProp.MenuLoader]; } } }; diff --git a/packages/qwik-router/src/runtime/src/server-functions.ts b/packages/qwik-router/src/runtime/src/server-functions.ts index d065522cd54..9025be6d6d0 100644 --- a/packages/qwik-router/src/runtime/src/server-functions.ts +++ b/packages/qwik-router/src/runtime/src/server-functions.ts @@ -1,8 +1,10 @@ import { $, implicit$FirstArg, + isDev, + isServer, noSerialize, - useContext, + untrack, useStore, type QRL, type ValueOrPromise, @@ -12,26 +14,34 @@ import { _getContextElement, _getContextEvent, _serialize, - _wrapStore, + _UNINITIALIZED, + _useInvokeContext, + type SerializationStrategy, } from '@qwik.dev/core/internal'; import * as v from 'valibot'; import { z } from 'zod'; import type { RequestEventLoader } from '../../middleware/request-handler/types'; -import { QACTION_KEY, QDATA_KEY, QFN_KEY } from './constants'; +import { + DEFAULT_LOADERS_SERIALIZATION_STRATEGY, + QACTION_KEY, + QDATA_KEY, + QFN_KEY, +} from './constants'; import { RouteStateContext } from './contexts'; import type { ActionConstructor, ActionConstructorQRL, ActionInternal, + ActionOptions, ActionStore, - CommonLoaderActionOptions, DataValidator, Editable, JSONObject, LoaderConstructor, LoaderConstructorQRL, LoaderInternal, + LoaderOptions, RequestEvent, RequestEventAction, RequestEventBase, @@ -52,15 +62,13 @@ import type { } from './types'; import { useAction, useLocation, useQwikRouterEnv } from './use-functions'; -import { isDev, isServer } from '@qwik.dev/core'; - import type { FormSubmitCompletedDetail } from './form-component'; import { deepFreeze } from './utils'; /** @internal */ export const routeActionQrl = (( actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, - ...rest: (CommonLoaderActionOptions | DataValidator)[] + ...rest: (ActionOptions | DataValidator)[] ) => { const { id, validators } = getValidators(rest, actionQrl); function action() { @@ -164,7 +172,7 @@ Action.run() can only be called on the browser, for example when a user clicks a /** @internal */ export const globalActionQrl = (( actionQrl: QRL<(form: JSONObject, event: RequestEventAction) => unknown>, - ...rest: (CommonLoaderActionOptions | DataValidator)[] + ...rest: (ActionOptions | DataValidator)[] ) => { const action = routeActionQrl(actionQrl, ...(rest as any)); if (isServer) { @@ -189,26 +197,36 @@ export const globalAction$: ActionConstructor = /*#__PURE__*/ implicit$FirstArg( /** @internal */ export const routeLoaderQrl = (( loaderQrl: QRL<(event: RequestEventLoader) => unknown>, - ...rest: (CommonLoaderActionOptions | DataValidator)[] + ...rest: (LoaderOptions | DataValidator)[] ): LoaderInternal => { - const { id, validators } = getValidators(rest, loaderQrl); + const { id, validators, serializationStrategy } = getValidators(rest, loaderQrl); function loader() { - return useContext(RouteStateContext, (state) => { - if (!(id in state)) { - throw new Error(`routeLoader$ "${loaderQrl.getSymbol()}" was invoked in a route where it was not declared. + const iCtx = _useInvokeContext(); + const state = iCtx.$container$.resolveContext(iCtx.$hostElement$, RouteStateContext)!; + + if (!(id in state)) { + throw new Error(`routeLoader$ "${loaderQrl.getSymbol()}" was invoked in a route where it was not declared. This is because the routeLoader$ was not exported in a 'layout.tsx' or 'index.tsx' file of the existing route. For more information check: https://qwik.dev/docs/route-loader/ If your are managing reusable logic or a library it is essential that this function is re-exported from within 'layout.tsx' or 'index.tsx file of the existing route otherwise it will not run or throw exception. For more information check: https://qwik.dev/docs/re-exporting-loaders/`); - } - return _wrapStore(state, id); - }); + } + // Force the signal to be initialized. + // It is an async computed signal. + // We have two options: + // - we already have data from signal or from previous fetch + // - we don't have data yet, so we need to fetch it from the server + // Calling it will trigger fetch the data from the server. + // Under the hood, it will throw a promise and await for it, so the client will load the data synchronously. + untrack(() => state[id].value); + return state[id]; } loader.__brand = 'server_loader' as const; loader.__qrl = loaderQrl; loader.__validators = validators; loader.__id = id; + loader.__serializationStrategy = serializationStrategy; Object.freeze(loader); return loader; @@ -498,8 +516,9 @@ export const serverQrl = ( /** @public */ export const server$ = /*#__PURE__*/ implicit$FirstArg(serverQrl); -const getValidators = (rest: (CommonLoaderActionOptions | DataValidator)[], qrl: QRL) => { +const getValidators = (rest: (LoaderOptions | DataValidator)[], qrl: QRL) => { let id: string | undefined; + let serializationStrategy: SerializationStrategy = DEFAULT_LOADERS_SERIALIZATION_STRATEGY; const validators: DataValidator[] = []; if (rest.length === 1) { const options = rest[0]; @@ -508,6 +527,9 @@ const getValidators = (rest: (CommonLoaderActionOptions | DataValidator)[], qrl: validators.push(options); } else { id = options.id; + if (options.serializationStrategy) { + serializationStrategy = options.serializationStrategy; + } if (options.validation) { validators.push(...options.validation); } @@ -530,6 +552,7 @@ const getValidators = (rest: (CommonLoaderActionOptions | DataValidator)[], qrl: return { validators: validators.reverse(), id, + serializationStrategy, }; }; diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index f0acf05df0c..4f15cbe84c6 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -6,6 +6,7 @@ import type { Signal, ValueOrPromise, } from '@qwik.dev/core'; +import type { SerializationStrategy } from '@qwik.dev/core/internal'; import type { EnvGetter, RequestEvent, @@ -262,9 +263,21 @@ export type RouteData = routeBundleNames: string[], ]; +export const enum RouteDataProp { + RouteName, + Loaders, + OriginalPathname, + RouteBundleNames, +} + /** @public */ export type MenuData = [pathname: string, menuLoader: MenuModuleLoader]; +export const enum MenuDataProp { + Pathname, + MenuLoader, +} + /** * @deprecated Use `QwikRouterConfig` instead. Will be removed in V3. * @public @@ -296,30 +309,32 @@ export type LoadedRoute = [ routeBundleNames: string[] | undefined, ]; -export interface LoadedContent extends LoadedRoute { - pageModule: PageModule; +export const enum LoadedRouteProp { + RouteName, + Params, + Mods, + Menu, + RouteBundleNames, } -export type RequestHandlerBody = BODY | string | number | boolean | undefined | null | void; - -export type RequestHandlerBodyFunction = () => - | RequestHandlerBody - | Promise>; - export interface EndpointResponse { status: number; loaders: Record; + loadersSerializationStrategy: Map; formData?: FormData; action?: string; } -export interface ClientPageData extends Omit { - status: number; +export interface ClientPageData extends Omit { href: string; redirect?: string; isRewrite?: boolean; } +export interface LoaderData { + loaders: Record; +} + /** @public */ export type StaticGenerateHandler = ({ env, @@ -393,10 +408,10 @@ export type GetValidatorType = GetValidatorOutputType; /** @public */ -export interface CommonLoaderActionOptions { +export type ActionOptions = { readonly id?: string; readonly validation?: DataValidator[]; -} +}; /** @public */ export type FailOfRest = REST extends readonly DataValidator< @@ -637,7 +652,9 @@ export type ActionConstructorQRL = { /** @public */ export type LoaderOptions = { - id?: string; + readonly id?: string; + readonly validation?: DataValidator[]; + readonly serializationStrategy?: SerializationStrategy; }; /** @public */ @@ -670,8 +687,6 @@ export type LoaderConstructorQRL = { ): Loader>>>; }; -export type LoaderStateHolder = Record>; - /** @public */ export type ActionReturn = { readonly status?: number; @@ -783,6 +798,7 @@ export interface LoaderInternal extends Loader { __qrl: QRL<(event: RequestEventLoader) => ValueOrPromise>; __id: string; __validators: DataValidator[] | undefined; + __serializationStrategy: SerializationStrategy; (): LoaderSignal; } diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index cd0d26632a7..158a20eb95b 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -4,19 +4,26 @@ import type { ClientPageData, RouteActionValue } from './types'; import { _deserialize } from '@qwik.dev/core/internal'; import { prefetchSymbols } from './client-navigate'; +const MAX_Q_DATA_RETRY_COUNT = 3; + export const loadClientData = async ( url: URL, element: unknown, opts?: { action?: RouteActionValue; + loaderIds?: string[]; clearCache?: boolean; prefetchSymbols?: boolean; isPrefetch?: boolean; - } -) => { + }, + retryCount: number = 0 +): Promise => { const pagePathname = url.pathname; const pageSearch = url.search; - const clientDataPath = getClientDataPath(pagePathname, pageSearch, opts?.action); + const clientDataPath = getClientDataPath(pagePathname, pageSearch, { + actionId: opts?.action?.id, + loaderIds: opts?.loaderIds, + }); let qData: Promise | undefined; if (!opts?.action) { qData = CLIENT_DATA_CACHE.get(clientDataPath); @@ -33,6 +40,12 @@ export const loadClientData = async ( opts.action.data = undefined; } qData = fetch(clientDataPath, fetchOptions).then((rsp) => { + if (rsp.status === 404 && opts?.loaderIds && retryCount < MAX_Q_DATA_RETRY_COUNT) { + // retry if the q-data.json is not found with all options + // we want to retry with all the loaders + opts.loaderIds = undefined; + return loadClientData(url, element, opts, retryCount + 1); + } if (rsp.redirected) { const redirectedURL = new URL(rsp.url); const isQData = redirectedURL.pathname.endsWith('/q-data.json'); diff --git a/packages/qwik-router/src/runtime/src/utils.ts b/packages/qwik-router/src/runtime/src/utils.ts index c540b61e136..1f0df86e857 100644 --- a/packages/qwik-router/src/runtime/src/utils.ts +++ b/packages/qwik-router/src/runtime/src/utils.ts @@ -1,6 +1,13 @@ -import type { RouteActionValue, SimpleURL } from './types'; +import type { SimpleURL } from './types'; -import { QACTION_KEY } from './constants'; +import { createAsyncComputed$, isBrowser } from '@qwik.dev/core'; +import { + _UNINITIALIZED, + type ClientContainer, + type SerializationStrategy, +} from '@qwik.dev/core/internal'; +import { QACTION_KEY, QLOADER_KEY } from './constants'; +import { loadClientData } from './use-endpoint'; /** Gets an absolute url path string (url.pathname + url.search + url.hash) */ export const toPath = (url: URL) => url.pathname + url.search + url.hash; @@ -31,11 +38,19 @@ export const isSameOriginDifferentPathname = (a: SimpleURL, b: SimpleURL) => export const getClientDataPath = ( pathname: string, pageSearch?: string, - action?: RouteActionValue + options?: { + actionId?: string; + loaderIds?: string[]; + } ) => { let search = pageSearch ?? ''; - if (action) { - search += (search ? '&' : '?') + QACTION_KEY + '=' + encodeURIComponent(action.id); + if (options?.actionId) { + search += (search ? '&' : '?') + QACTION_KEY + '=' + encodeURIComponent(options.actionId); + } + if (options?.loaderIds) { + for (const loaderId of options.loaderIds) { + search += (search ? '&' : '?') + QLOADER_KEY + '=' + encodeURIComponent(loaderId); + } } return pathname + (pathname.endsWith('/') ? '' : '/') + 'q-data.json' + search; }; @@ -86,3 +101,27 @@ export const deepFreeze = (obj: any) => { }); return Object.freeze(obj); }; + +export const createLoaderSignal = ( + loadersObject: Record, + loaderId: string, + url: URL, + serializationStrategy: SerializationStrategy, + container?: ClientContainer +) => { + return createAsyncComputed$( + async () => { + if (isBrowser && loadersObject[loaderId] === _UNINITIALIZED) { + const data = await loadClientData(url, undefined, { + loaderIds: [loaderId], + }); + loadersObject[loaderId] = data?.loaders[loaderId] ?? _UNINITIALIZED; + } + return loadersObject[loaderId]; + }, + { + container: container as ClientContainer, + serializationStrategy, + } + ); +}; diff --git a/packages/qwik-router/src/static/not-found.ts b/packages/qwik-router/src/static/not-found.ts index 4584aeb5e56..d29a7dc2ee1 100644 --- a/packages/qwik-router/src/static/not-found.ts +++ b/packages/qwik-router/src/static/not-found.ts @@ -1,6 +1,7 @@ import type { RouteData } from '@qwik.dev/router'; import { getErrorHtml } from '@qwik.dev/router/middleware/request-handler'; import type { StaticGenerateOptions, System } from './types'; +import { RouteDataProp } from '../runtime/src/types'; export async function generateNotFoundPages( sys: System, @@ -11,7 +12,9 @@ export async function generateNotFoundPages( const basePathname = opts.basePathname || '/'; const rootNotFoundPathname = basePathname + '404.html'; - const hasRootNotFound = routes.some((r) => r[2] === rootNotFoundPathname); + const hasRootNotFound = routes.some( + (r) => r[RouteDataProp.OriginalPathname] === rootNotFoundPathname + ); if (!hasRootNotFound) { const filePath = sys.getRouteFilePath(rootNotFoundPathname, true); diff --git a/packages/qwik-router/src/static/worker-thread.ts b/packages/qwik-router/src/static/worker-thread.ts index 82da1350300..497ea2dfb53 100644 --- a/packages/qwik-router/src/static/worker-thread.ts +++ b/packages/qwik-router/src/static/worker-thread.ts @@ -1,6 +1,6 @@ import { _deserialize, _serialize, _verifySerializable } from '@qwik.dev/core/internal'; import type { ServerRequestEvent } from '@qwik.dev/router/middleware/request-handler'; -import { requestHandler } from '@qwik.dev/router/middleware/request-handler'; +import { requestHandler, RequestEvShareQData } from '@qwik.dev/router/middleware/request-handler'; import { WritableStream } from 'node:stream/web'; import { pathToFileURL } from 'node:url'; import type { QwikSerializer } from '../middleware/request-handler/types'; @@ -178,7 +178,7 @@ async function workerRender( try { if (writeQDataEnabled) { - const qData: ClientPageData = requestEv.sharedMap.get('qData'); + const qData: ClientPageData = requestEv.sharedMap.get(RequestEvShareQData); if (qData && !is404ErrorPage) { // write q-data.json file when enabled and qData is set const qDataFilePath = sys.getDataFilePath(url.pathname); diff --git a/packages/qwik-router/src/utils/fs.unit.ts b/packages/qwik-router/src/utils/fs.unit.ts index 861ba5ae7ba..ade081e51c3 100644 --- a/packages/qwik-router/src/utils/fs.unit.ts +++ b/packages/qwik-router/src/utils/fs.unit.ts @@ -265,6 +265,7 @@ test('createFileId, Menu', () => { mdx: {}, platform: {}, rewriteRoutes: [], + defaultLoadersSerializationStrategy: 'never', }; const pathname = getPathnameFromDirPath(opts, t.dirPath); assert.equal(pathname, t.expect, t.dirPath); @@ -366,6 +367,7 @@ test('parseRouteIndexName', () => { mdx: {}, platform: {}, rewriteRoutes: [], + defaultLoadersSerializationStrategy: 'never', }; const pathname = getMenuPathname(opts, t.filePath); assert.equal(pathname, t.expect); diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts index e9c2e570725..8cdaaace403 100644 --- a/packages/qwik/public.d.ts +++ b/packages/qwik/public.d.ts @@ -6,6 +6,7 @@ export { ComputedSignal, ContextId, createComputed$, + createAsyncComputed$, createSerializer$, createContextId, createSignal, diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index b04984c184c..77068bd48b6 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -85,13 +85,12 @@ export type { QwikHTMLElements, QwikSVGElements, SVGAttributes, - HTMLElementAttrs, - SVGProps, } from './shared/jsx/types/jsx-generated'; export { render } from './client/dom-render'; export { getDomContainer, _getQContainerElement } from './client/dom-container'; export type { StreamWriter, RenderSSROptions } from './ssr/ssr-types'; export type { RenderOptions, RenderResult } from './client/types'; +export type { SerializationStrategy } from './shared/types'; ////////////////////////////////////////////////////////////////////////////////////////// // use API @@ -150,7 +149,10 @@ export { createComputed$, createSerializerQrl, createSerializer$, + createAsyncComputedQrl, + createAsyncComputed$, } from './reactive-primitives/signal.public'; +export type { ComputedOptions } from './reactive-primitives/types'; ////////////////////////////////////////////////////////////////////////////////////////// // Developer Low-Level API diff --git a/packages/qwik/src/core/internal.ts b/packages/qwik/src/core/internal.ts index 766b94aa3c1..7d1bf0c6d32 100644 --- a/packages/qwik/src/core/internal.ts +++ b/packages/qwik/src/core/internal.ts @@ -16,7 +16,7 @@ export type { VNodeFlags as _VNodeFlags, } from './client/types'; export { vnode_toString as _vnode_toString } from './client/vnode'; -export { _wrapProp, _wrapSignal, _wrapStore } from './reactive-primitives/internal-api'; +export { _wrapProp, _wrapSignal } from './reactive-primitives/internal-api'; export { SubscriptionData as _SubscriptionData } from './reactive-primitives/subscription-data'; export { _EFFECT_BACK_REF } from './reactive-primitives/types'; export { @@ -37,20 +37,20 @@ export { _deserialize, dumpState as _dumpState, preprocessState as _preprocessState, + _serializationWeakRef, _serialize, } from './shared/shared-serialization'; -export { _CONST_PROPS, _IMMUTABLE, _VAR_PROPS } from './shared/utils/constants'; +export { _CONST_PROPS, _IMMUTABLE, _VAR_PROPS, _UNINITIALIZED } from './shared/utils/constants'; export { EMPTY_ARRAY as _EMPTY_ARRAY } from './shared/utils/flyweight'; export { _restProps } from './shared/utils/prop'; -export { - verifySerializable as _verifySerializable, - _weakSerialize, -} from './shared/utils/serialize-utils'; +export { verifySerializable as _verifySerializable } from './shared/utils/serialize-utils'; export { _walkJSX } from './ssr/ssr-render-jsx'; export { _getContextElement, _getContextEvent, + _getContextContainer, _jsxBranch, + useInvokeContext as _useInvokeContext, _waitUntilRendered, } from './use/use-core'; export { scheduleTask as _task } from './use/use-task'; diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index e24774dbfe4..e68e4ecc99e 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -4,12 +4,12 @@ ```ts +import { ComputedSignal as ComputedSignal_2 } from '..'; import * as CSS_2 from 'csstype'; import { isBrowser } from '@qwik.dev/core/build'; import { isDev } from '@qwik.dev/core/build'; import { isServer } from '@qwik.dev/core/build'; import { QRL as QRL_2 } from './qrl.public'; -import { ReadonlySignal as ReadonlySignal_2 } from '..'; import type { StreamWriter as StreamWriter_2 } from '@qwik.dev/core'; import { ValueOrPromise as ValueOrPromise_2 } from '..'; @@ -22,7 +22,7 @@ export const $: (expression: T) => QRL; export type AsyncComputedFn = (ctx: AsyncComputedCtx) => Promise; // @public (undocumented) -export interface AsyncComputedReadonlySignal extends ReadonlySignal { +export interface AsyncComputedReadonlySignal extends ComputedSignal { } // @public (undocumented) @@ -87,11 +87,20 @@ export const componentQrl: >(componentQrl: QRL = () => T; // @public (undocumented) -export type ComputedReturnType = T extends Promise ? never : ReadonlySignal; +export interface ComputedOptions { + // (undocumented) + container?: Container; + // (undocumented) + serializationStrategy?: SerializationStrategy; +} + +// @public (undocumented) +export type ComputedReturnType = T extends Promise ? never : ComputedSignal; // @public export interface ComputedSignal extends ReadonlySignal { force(): void; + invalidate(): void; } // @internal (undocumented) @@ -129,13 +138,22 @@ export interface CorrectedToggleEvent extends Event { } // @public -export const createComputed$: (qrl: () => T) => T extends Promise ? never : ComputedSignal; +export const createAsyncComputed$: (qrl: () => Promise, options?: ComputedOptions) => AsyncComputedReturnType; + +// Warning: (ae-forgotten-export) The symbol "AsyncComputedSignalImpl" needs to be exported by the entry point index.d.ts +// Warning: (ae-internal-missing-underscore) The name "createAsyncComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export const createAsyncComputedQrl: (qrl: QRL<(ctx: AsyncComputedCtx) => Promise>, options?: ComputedOptions) => AsyncComputedSignalImpl; + +// @public +export const createComputed$: (qrl: () => T, options?: ComputedOptions) => ComputedReturnType; // Warning: (ae-forgotten-export) The symbol "ComputedSignalImpl" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "createComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const createComputedQrl: (qrl: QRL<() => T>) => ComputedSignalImpl; +export const createComputedQrl: (qrl: QRL<() => T>, options?: ComputedOptions) => ComputedSignalImpl; // @public export const createContextId: (name: string) => ContextId; @@ -323,6 +341,9 @@ export type FunctionComponent

= { renderFn(props: P, key: string | null, flags: number, dev?: DevJSX): JSXOutput; }['renderFn']; +// @internal (undocumented) +export const _getContextContainer: () => ClientContainer | undefined; + // @internal (undocumented) export const _getContextElement: () => unknown; @@ -351,13 +372,6 @@ function h, PROPS extends {} = {} export { h as createElement } export { h } -// Warning: (ae-forgotten-export) The symbol "HTMLAttributesBase" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "FilterBase" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export interface HTMLElementAttrs extends HTMLAttributesBase, FilterBase { -} - // @internal @deprecated (undocumented) export const _IMMUTABLE: unique symbol; @@ -652,6 +666,7 @@ export type QwikFocusEvent = NativeFocusEvent; // Warning: (ae-forgotten-export) The symbol "Augmented" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "SpecialAttrs" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "HTMLElementAttrs" needs to be exported by the entry point index.d.ts // // @public export type QwikHTMLElements = { @@ -708,6 +723,8 @@ export type QwikPointerEvent = NativePointerEvent; // @public @deprecated (undocumented) export type QwikSubmitEvent = SubmitEvent; +// Warning: (ae-forgotten-export) The symbol "SVGProps" needs to be exported by the entry point index.d.ts +// // @public export type QwikSVGElements = { [K in keyof Omit]: SVGProps; @@ -857,6 +874,14 @@ export const _restProps: (props: PropsProxy, omit: string[], target?: Props) => // @internal export const _run: (...args: unknown[]) => ValueOrPromise_2; +// @public (undocumented) +export type SerializationStrategy = 'never' | 'always'; + +// Warning: (ae-forgotten-export) The symbol "SerializationWeakRef" needs to be exported by the entry point index.d.ts +// +// @internal (undocumented) +export const _serializationWeakRef: (obj: unknown) => SerializationWeakRef; + // @internal export function _serialize(data: unknown[]): Promise; @@ -1573,10 +1598,6 @@ export interface SVGAttributes extends AriaAttribut zoomAndPan?: string | undefined; } -// @public (undocumented) -export interface SVGProps extends SVGAttributes, QwikAttributes { -} - // @public export const sync$: (fn: T) => SyncQRL; @@ -1628,6 +1649,9 @@ export interface Tracker { (obj: T, prop: P): T[P]; } +// @internal (undocumented) +export const _UNINITIALIZED: unique symbol; + // @public export const untrack: (fn: () => T) => T; @@ -1635,20 +1659,20 @@ export const untrack: (fn: () => T) => T; export const unwrapStore: (value: T) => T; // @public -export const useAsyncComputed$: (qrl: AsyncComputedFn) => AsyncComputedReturnType; +export const useAsyncComputed$: (qrl: AsyncComputedFn, options?: ComputedOptions | undefined) => AsyncComputedReturnType; // Warning: (ae-internal-missing-underscore) The name "useAsyncComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const useAsyncComputedQrl: (qrl: QRL>) => AsyncComputedReturnType; +export const useAsyncComputedQrl: (qrl: QRL>, options?: ComputedOptions) => AsyncComputedReturnType; // @public -export const useComputed$: (qrl: ComputedFn) => ComputedReturnType; +export const useComputed$: (qrl: ComputedFn, options?: ComputedOptions | undefined) => ComputedReturnType; // Warning: (ae-internal-missing-underscore) The name "useComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const useComputedQrl: (qrl: QRL>) => ComputedReturnType; +export const useComputedQrl: (qrl: QRL>, options?: ComputedOptions) => ComputedReturnType; // @public export const useConstant: (value: (() => T) | T) => T; @@ -1667,6 +1691,11 @@ export const useErrorBoundary: () => ErrorBoundaryStore; // @public (undocumented) export const useId: () => string; +// Warning: (ae-forgotten-export) The symbol "RenderInvokeContext" needs to be exported by the entry point index.d.ts +// +// @internal (undocumented) +export const _useInvokeContext: () => RenderInvokeContext; + // Warning: (ae-internal-missing-underscore) The name "useLexicalScope" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -1697,7 +1726,7 @@ 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_2; +export const useSerializerQrl: (qrl: QRL>) => ComputedSignal_2; // @public (undocumented) export function useServerData(key: string): T | undefined; @@ -1849,9 +1878,6 @@ export function _walkJSX(ssr: SSRContainer, value: JSXOutput, options: { parentComponentFrame: ISsrComponentFrame | null; }): Promise; -// @internal (undocumented) -export const _weakSerialize: (input: T) => Partial; - // @public export function withLocale(locale: string, fn: () => T): T; @@ -1861,9 +1887,6 @@ export const _wrapProp: , P extends keyof T>(...args: // @internal @deprecated (undocumented) export const _wrapSignal: , P extends keyof T>(obj: T, prop: P) => any; -// @internal (undocumented) -export const _wrapStore: , P extends keyof T>(obj: T, prop: P) => Signal; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts index bb98493cbaa..2e9d53023e0 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts @@ -4,8 +4,8 @@ import { ChoreType } from '../../shared/util-chore-type'; import { isPromise } from '../../shared/utils/promises'; import { cleanupFn, trackFn } from '../../use/utils/tracker'; import type { BackRef } from '../cleanup'; -import type { AsyncComputeQRL, EffectSubscription } from '../types'; -import { _EFFECT_BACK_REF, EffectProperty, SignalFlags } from '../types'; +import { AsyncComputeQRL, ComputedSignalFlags, EffectSubscription } from '../types'; +import { _EFFECT_BACK_REF, EffectProperty, NEEDS_COMPUTATION, SignalFlags } from '../types'; import { throwIfQRLNotResolved } from '../utils'; import { ComputedSignalImpl } from './computed-signal-impl'; import { setupSignalValueAccess } from './signal-impl'; @@ -33,11 +33,15 @@ export class AsyncComputedSignalImpl $loadingEffects$: null | Set = null; $errorEffects$: null | Set = null; $destroy$: NoSerialize<() => void> | null; - private $promiseValue$: T | null = null; + private $promiseValue$: T | typeof NEEDS_COMPUTATION = NEEDS_COMPUTATION; [_EFFECT_BACK_REF]: Map | null = null; - constructor(container: Container | null, fn: AsyncComputeQRL, flags = SignalFlags.INVALID) { + constructor( + container: Container | null, + fn: AsyncComputeQRL, + flags: SignalFlags | ComputedSignalFlags = SignalFlags.INVALID + ) { super(container, fn, flags); } @@ -94,6 +98,11 @@ export class AsyncComputedSignalImpl return this.$untrackedError$; } + override invalidate() { + super.invalidate(); + this.$promiseValue$ = NEEDS_COMPUTATION; + } + $computeIfNeeded$() { if (!(this.$flags$ & SignalFlags.INVALID)) { return false; @@ -103,11 +112,12 @@ export class AsyncComputedSignalImpl const [cleanup] = cleanupFn(this, (err) => this.$container$?.handleError(err, null!)); const untrackedValue = - this.$promiseValue$ ?? - (computeQrl.getFn()({ - track: trackFn(this, this.$container$), - cleanup, - }) as T); + this.$promiseValue$ === NEEDS_COMPUTATION + ? (computeQrl.getFn()({ + track: trackFn(this, this.$container$), + cleanup, + }) as T) + : this.$promiseValue$; if (isPromise(untrackedValue)) { this.untrackedLoading = true; this.untrackedError = null; @@ -118,11 +128,12 @@ export class AsyncComputedSignalImpl this.untrackedError = null; }) .catch((err) => { + this.$promiseValue$ = err; this.untrackedLoading = false; this.untrackedError = err; }); } - this.$promiseValue$ = null; + this.$promiseValue$ = NEEDS_COMPUTATION; DEBUG && log('Signal.$asyncCompute$', untrackedValue); this.$flags$ &= ~SignalFlags.INVALID; diff --git a/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts index 0595c7d5471..ddd68e42b66 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts @@ -8,7 +8,7 @@ import { tryGetInvokeContext } from '../../use/use-core'; import { throwIfQRLNotResolved } from '../utils'; import type { BackRef } from '../cleanup'; import { getSubscriber } from '../subscriber'; -import type { ComputeQRL, EffectSubscription } from '../types'; +import { ComputedSignalFlags, ComputeQRL, EffectSubscription } from '../types'; import { _EFFECT_BACK_REF, EffectProperty, NEEDS_COMPUTATION, SignalFlags } from '../types'; import { SignalImpl } from './signal-impl'; import type { QRLInternal } from '../../shared/qrl/qrl-class'; @@ -33,7 +33,7 @@ export class ComputedSignalImpl> * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) */ $computeQrl$: S; - $flags$: SignalFlags; + $flags$: SignalFlags | ComputedSignalFlags; $forceRunEffects$: boolean = false; [_EFFECT_BACK_REF]: Map | null = null; @@ -42,7 +42,8 @@ export class ComputedSignalImpl> fn: S, // We need a separate flag to know when the computation needs running because // we need the old value to know if effects need running after computation - flags = SignalFlags.INVALID + flags: SignalFlags | ComputedSignalFlags = SignalFlags.INVALID | + ComputedSignalFlags.SERIALIZATION_STRATEGY_ALWAYS ) { // 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. @@ -51,7 +52,7 @@ export class ComputedSignalImpl> this.$flags$ = flags; } - $invalidate$() { + invalidate() { this.$flags$ |= SignalFlags.INVALID; this.$forceRunEffects$ = false; this.$container$?.$scheduler$( diff --git a/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts index fc351d7515f..254fddb28be 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts @@ -4,7 +4,13 @@ import type { Container } from '../../shared/types'; import { trackSignal } from '../../use/use-core'; import { throwIfQRLNotResolved } from '../utils'; import type { SerializerArg } from '../types'; -import { EffectProperty, NEEDS_COMPUTATION, SignalFlags, type ComputeQRL } from '../types'; +import { + ComputedSignalFlags, + EffectProperty, + NEEDS_COMPUTATION, + SignalFlags, + type ComputeQRL, +} from '../types'; import { ComputedSignalImpl } from './computed-signal-impl'; const DEBUG = false; @@ -19,7 +25,11 @@ const log = (...args: any[]) => console.log('SERIALIZER SIGNAL', ...args.map(qwi */ export class SerializerSignalImpl extends ComputedSignalImpl { constructor(container: Container | null, argQrl: QRLInternal>) { - super(container, argQrl as unknown as ComputeQRL); + super( + container, + argQrl as unknown as ComputeQRL, + SignalFlags.INVALID | ComputedSignalFlags.SERIALIZATION_STRATEGY_ALWAYS + ); } $didInitialize$: boolean = false; @@ -39,7 +49,7 @@ export class SerializerSignalImpl extends ComputedSignalImpl { const untrackedValue = trackSignal( () => this.$didInitialize$ - ? update?.(currentValue as T) + ? update?.(currentValue as T) || currentValue : deserialize(currentValue as Awaited), this, EffectProperty.VNODE, diff --git a/packages/qwik/src/core/reactive-primitives/impl/store.ts b/packages/qwik/src/core/reactive-primitives/impl/store.ts index 9f101298bf1..04701c6072e 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/store.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/store.ts @@ -1,7 +1,7 @@ import { pad, qwikDebugToString } from '../../debug'; import { assertTrue } from '../../shared/error/assert'; import { tryGetInvokeContext } from '../../use/use-core'; -import { isSerializableObject } from '../../shared/utils/types'; +import { isObject, isSerializableObject } from '../../shared/utils/types'; import type { Container } from '../../shared/types'; import { addQrlToSerializationCtx, @@ -124,8 +124,7 @@ export class StoreHandler implements ProxyHandler { const flags = this.$flags$; if ( flags & StoreFlags.RECURSIVE && - typeof value === 'object' && - value !== null && + isObject(value) && !Object.isFrozen(value) && !isStore(value) && !Object.isFrozen(target) diff --git a/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts index 863da6841e8..0b2ed85c181 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts @@ -3,7 +3,6 @@ import { QError, qError } from '../../shared/error/error'; import type { Container, HostElement } from '../../shared/types'; import { ChoreType } from '../../shared/util-chore-type'; import { trackSignal } from '../../use/use-core'; -import { triggerEffects } from '../utils'; import type { BackRef } from '../cleanup'; import type { AllSignalFlags, EffectSubscription } from '../types'; import { @@ -41,7 +40,7 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { this.$flags$ = flags; } - $invalidate$() { + invalidate() { this.$flags$ |= SignalFlags.INVALID; this.$forceRunEffects$ = false; this.$container$?.$scheduler$( @@ -57,9 +56,13 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { * remained the same object. */ force() { - this.$flags$ |= SignalFlags.INVALID; - this.$forceRunEffects$ = false; - triggerEffects(this.$container$, this, this.$effects$); + this.$forceRunEffects$ = true; + this.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + this.$hostElement$, + this, + this.$effects$ + ); } get untrackedValue() { diff --git a/packages/qwik/src/core/reactive-primitives/internal-api.ts b/packages/qwik/src/core/reactive-primitives/internal-api.ts index cc508f0ce30..7a9e169d589 100644 --- a/packages/qwik/src/core/reactive-primitives/internal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/internal-api.ts @@ -1,10 +1,10 @@ import { _CONST_PROPS, _IMMUTABLE } from '../shared/utils/constants'; import { assertEqual } from '../shared/error/assert'; import { isObject } from '../shared/utils/types'; -import { isSignal, type Signal } from './signal.public'; +import { isSignal } from './signal.public'; import { getStoreTarget } from './impl/store'; import { isPropsProxy } from '../shared/jsx/jsx-runtime'; -import { SignalFlags, WrappedSignalFlags } from './types'; +import { WrappedSignalFlags } from './types'; import { WrappedSignalImpl } from './impl/wrapped-signal-impl'; import { AsyncComputedSignalImpl } from './impl/async-computed-signal-impl'; @@ -63,20 +63,6 @@ export const _wrapProp = , P extends keyof T>(...args return obj[prop]; }; -/** @internal */ -export const _wrapStore = , P extends keyof T>( - obj: T, - prop: P -): Signal => { - const target = getStoreTarget(obj)!; - const value = target[prop]; - if (isSignal(value)) { - return value; - } else { - return new WrappedSignalImpl(null, getProp, [obj, prop], null, SignalFlags.INVALID); - } -}; - /** @internal @deprecated v1 compat */ export const _wrapSignal = , P extends keyof T>( obj: T, diff --git a/packages/qwik/src/core/reactive-primitives/signal-api.ts b/packages/qwik/src/core/reactive-primitives/signal-api.ts index f9d6fcf3232..4da0a450d8b 100644 --- a/packages/qwik/src/core/reactive-primitives/signal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/signal-api.ts @@ -2,11 +2,17 @@ import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { SignalImpl } from './impl/signal-impl'; import { ComputedSignalImpl } from './impl/computed-signal-impl'; -import { throwIfQRLNotResolved } from './utils'; import type { Signal } from './signal.public'; -import type { AsyncComputedCtx, AsyncComputeQRL, ComputeQRL, SerializerArg } from './types'; +import { + type AsyncComputedCtx, + type AsyncComputeQRL, + type ComputedOptions, + type ComputeQRL, + type SerializerArg, +} from './types'; import { SerializerSignalImpl } from './impl/serializer-signal-impl'; import { AsyncComputedSignalImpl } from './impl/async-computed-signal-impl'; +import { getComputedSignalFlags } from './utils'; /** @internal */ export const createSignal = (value?: T): Signal => { @@ -14,17 +20,27 @@ export const createSignal = (value?: T): Signal => { }; /** @internal */ -export const createComputedSignal = (qrl: QRL<() => T>): ComputedSignalImpl => { - throwIfQRLNotResolved(qrl); - return new ComputedSignalImpl(null, qrl as ComputeQRL); +export const createComputedSignal = ( + qrl: QRL<() => T>, + options?: ComputedOptions +): ComputedSignalImpl => { + return new ComputedSignalImpl( + options?.container || null, + qrl as ComputeQRL, + getComputedSignalFlags(options?.serializationStrategy || 'always') + ); }; /** @internal */ export const createAsyncComputedSignal = ( - qrl: QRL<(ctx: AsyncComputedCtx) => Promise> + qrl: QRL<(ctx: AsyncComputedCtx) => Promise>, + options?: ComputedOptions ): AsyncComputedSignalImpl => { - throwIfQRLNotResolved(qrl); - return new AsyncComputedSignalImpl(null, qrl as AsyncComputeQRL); + return new AsyncComputedSignalImpl( + options?.container || null, + qrl as AsyncComputeQRL, + getComputedSignalFlags(options?.serializationStrategy || 'never') + ); }; /** @internal */ @@ -35,6 +51,5 @@ export const createSerializerSignal = ( initial?: S; }> ) => { - throwIfQRLNotResolved(arg); return new SerializerSignalImpl(null, arg as any as QRLInternal>); }; diff --git a/packages/qwik/src/core/reactive-primitives/signal.public.ts b/packages/qwik/src/core/reactive-primitives/signal.public.ts index bf3e11a3963..4c79dd0e62b 100644 --- a/packages/qwik/src/core/reactive-primitives/signal.public.ts +++ b/packages/qwik/src/core/reactive-primitives/signal.public.ts @@ -1,10 +1,13 @@ import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; -import type { SerializerArg } from './types'; +import type { ComputedOptions, SerializerArg } from './types'; import { createSignal as _createSignal, createComputedSignal as createComputedQrl, createSerializerSignal as createSerializerQrl, + createAsyncComputedSignal as createAsyncComputedQrl, } from './signal-api'; +import type { ComputedReturnType } from '../use/use-computed'; +import type { AsyncComputedReturnType } from '../use/use-async-computed'; export { isSignal } from './utils'; @@ -14,7 +17,7 @@ export interface ReadonlySignal { } /** @public */ -export interface AsyncComputedReadonlySignal extends ReadonlySignal { +export interface AsyncComputedReadonlySignal extends ComputedSignal { // TODO: enable later this, after the scheduler changes for "streaming" signals values // loading: boolean; // error: Error | null; @@ -45,10 +48,16 @@ export interface Signal extends ReadonlySignal { */ export interface ComputedSignal extends ReadonlySignal { /** - * Use this to force recalculation and running subscribers, for example when the calculated value - * mutates but remains the same object. Useful for third-party libraries. + * Use this to force running subscribers, for example when the calculated value mutates but + * remains the same object. */ force(): void; + + /** + * Use this to force recalculation and running subscribers, for example when the calculated value + * mutates but remains the same object. + */ + invalidate(): void; } /** @@ -80,17 +89,32 @@ export const createSignal: { * 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. + * If you need the function to be async, use `useAsyncComputed$` instead. * * @public */ export const createComputed$: ( - qrl: () => T -) => T extends Promise ? never : ComputedSignal = /*#__PURE__*/ implicit$FirstArg( - createComputedQrl as any -); + qrl: () => T, + options?: ComputedOptions +) => ComputedReturnType = /*#__PURE__*/ implicit$FirstArg(createComputedQrl as any); export { createComputedQrl }; +/** + * Create an async computed signal which is calculated from the given QRL. A computed signal is a + * signal which is calculated from other signals or async operation. 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 can be async. + * + * @public + */ +export const createAsyncComputed$: ( + qrl: () => Promise, + options?: ComputedOptions +) => AsyncComputedReturnType = /*#__PURE__*/ implicit$FirstArg(createAsyncComputedQrl as any); +export { createAsyncComputedQrl }; + /** * Create a signal that holds a custom serializable value. See {@link useSerializer$} for more * details. diff --git a/packages/qwik/src/core/reactive-primitives/types.ts b/packages/qwik/src/core/reactive-primitives/types.ts index 5cd995464a9..46ba83715d6 100644 --- a/packages/qwik/src/core/reactive-primitives/types.ts +++ b/packages/qwik/src/core/reactive-primitives/types.ts @@ -8,6 +8,7 @@ import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { SerializerSymbol } from '../shared/utils/serialize-utils'; import type { ComputedFn } from '../use/use-computed'; import type { AsyncComputedFn } from '../use/use-async-computed'; +import type { Container, SerializationStrategy } from '../shared/types'; /** * # ================================ @@ -42,6 +43,12 @@ export type AsyncComputedCtx = { }; export type AsyncComputeQRL = QRLInternal>; +/** @public */ +export interface ComputedOptions { + serializationStrategy?: SerializationStrategy; + container?: Container; +} + export const enum SignalFlags { INVALID = 1, } @@ -51,7 +58,14 @@ export const enum WrappedSignalFlags { UNWRAP = 2, } -export type AllSignalFlags = SignalFlags | WrappedSignalFlags; +export const enum ComputedSignalFlags { + // TODO: implement this in the future + // SERIALIZATION_STRATEGY_AUTO = 4, + SERIALIZATION_STRATEGY_NEVER = 8, + SERIALIZATION_STRATEGY_ALWAYS = 16, +} + +export type AllSignalFlags = SignalFlags | WrappedSignalFlags | ComputedSignalFlags; /** * Effect is something which needs to happen (side-effect) due to signal value change. diff --git a/packages/qwik/src/core/reactive-primitives/utils.ts b/packages/qwik/src/core/reactive-primitives/utils.ts index fe898ce337c..e0fc2351b2a 100644 --- a/packages/qwik/src/core/reactive-primitives/utils.ts +++ b/packages/qwik/src/core/reactive-primitives/utils.ts @@ -5,10 +5,11 @@ import { assertDefined } from '../shared/error/assert'; 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 type { Container, HostElement } from '../shared/types'; +import type { Container, HostElement, SerializationStrategy } from '../shared/types'; import { ChoreType } from '../shared/util-chore-type'; import { ELEMENT_PROPS, OnRenderProp } from '../shared/utils/markers'; import { SerializerSymbol } from '../shared/utils/serialize-utils'; +import { isObject } from '../shared/utils/types'; import type { ISsrNode, SSRContainer } from '../ssr/ssr-types'; import { TaskFlags, isTask } from '../use/use-task'; import { ComputedSignalImpl } from './impl/computed-signal-impl'; @@ -17,8 +18,10 @@ import type { WrappedSignalImpl } from './impl/wrapped-signal-impl'; import type { Signal } from './signal.public'; import { SubscriptionData, type NodePropPayload } from './subscription-data'; import { + ComputedSignalFlags, EffectProperty, EffectSubscriptionProp, + SignalFlags, type CustomSerializable, type EffectSubscription, type StoreTarget, @@ -110,7 +113,7 @@ export const triggerEffects = ( } } - (consumer as ComputedSignalImpl | WrappedSignalImpl).$invalidate$(); + (consumer as ComputedSignalImpl | WrappedSignalImpl).invalidate(); } else if (property === EffectProperty.COMPONENT) { const host: HostElement = consumer as any; const qrl = container.getHostProp>>(host, OnRenderProp); @@ -147,7 +150,24 @@ export const triggerEffects = ( export const isSerializerObj = any }, S>( obj: unknown ): obj is CustomSerializable => { - return ( - typeof obj === 'object' && obj !== null && typeof (obj as any)[SerializerSymbol] === 'function' - ); + return isObject(obj) && typeof (obj as any)[SerializerSymbol] === 'function'; +}; + +export const getComputedSignalFlags = ( + serializationStrategy: SerializationStrategy +): ComputedSignalFlags | SignalFlags => { + let flags = SignalFlags.INVALID; + switch (serializationStrategy) { + // TODO: implement this in the future + // case 'auto': + // flags |= ComputedSignalFlags.SERIALIZATION_STRATEGY_AUTO; + // break; + case 'never': + flags |= ComputedSignalFlags.SERIALIZATION_STRATEGY_NEVER; + break; + case 'always': + flags |= ComputedSignalFlags.SERIALIZATION_STRATEGY_ALWAYS; + break; + } + return flags; }; diff --git a/packages/qwik/src/core/shared/component-execution.ts b/packages/qwik/src/core/shared/component-execution.ts index ef8dbddb33c..4917f421e61 100644 --- a/packages/qwik/src/core/shared/component-execution.ts +++ b/packages/qwik/src/core/shared/component-execution.ts @@ -116,9 +116,12 @@ export const executeComponent = ( (err) => { if (isPromise(err) && retryCount < MAX_RETRY_ON_PROMISE_COUNT) { return err.then(() => - executeComponentWithPromiseExceptionRetry(retryCount++) + executeComponentWithPromiseExceptionRetry(++retryCount) ) as Promise; } else { + if (retryCount >= MAX_RETRY_ON_PROMISE_COUNT) { + throw new Error(`Max retry count of component execution reached`); + } throw err; } } diff --git a/packages/qwik/src/core/shared/error/error.ts b/packages/qwik/src/core/shared/error/error.ts index b14140b553f..5e29af7401a 100644 --- a/packages/qwik/src/core/shared/error/error.ts +++ b/packages/qwik/src/core/shared/error/error.ts @@ -1,5 +1,6 @@ import { logErrorAndStop } from '../utils/log'; import { qDev } from '../utils/qdev'; +import { isObject } from '../utils/types'; export const codeToText = (code: number, ...parts: any[]): string => { if (qDev) { @@ -45,7 +46,7 @@ export const codeToText = (code: number, ...parts: any[]): string => { if (parts.length) { text = text.replaceAll(/{{(\d+)}}/g, (_, index) => { let v = parts[index]; - if (v && typeof v === 'object' && v.constructor === Object) { + if (v && isObject(v) && v.constructor === Object) { v = JSON.stringify(v).slice(0, 50); } return v; diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index c2cd5386535..6e4fbf28b18 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -492,7 +492,7 @@ export const createScheduler = ( if (target instanceof ComputedSignalImpl || target instanceof WrappedSignalImpl) { const forceRunEffects = target.$forceRunEffects$; target.$forceRunEffects$ = false; - if (!effects?.size) { + if (!effects?.size && !forceRunEffects) { break; } // needed for computed signals and throwing QRLs diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 21e3d7c14e9..5e46dd73524 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -35,7 +35,7 @@ import { isQrl, isSyncQrl } from './qrl/qrl-utils'; import type { QRL } from './qrl/qrl.public'; import { ChoreType } from './util-chore-type'; import type { DeserializeContainer, HostElement, ObjToProxyMap } from './types'; -import { _CONST_PROPS, _VAR_PROPS } from './utils/constants'; +import { _CONST_PROPS, _UNINITIALIZED, _VAR_PROPS } from './utils/constants'; import { isElement, isNode } from './utils/element'; import { EMPTY_ARRAY, EMPTY_OBJ } from './utils/flyweight'; import { ELEMENT_ID, ELEMENT_PROPS, QBackRefs } from './utils/markers'; @@ -43,6 +43,7 @@ import { isPromise } from './utils/promises'; import { SerializerSymbol, fastSkipSerialize } from './utils/serialize-utils'; import { _EFFECT_BACK_REF, + ComputedSignalFlags, EffectSubscriptionProp, NEEDS_COMPUTATION, SignalFlags, @@ -57,13 +58,14 @@ import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal- import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; import { SerializerSignalImpl } from '../reactive-primitives/impl/serializer-signal-impl'; import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; +import { isObject } from './utils/types'; const deserializedProxyMap = new WeakMap(); type DeserializerProxy = T & { [SERIALIZER_PROXY_UNWRAP]: object }; export const isDeserializerProxy = (value: unknown): value is DeserializerProxy => { - return typeof value === 'object' && value !== null && SERIALIZER_PROXY_UNWRAP in value; + return isObject(value) && SERIALIZER_PROXY_UNWRAP in value; }; export const SERIALIZER_PROXY_UNWRAP = Symbol('UNWRAP'); @@ -309,9 +311,9 @@ const inflate = ( const hasValue = d.length > 6; if (hasValue) { asyncComputed.$untrackedValue$ = d[6]; - } else { - asyncComputed.$flags$ |= SignalFlags.INVALID; } + asyncComputed.$flags$ |= SignalFlags.INVALID; + break; } // Inflating a SerializerSignal is the same as inflating a ComputedSignal @@ -413,7 +415,7 @@ const inflate = ( propsProxy[_VAR_PROPS] = data === 0 ? {} : (data as any)[0]; propsProxy[_CONST_PROPS] = (data as any)[1]; break; - case TypeIds.EffectData: { + case TypeIds.SubscriptionData: { const effectData = target as SubscriptionData; effectData.data.$scopedStyleIdPrefix$ = (data as any[])[0]; effectData.data.$isConst$ = (data as any[])[1]; @@ -435,6 +437,7 @@ export const _constants = [ EMPTY_OBJ, NEEDS_COMPUTATION, STORE_ALL_PROPS, + _UNINITIALIZED, Slot, Fragment, NaN, @@ -454,6 +457,7 @@ const _constantNames = [ 'EMPTY_OBJ', 'NEEDS_COMPUTATION', 'STORE_ALL_PROPS', + '_UNINITIALIZED', 'Slot', 'Fragment', 'NaN', @@ -476,7 +480,12 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow if (!container.$forwardRefs$) { throw qError(QError.serializeErrorCannotAllocate, ['forward ref']); } - return container.$getObjectById$(container.$forwardRefs$[value as number]); + const rootRef = container.$forwardRefs$[value as number]; + if (rootRef === -1) { + return _UNINITIALIZED; + } else { + return container.$getObjectById$(rootRef); + } case TypeIds.ForwardRefs: return value; case TypeIds.Constant: @@ -574,9 +583,8 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow } else { throw qError(QError.serializeErrorExpectedVNode, [typeof vNode]); } - case TypeIds.EffectData: + case TypeIds.SubscriptionData: return new SubscriptionData({} as NodePropData); - default: throw qError(QError.serializeErrorCannotAllocate, [typeId]); } @@ -688,7 +696,7 @@ export interface SerializationContext { $seen$: (obj: unknown, parent: unknown | null, index: number) => void; $roots$: unknown[]; - $pathMap$: Map; + $objectPathStringCache$: Map; $addSyncFn$($funcStr$: string | null, argsCount: number, fn: Function): number; @@ -736,7 +744,7 @@ export const createSerializationContext = ( } as StreamWriter; } const seenObjsMap = new Map(); - const rootsPathMap = new Map(); + const objectPathStringCache = new Map(); const syncFnMap = new Map(); const syncFns: string[] = []; const roots: unknown[] = []; @@ -747,7 +755,7 @@ export const createSerializationContext = ( }; const $addRootPath$ = (obj: unknown) => { - const rootPath = rootsPathMap.get(obj); + const rootPath = objectPathStringCache.get(obj); if (rootPath) { return rootPath; } @@ -768,7 +776,7 @@ export const createSerializationContext = ( } const pathStr = path.length > 1 ? path.join(' ') : path.length ? path[0] : seen.$index$; - rootsPathMap.set(obj, pathStr); + objectPathStringCache.set(obj, pathStr); return pathStr; }; @@ -841,7 +849,7 @@ export const createSerializationContext = ( $storeProxyMap$: storeProxyMap, $getProp$: getProp, $setProp$: setProp, - $pathMap$: rootsPathMap, + $objectPathStringCache$: objectPathStringCache, }; }; @@ -874,6 +882,7 @@ const discoverValuesForVNodeData = (vnodeData: VNodeData, callback: (value: unkn const keyValue = value[i - 1]; const attrValue = value[i]; if ( + attrValue == null || typeof attrValue === 'string' || // skip empty props (keyValue === ELEMENT_PROPS && @@ -899,6 +908,14 @@ class PromiseResult { public $qrl$: QRLInternal | null = null ) {} } + +class SerializationWeakRef { + constructor(public $obj$: unknown) {} +} + +/** @internal */ +export const _serializationWeakRef = (obj: unknown) => new SerializationWeakRef(obj); + /** * Format: * @@ -909,13 +926,22 @@ class PromiseResult { * - Therefore root indexes need to be doubled to get the actual index. */ async function serialize(serializationContext: SerializationContext): Promise { - const { $writer$, $isSsrNode$, $isDomRef$, $storeProxyMap$, $addRoot$, $pathMap$, $wasSeen$ } = - serializationContext; + const { + $writer$, + $isSsrNode$, + $isDomRef$, + $storeProxyMap$, + $addRoot$, + $objectPathStringCache$, + $wasSeen$, + } = serializationContext; let depth = 0; + let rootIdx = 0; const forwardRefs: number[] = []; let forwardRefsId = 0; const promises: Set> = new Set(); const preloadQrls = new Set(); + const s11nWeakRefs = new Map(); let parent: unknown = null; const isRootObject = () => depth === 0; @@ -960,19 +986,41 @@ async function serialize(serializationContext: SerializationContext): Promise { preloadQrls.add(qrl); - serializationContext.$addRoot$(qrl, null); + serializationContext.$addRoot$(qrl); }; - const outputRootRef = (value: unknown, rootDepth = 0) => { + const outputAsRootRef = (value: unknown, rootDepth = 0): boolean => { const seen = $wasSeen$(value); - const rootRefPath = $pathMap$.get(value); + const rootRefPath = $objectPathStringCache$.get(value); + + // Objects are the only way to create circular dependencies. + // So the first thing to to is to see if we have a circular dependency. + // (NOTE: For root objects we need to serialize them regardless if we have seen + // them before, otherwise the root object reference will point to itself.) + // Also note that depth will be 1 for objects in root if (rootDepth === depth && seen && seen.$parent$ !== null && rootRefPath) { output(TypeIds.RootRef, rootRefPath); return true; } else if (depth > rootDepth && seen && seen.$rootIndex$ !== -1) { + // We have seen this object before, so we can serialize it as a reference. + // Otherwise serialize as normal output(TypeIds.RootRef, seen.$rootIndex$); return true; + } else if (s11nWeakRefs.has(value)) { + const forwardRefId = s11nWeakRefs.get(value)!; + // We see the object again, we must now make it a root and update the forward ref + if (rootDepth === depth) { + // It's already a root + forwardRefs[forwardRefId] = rootIdx; + } else { + // ref + const rootRef = $addRoot$(value); + output(TypeIds.RootRef, rootRef); + forwardRefs[forwardRefId] = rootRef; + return true; + } } + return false; }; @@ -989,7 +1037,7 @@ async function serialize(serializationContext: SerializationContext): Promise | null, - Set | null, - Set | null, - boolean, - Error | null, - unknown?, - ] = [ - value.$computeQrl$, - value.$effects$, - value.$loadingEffects$, - value.$errorEffects$, - value.$untrackedLoading$, - value.$untrackedError$, - ]; - if (v !== NEEDS_COMPUTATION) { - out.push(v); - } - output(TypeIds.AsyncComputedSignal, out); } else if (value instanceof ComputedSignalImpl) { + let v = value.$untrackedValue$; + const shouldAlwaysSerialize = + value.$flags$ & ComputedSignalFlags.SERIALIZATION_STRATEGY_ALWAYS; + const shouldNeverSerialize = + value.$flags$ & ComputedSignalFlags.SERIALIZATION_STRATEGY_NEVER; + const isInvalid = value.$flags$ & SignalFlags.INVALID; + const isSkippable = fastSkipSerialize(value.$untrackedValue$); + + if (shouldAlwaysSerialize) { + v = value.$untrackedValue$; + } else if (shouldNeverSerialize) { + v = NEEDS_COMPUTATION; + } else if (isInvalid || isSkippable) { + v = NEEDS_COMPUTATION; + } addPreloadQrl(value.$computeQrl$); - const out: [QRLInternal, Set | null, unknown?] = [ - value.$computeQrl$, - // TODO check if we can use domVRef for effects - value.$effects$, - ]; + + const out: unknown[] = [value.$computeQrl$, value.$effects$]; + const isAsync = value instanceof AsyncComputedSignalImpl; + if (isAsync) { + out.push( + value.$loadingEffects$, + value.$errorEffects$, + value.$untrackedLoading$, + value.$untrackedError$ + ); + } + if (v !== NEEDS_COMPUTATION) { out.push(v); } - output(TypeIds.ComputedSignal, out); + output(isAsync ? TypeIds.AsyncComputedSignal : TypeIds.ComputedSignal, out); } else { - output(TypeIds.Signal, [v, ...(value.$effects$ || [])]); + output(TypeIds.Signal, [value.$untrackedValue$, ...(value.$effects$ || [])]); } } else if (value instanceof URL) { output(TypeIds.URL, value.href); @@ -1335,6 +1372,11 @@ async function serialize(serializationContext: SerializationContext): Promise { $writer$.write('['); - let lastRootsLength = 0; let rootsLength = serializationContext.$roots$.length; - while (lastRootsLength < rootsLength || promises.size) { - if (lastRootsLength !== 0) { + while (rootIdx < rootsLength || promises.size) { + if (rootIdx !== 0) { $writer$.write(','); } let separator = false; - for (let i = lastRootsLength; i < rootsLength; i++) { + for (; rootIdx < rootsLength; rootIdx++) { if (separator) { $writer$.write(','); } else { separator = true; } - writeValue(serializationContext.$roots$[i]); + writeValue(serializationContext.$roots$[rootIdx]); } if (promises.size) { @@ -1391,7 +1432,6 @@ async function serialize(serializationContext: SerializationContext): Promise(value: object): value is ResourceReturnInternal const frameworkType = (obj: any) => { return ( - (typeof obj === 'object' && - obj !== null && - (obj instanceof SignalImpl || obj instanceof Task || isJSXNode(obj))) || + (isObject(obj) && (obj instanceof SignalImpl || obj instanceof Task || isJSXNode(obj))) || isQrl(obj) ); }; @@ -1851,6 +1889,8 @@ export const canSerialize = (value: any, seen: WeakSet = new WeakSet()): bo if (isQrl(value) || isQwikComponent(value)) { return true; } + } else if (value === _UNINITIALIZED) { + return true; } return false; }; @@ -1895,7 +1935,7 @@ export const enum TypeIds { FormData, JSXNode, PropsProxy, - EffectData, + SubscriptionData, } export const _typeIdNames = [ 'RootRef', @@ -1933,7 +1973,7 @@ export const _typeIdNames = [ 'FormData', 'JSXNode', 'PropsProxy', - 'EffectData', + 'SubscriptionData', ]; export const enum Constants { @@ -1946,6 +1986,7 @@ export const enum Constants { EMPTY_OBJ, NEEDS_COMPUTATION, STORE_ALL_PROPS, + UNINITIALIZED, Slot, Fragment, NaN, @@ -1961,8 +2002,8 @@ const circularProofJson = (obj: unknown, indent?: string | number) => { const seen = new WeakSet(); return JSON.stringify( obj, - (key, value) => { - if (typeof value === 'object' && value !== null) { + (_, value) => { + if (isObject(value)) { if (seen.has(value)) { return `[Circular ${value.constructor.name}]`; } @@ -2007,7 +2048,7 @@ export const dumpState = ( if (key === undefined) { hasRaw = true; out.push( - `${RED}[raw${typeof value === 'object' && value ? ` ${value.constructor.name}` : ''}]${RESET} ${printRaw(value, `${prefix} `)}` + `${RED}[raw${isObject(value) ? ` ${value.constructor.name}` : ''}]${RESET} ${printRaw(value, `${prefix} `)}` ); } else { if (key === TypeIds.Constant) { @@ -2038,6 +2079,6 @@ export const typeIdToName = (code: TypeIds) => { return _typeIdNames[code] || `Unknown(${code})`; }; -const constantToName = (code: Constants) => { +export const constantToName = (code: Constants) => { return _constantNames[code] || `Unknown(${code})`; }; diff --git a/packages/qwik/src/core/shared/shared-serialization.unit.ts b/packages/qwik/src/core/shared/shared-serialization.unit.ts index 967732eddeb..46b205c225d 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -1,6 +1,6 @@ import { $, componentQrl, noSerialize } from '@qwik.dev/core'; import { describe, expect, it, vi } from 'vitest'; -import { _fnSignal, _wrapProp } from '../internal'; +import { _fnSignal, _serializationWeakRef, _UNINITIALIZED, _wrapProp } from '../internal'; import { type SignalImpl } from '../reactive-primitives/impl/signal-impl'; import { createComputedQrl, @@ -68,17 +68,95 @@ describe('shared-serialization', () => { 6 Constant EMPTY_OBJ 7 Constant NEEDS_COMPUTATION 8 Constant STORE_ALL_PROPS - 9 Constant Slot - 10 Constant Fragment - 11 Constant NaN - 12 Constant Infinity - 13 Constant -Infinity - 14 Constant MAX_SAFE_INTEGER - 15 Constant MAX_SAFE_INTEGER-1 - 16 Constant MIN_SAFE_INTEGER - (76 chars)" + 9 Constant _UNINITIALIZED + 10 Constant Slot + 11 Constant Fragment + 12 Constant NaN + 13 Constant Infinity + 14 Constant -Infinity + 15 Constant MAX_SAFE_INTEGER + 16 Constant MAX_SAFE_INTEGER-1 + 17 Constant MIN_SAFE_INTEGER + (81 chars)" `); }); + describe('Serialization Weak Ref', () => { + it('should not serialize object', async () => { + const parent = { + child: { should: 'serialize' }, + }; + + (parent as any)[SerializerSymbol] = () => ({ + child: _serializationWeakRef(parent.child), + }); + + expect(await dump(parent)).toMatchInlineSnapshot(` + " + 0 Object [ + String "child" + ForwardRef 0 + ] + 1 ForwardRefs [ + -1 + ] + (27 chars)" + `); + }); + it('should serialize object before qrl', async () => { + const parent = { + child: { should: 'serialize' }, + }; + + (parent as any)[SerializerSymbol] = () => ({ + child: _serializationWeakRef(parent.child), + }); + + const qrl = inlinedQrl(() => parent.child.should, 'dump_qrl', [parent.child]); + expect(await dump(parent, qrl)).toMatchInlineSnapshot(` + " + 0 Object [ + String "child" + ForwardRef 0 + ] + 1 QRL "mock-chunk#dump_qrl[2]" + 2 Object [ + String "should" + String "serialize" + ] + 3 ForwardRefs [ + 2 + ] + (84 chars)" + `); + }); + it('should serialize object after qrl', async () => { + const parent = { + child: { should: 'serialize' }, + }; + + (parent as any)[SerializerSymbol] = () => ({ + child: _serializationWeakRef(parent.child), + }); + + const qrl = inlinedQrl(() => parent.child.should, 'dump_qrl', [parent.child]); + expect(await dump(qrl, parent)).toMatchInlineSnapshot(` + " + 0 QRL "mock-chunk#dump_qrl[2]" + 1 Object [ + String "child" + ForwardRef 0 + ] + 2 Object [ + String "should" + String "serialize" + ] + 3 ForwardRefs [ + 2 + ] + (84 chars)" + `); + }); + }); it(title(TypeIds.Number), async () => { expect(await dump(123)).toMatchInlineSnapshot(` " @@ -456,26 +534,51 @@ describe('shared-serialization', () => { const foo = createSignal(1); const dirty = createComputedQrl(inlinedQrl(() => foo.value + 1, 'dirty', [foo])); const clean = createComputedQrl(inlinedQrl(() => foo.value + 1, 'clean', [foo])); + const never = createComputedQrl( + inlinedQrl(() => foo.value + 1, 'never', [foo]), + { + serializationStrategy: 'never', + } + ); + const always = createComputedQrl( + inlinedQrl(() => foo.value + 1, 'always', [foo]), + { + serializationStrategy: 'always', + } + ); // note that this won't subscribe because we're not setting up the context expect(clean.value).toBe(2); - const objs = await serialize(dirty, clean); + expect(never.value).toBe(2); + expect(always.value).toBe(2); + const objs = await serialize(dirty, clean, never, always); expect(dumpState(objs)).toMatchInlineSnapshot(` " 0 ComputedSignal [ - RootRef 2 + RootRef 4 Constant null ] 1 ComputedSignal [ - RootRef 3 + RootRef 5 + Constant null + Number 2 + ] + 2 ComputedSignal [ + RootRef 6 + Constant null + ] + 3 ComputedSignal [ + RootRef 7 Constant null Number 2 ] - 2 PreloadQRL "mock-chunk#dirty[4]" - 3 PreloadQRL "mock-chunk#clean[4]" - 4 Signal [ + 4 PreloadQRL "mock-chunk#dirty[8]" + 5 PreloadQRL "mock-chunk#clean[8]" + 6 PreloadQRL "mock-chunk#never[8]" + 7 PreloadQRL "mock-chunk#always[8]" + 8 Signal [ Number 1 ] - (90 chars)" + (171 chars)" `); }); it(title(TypeIds.SerializerSignal), async () => { @@ -526,16 +629,41 @@ describe('shared-serialization', () => { [foo] ) ); + + const never = createAsyncComputedSignal( + inlinedQrl( + ({ track }) => Promise.resolve(track(() => (foo as SignalImpl).value) + 1), + 'never', + [foo] + ), + { + serializationStrategy: 'never', + } + ); + + const always = createAsyncComputedSignal( + inlinedQrl( + ({ track }) => Promise.resolve(track(() => (foo as SignalImpl).value) + 1), + 'always', + [foo] + ), + { + serializationStrategy: 'always', + } + ); + await retryOnPromise(() => { // note that this won't subscribe because we're not setting up the context expect(clean.value).toBe(2); + expect(never.value).toBe(2); + expect(always.value).toBe(2); }); - const objs = await serialize(dirty, clean); + const objs = await serialize(dirty, clean, never, always); expect(dumpState(objs)).toMatchInlineSnapshot(` " 0 AsyncComputedSignal [ - RootRef 2 + RootRef 4 Constant null Constant null Constant null @@ -543,7 +671,23 @@ describe('shared-serialization', () => { Constant null ] 1 AsyncComputedSignal [ - RootRef 3 + RootRef 5 + Constant null + Constant null + Constant null + Constant false + Constant null + ] + 2 AsyncComputedSignal [ + RootRef 6 + Constant null + Constant null + Constant null + Constant false + Constant null + ] + 3 AsyncComputedSignal [ + RootRef 7 Constant null Constant null Constant null @@ -551,12 +695,14 @@ describe('shared-serialization', () => { Constant null Number 2 ] - 2 PreloadQRL "mock-chunk#dirty[4]" - 3 PreloadQRL "mock-chunk#clean[4]" - 4 Signal [ + 4 PreloadQRL "mock-chunk#dirty[8]" + 5 PreloadQRL "mock-chunk#clean[8]" + 6 PreloadQRL "mock-chunk#never[8]" + 7 PreloadQRL "mock-chunk#always[8]" + 8 Signal [ Number 1 ] - (122 chars)" + (231 chars)" `); }); it(title(TypeIds.Store), async () => { @@ -597,11 +743,11 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.FormData)); it.todo(title(TypeIds.JSXNode)); it.todo(title(TypeIds.PropsProxy)); - it(title(TypeIds.EffectData), async () => { + it(title(TypeIds.SubscriptionData), async () => { expect(await dump(new SubscriptionData({ $isConst$: true, $scopedStyleIdPrefix$: null }))) .toMatchInlineSnapshot(` " - 0 EffectData [ + 0 SubscriptionData [ Constant null Constant true ] @@ -681,7 +827,7 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.VNode)); it(title(TypeIds.BigInt), async () => { const objs = await serialize(BigInt('12345678901234567890')); - const bi = deserialize(objs)[0] as BigInt; + const bi = deserialize(objs)[0] as bigint; expect(bi).toBeTypeOf('bigint'); expect(bi.toString()).toBe('12345678901234567890'); }); @@ -802,7 +948,7 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.ComputedSignal)); it.todo(title(TypeIds.SerializerSignal)); // this requires a domcontainer - it.skip(title(TypeIds.Store), async () => { + it(title(TypeIds.Store), async () => { const objs = await serialize(createStore(null, { a: { b: true } }, StoreFlags.RECURSIVE)); const store = deserialize(objs)[0] as any; expect(store).toHaveProperty('a'); @@ -812,7 +958,7 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.FormData)); it.todo(title(TypeIds.JSXNode)); it.todo(title(TypeIds.PropsProxy)); - it(title(TypeIds.EffectData), async () => { + it(title(TypeIds.SubscriptionData), async () => { const objs = await serialize( new SubscriptionData({ $isConst$: true, $scopedStyleIdPrefix$: null }) ); diff --git a/packages/qwik/src/core/shared/types.ts b/packages/qwik/src/core/shared/types.ts index dc44a3ca5e5..82a28e43383 100644 --- a/packages/qwik/src/core/shared/types.ts +++ b/packages/qwik/src/core/shared/types.ts @@ -117,3 +117,9 @@ export interface QContainerElement extends Element { qFuncs?: Function[]; _qwikjson_?: any; } + +/** @public */ +export type SerializationStrategy = + // TODO: implement this in the future + // 'auto' | + 'never' | 'always'; diff --git a/packages/qwik/src/core/shared/utils/constants.ts b/packages/qwik/src/core/shared/utils/constants.ts index 8414be910bc..1dac1773baf 100644 --- a/packages/qwik/src/core/shared/utils/constants.ts +++ b/packages/qwik/src/core/shared/utils/constants.ts @@ -5,3 +5,6 @@ export const _VAR_PROPS = Symbol('VAR'); /** @internal @deprecated v1 compat */ export const _IMMUTABLE = Symbol('IMMUTABLE'); + +/** @internal */ +export const _UNINITIALIZED = Symbol('UNINITIALIZED'); diff --git a/packages/qwik/src/core/shared/utils/promises.ts b/packages/qwik/src/core/shared/utils/promises.ts index 46f75b8e6e8..e1d632f1e0d 100644 --- a/packages/qwik/src/core/shared/utils/promises.ts +++ b/packages/qwik/src/core/shared/utils/promises.ts @@ -96,7 +96,7 @@ export const delay = (timeout: number) => { }); }; -// Retries a function that throws a promise. +/** Retries a function that throws a promise. */ export function retryOnPromise( fn: () => ValueOrPromise, retryCount: number = 0 diff --git a/packages/qwik/src/core/shared/utils/serialize-utils.ts b/packages/qwik/src/core/shared/utils/serialize-utils.ts index cda2a9c3f19..df293616c7b 100644 --- a/packages/qwik/src/core/shared/utils/serialize-utils.ts +++ b/packages/qwik/src/core/shared/utils/serialize-utils.ts @@ -90,7 +90,6 @@ const _verifySerializable = ( return value; }; const noSerializeSet = /*#__PURE__*/ new WeakSet(); -const weakSerializeSet = /*#__PURE__*/ new WeakSet(); export const shouldSerialize = (obj: unknown): boolean => { if (isObject(obj) || isFunction(obj)) { @@ -102,15 +101,11 @@ export const shouldSerialize = (obj: unknown): boolean => { export const fastSkipSerialize = (obj: object | Function): boolean => { return ( obj && - (typeof obj === 'object' || typeof obj === 'function') && + (isObject(obj) || typeof obj === 'function') && (NoSerializeSymbol in obj || noSerializeSet.has(obj)) ); }; -export const fastWeakSerialize = (obj: object): boolean => { - return weakSerializeSet.has(obj); -}; - /** * Returned type of the `noSerialize()` function. It will be TYPE or undefined. * @@ -141,18 +136,12 @@ export type NoSerialize = (T & { __no_serialize__: true }) | undefined; // export const noSerialize = (input: T): NoSerialize => { // only add supported values to the noSerializeSet, prevent console errors - if ((typeof input === 'object' && input !== null) || typeof input === 'function') { + if ((isObject(input) && input !== null) || typeof input === 'function') { noSerializeSet.add(input); } return input as any; }; -/** @internal */ -export const _weakSerialize = (input: T): Partial => { - weakSerializeSet.add(input); - return input as any; -}; - /** * If an object has this property, it will not be serialized. Use this on prototypes to avoid having * to call `noSerialize()` on every object. diff --git a/packages/qwik/src/core/shared/utils/types.ts b/packages/qwik/src/core/shared/utils/types.ts index 20567d85822..c3114840e9a 100644 --- a/packages/qwik/src/core/shared/utils/types.ts +++ b/packages/qwik/src/core/shared/utils/types.ts @@ -9,7 +9,7 @@ export const isSerializableObject = (v: unknown): v is Record = }; export const isObject = (v: unknown): v is object => { - return !!v && typeof v === 'object'; + return typeof v === 'object' && v !== null; }; export const isArray = (v: unknown): v is unknown[] => { diff --git a/packages/qwik/src/core/tests/use-async-computed.spec.tsx b/packages/qwik/src/core/tests/use-async-computed.spec.tsx index cab5663f52e..59ee492ecef 100644 --- a/packages/qwik/src/core/tests/use-async-computed.spec.tsx +++ b/packages/qwik/src/core/tests/use-async-computed.spec.tsx @@ -1,4 +1,4 @@ -import { Fragment as Signal, component$, useSignal } from '@qwik.dev/core'; +import { Fragment as Signal, component$, useSignal, useTask$ } from '@qwik.dev/core'; import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; import { describe, expect, it } from 'vitest'; import { useAsyncComputed$ } from '../use/use-async-computed'; @@ -92,4 +92,40 @@ describe.each([ ); }); + + it('should handle error if promise is rejected', async () => { + (globalThis as any).log = []; + const Counter = component$(() => { + const count = useSignal(1); + const doubleCount = useAsyncComputed$(() => Promise.reject(new Error('test'))); + + useTask$(({ track }) => { + track(doubleCount); + + (globalThis as any).log.push((doubleCount as any).untrackedError.message); + }); + + return ; + }); + await render(, { debug }); + expect((globalThis as any).log).toEqual(['test']); + }); + + it('should handle undefined as promise result', async () => { + (globalThis as any).log = []; + const Counter = component$(() => { + const count = useSignal(1); + const doubleCount = useAsyncComputed$(() => Promise.resolve(undefined)); + + return ; + }); + const { vNode } = await render(, { debug }); + expect(vNode).toMatchVDOM( + <> + + + ); + }); }); diff --git a/packages/qwik/src/core/tests/use-serialized.spec.tsx b/packages/qwik/src/core/tests/use-serialized.spec.tsx index 209dc8b9807..f8626405e1b 100644 --- a/packages/qwik/src/core/tests/use-serialized.spec.tsx +++ b/packages/qwik/src/core/tests/use-serialized.spec.tsx @@ -145,6 +145,49 @@ describe.each([ ); }); + it('should recalculate value without update function', async () => { + const Counter = component$(() => { + const count = useSerializer$({ + deserialize: (data: number) => new WithSerialize(data), + }); + return ( + + ); + }); + + const { vNode, container } = await render(, { debug }); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + }); + it('should not crash when used many times', async () => { // We don't have the Signal type here const MyComponent = component$(({ foo }: { foo: { value: number } }) => { diff --git a/packages/qwik/src/core/use/use-async-computed.ts b/packages/qwik/src/core/use/use-async-computed.ts index 634c7811c84..60aed7e8053 100644 --- a/packages/qwik/src/core/use/use-async-computed.ts +++ b/packages/qwik/src/core/use/use-async-computed.ts @@ -1,7 +1,6 @@ -import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; -import type { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; +import { createAsyncComputedSignal } from '../reactive-primitives/signal-api'; import { type AsyncComputedReadonlySignal } from '../reactive-primitives/signal.public'; -import type { AsyncComputedCtx } from '../reactive-primitives/types'; +import type { AsyncComputedCtx, ComputedOptions } from '../reactive-primitives/types'; import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; import type { QRL } from '../shared/qrl/qrl.public'; import { useComputedCommon } from './use-computed'; @@ -14,9 +13,10 @@ export type AsyncComputedReturnType = /** @internal */ export const useAsyncComputedQrl = ( - qrl: QRL> + qrl: QRL>, + options?: ComputedOptions ): AsyncComputedReturnType => { - return useComputedCommon(qrl, AsyncComputedSignalImpl as typeof ComputedSignalImpl); + return useComputedCommon(qrl, createAsyncComputedSignal, options); }; /** diff --git a/packages/qwik/src/core/use/use-computed.ts b/packages/qwik/src/core/use/use-computed.ts index 64191ab0817..6d396992c00 100644 --- a/packages/qwik/src/core/use/use-computed.ts +++ b/packages/qwik/src/core/use/use-computed.ts @@ -3,28 +3,37 @@ import { assertQrl } from '../shared/qrl/qrl-utils'; import type { QRL } from '../shared/qrl/qrl.public'; import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; import { throwIfQRLNotResolved } from '../reactive-primitives/utils'; -import type { ReadonlySignal, Signal } from '../reactive-primitives/signal.public'; +import type { ComputedSignal, Signal } from '../reactive-primitives/signal.public'; import { useSequentialScope } from './use-sequential-scope'; +import { createComputedSignal } from '../reactive-primitives/signal-api'; +import type { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; +import type { SerializerSignalImpl } from '../reactive-primitives/impl/serializer-signal-impl'; +import type { ComputedOptions } from '../reactive-primitives/types'; /** @public */ export type ComputedFn = () => T; /** @public */ -export type ComputedReturnType = T extends Promise ? never : ReadonlySignal; +export type ComputedReturnType = T extends Promise ? never : ComputedSignal; export const useComputedCommon = < T, + S, FUNC extends Function = ComputedFn, RETURN = ComputedReturnType, >( qrl: QRL, - Class: typeof ComputedSignalImpl + createFn: ( + qrl: QRL, + options?: ComputedOptions + ) => ComputedSignalImpl | AsyncComputedSignalImpl | SerializerSignalImpl, + options?: ComputedOptions ): RETURN => { const { val, set } = useSequentialScope>(); if (val) { return val as any; } assertQrl(qrl); - const signal = new Class(null, qrl); + const signal = createFn(qrl, options); set(signal); // Note that we first save the signal @@ -35,8 +44,11 @@ export const useComputedCommon = < }; /** @internal */ -export const useComputedQrl = (qrl: QRL>): ComputedReturnType => { - return useComputedCommon(qrl, ComputedSignalImpl); +export const useComputedQrl = ( + qrl: QRL>, + options?: ComputedOptions +): ComputedReturnType => { + return useComputedCommon(qrl, createComputedSignal, options); }; /** diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index 346bf720e70..98114a86874 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -5,12 +5,12 @@ import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { RenderEvent, ResourceEvent, TaskEvent } from '../shared/utils/markers'; import { seal } from '../shared/utils/qdev'; -import { isArray } from '../shared/utils/types'; +import { isArray, isObject } from '../shared/utils/types'; import { setLocale } from './use-locale'; import type { Container, HostElement } from '../shared/types'; import { vnode_getNode, vnode_isElementVNode, vnode_isVNode, vnode_locate } from '../client/vnode'; import { _getQContainerElement, getDomContainer } from '../client/dom-container'; -import { type ContainerElement } from '../client/types'; +import { type ClientContainer, type ContainerElement } from '../client/types'; import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; import { type EffectSubscription, type EffectSubscriptionProp } from '../reactive-primitives/types'; import type { Signal } from '../reactive-primitives/signal.public'; @@ -65,7 +65,6 @@ export interface InvokeContext { let _context: InvokeContext | undefined; -/** @public */ export const tryGetInvokeContext = (): InvokeContext | undefined => { if (!_context) { const context = typeof document !== 'undefined' && document && document.__q_context__; @@ -88,6 +87,7 @@ export const getInvokeContext = (): InvokeContext => { return ctx; }; +/** @internal */ export const useInvokeContext = (): RenderInvokeContext => { const ctx = tryGetInvokeContext(); if (!ctx || ctx.$event$ !== RenderEvent) { @@ -158,7 +158,7 @@ export const newInvokeContext = ( ): InvokeContext => { // ServerRequestEvent has .locale, but it's not always defined. const $locale$ = - locale || (typeof event === 'object' && event && 'locale' in event ? event.locale : undefined); + locale || (event && isObject(event) && 'locale' in event ? event.locale : undefined); const ctx: InvokeContext = { $url$: url, $i$: 0, @@ -261,6 +261,14 @@ export const _getContextEvent = (): unknown => { } }; +/** @internal */ +export const _getContextContainer = (): ClientContainer | undefined => { + const iCtx = tryGetInvokeContext(); + if (iCtx) { + return iCtx.$container$ as ClientContainer; + } +}; + /** @internal */ export const _jsxBranch = (input?: T) => { return input; diff --git a/packages/qwik/src/core/use/use-resource.ts b/packages/qwik/src/core/use/use-resource.ts index ec2702659b0..ab22ac36578 100644 --- a/packages/qwik/src/core/use/use-resource.ts +++ b/packages/qwik/src/core/use/use-resource.ts @@ -15,7 +15,7 @@ import { assertDefined } from '../shared/error/assert'; import type { JSXOutput } from '../shared/jsx/types/jsx-node'; import { ChoreType } from '../shared/util-chore-type'; import { ResourceEvent } from '../shared/utils/markers'; -import { delay, isPromise, safeCall } from '../shared/utils/promises'; +import { delay, isPromise, retryOnPromise, safeCall } from '../shared/utils/promises'; import { isObject } from '../shared/utils/types'; import { useSequentialScope } from './use-sequential-scope'; import { cleanupFn, trackFn } from './utils/tracker'; @@ -220,15 +220,11 @@ function getResourceValueAsPromise(props: ResourceProps): Promise resource.value); + const promise = isPromise(value) ? value : Promise.resolve(value); + return promise.then(useBindInvokeContext(props.onResolved)); } else { - return Promise.resolve(resource as T).then( - useBindInvokeContext(props.onResolved), - useBindInvokeContext(props.onRejected) - ); + return Promise.resolve(resource as T).then(useBindInvokeContext(props.onResolved)); } } diff --git a/packages/qwik/src/core/use/use-serializer.ts b/packages/qwik/src/core/use/use-serializer.ts index a398736ae95..112e092679c 100644 --- a/packages/qwik/src/core/use/use-serializer.ts +++ b/packages/qwik/src/core/use/use-serializer.ts @@ -1,14 +1,13 @@ import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; import type { QRL } from '../shared/qrl/qrl.public'; -import type { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; -import { SerializerSignalImpl } from '../reactive-primitives/impl/serializer-signal-impl'; import type { SerializerArg } from '../reactive-primitives/types'; import type { createSerializer$ } from '../reactive-primitives/signal.public'; import { useComputedCommon } from './use-computed'; +import { createSerializerSignal } from '../reactive-primitives/signal-api'; /** @internal */ export const useSerializerQrl = (qrl: QRL>) => - useComputedCommon(qrl as any, SerializerSignalImpl as typeof ComputedSignalImpl); + useComputedCommon(qrl as any, createSerializerSignal); /** * Creates a signal which holds a custom serializable value. It requires that the value implements diff --git a/scripts/qwik-router.ts b/scripts/qwik-router.ts index 58ad6c1ea5b..1b9bc208f90 100644 --- a/scripts/qwik-router.ts +++ b/scripts/qwik-router.ts @@ -82,6 +82,7 @@ async function buildVite(config: BuildConfig) { 'typescript', 'vite-imagetools', 'svgo', + '@qwik.dev/core', ]; const swRegisterPath = join(config.srcQwikRouterDir, 'runtime', 'src', 'sw-register.ts'); @@ -102,7 +103,6 @@ async function buildVite(config: BuildConfig) { format: 'esm', external, alias: { - '@qwik.dev/core': 'noop', '@qwik.dev/core/optimizer': 'noop', }, plugins: [serviceWorkerRegisterBuild(swRegisterCode)], diff --git a/starters/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx b/starters/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx index 5d1e5f8364f..01bf8292b76 100644 --- a/starters/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx +++ b/starters/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx @@ -7,7 +7,7 @@ import { zod$, } from "@qwik.dev/router"; import type { - CommonLoaderActionOptions, + ActionOptions, JSONObject, RequestEventAction, ValidatorErrorType, @@ -67,13 +67,13 @@ export const useLoader = routeLoader$(() => { export const useAction1 = routeAction$(actionQrl, { validation: [typedDataValidator, dataValidator], -} as CommonLoaderActionOptions); +} as ActionOptions); export const useAction2 = routeAction$(actionQrl, { validation: [typedDataValidator], -} as CommonLoaderActionOptions); +} as ActionOptions); export const useAction3 = routeAction$(actionQrl, { validation: [dataValidator], -} as CommonLoaderActionOptions); +} as ActionOptions); export const useAction4 = routeAction$( actionQrl, typedDataValidator, diff --git a/starters/apps/qwikrouter-test/src/routes/loaders-serialization/index.tsx b/starters/apps/qwikrouter-test/src/routes/loaders-serialization/index.tsx new file mode 100644 index 00000000000..eb7d3488922 --- /dev/null +++ b/starters/apps/qwikrouter-test/src/routes/loaders-serialization/index.tsx @@ -0,0 +1,48 @@ +import { component$, useSignal } from "@qwik.dev/core"; +import { routeLoader$ } from "@qwik.dev/router"; + +export const useTestLoader = routeLoader$(() => { + return { test: "some test value", abcd: "should not serialize this" }; +}); + +export const useTestLoaderEager = routeLoader$( + () => { + return { foo: "some eager test value", bar: "should serialize this" }; + }, + { serializationStrategy: "always" }, +); + +export default component$(() => { + const testSignal = useTestLoader(); + const toggle = useSignal(false); + return ( + <> + {testSignal.value.test} + + {toggle.value && } + {toggle.value && } + + ); +}); + +export const Child = component$(() => { + const testSignal = useTestLoader(); + return ( + <> +
{testSignal.value.test}
+
{testSignal.value.abcd}
+ + ); +}); + +export const ChildEager = component$(() => { + const testSignal = useTestLoaderEager(); + return ( + <> +
{testSignal.value.foo}
+
{testSignal.value.bar}
+ + ); +}); diff --git a/starters/e2e/qwikrouter/loaders.e2e.ts b/starters/e2e/qwikrouter/loaders.e2e.ts index c05c40706f2..36a7e05c1e8 100644 --- a/starters/e2e/qwikrouter/loaders.e2e.ts +++ b/starters/e2e/qwikrouter/loaders.e2e.ts @@ -151,5 +151,75 @@ test.describe("loaders", () => { const body = page.locator("body"); await expect(body).toContainText("server-error-data"); }); + + test("should not serialize loaders by default and serialize with serializationStrategy: always", async ({ + page, + javaScriptEnabled, + }) => { + await page.goto("/qwikrouter-test/loaders-serialization/"); + const stateData = page.locator('script[type="qwik/state"]'); + + expect(await stateData.textContent()).not.toContain("some test value"); + expect(await stateData.textContent()).not.toContain( + "should not serialize this", + ); + expect(await stateData.textContent()).toContain("some eager test value"); + expect(await stateData.textContent()).toContain("should serialize this"); + + if (javaScriptEnabled) { + await page.locator("#toggle-child").click(); + await expect(page.locator("#prop1")).toHaveText("some test value"); + await expect(page.locator("#prop2")).toHaveText( + "should not serialize this", + ); + await expect(page.locator("#prop3")).toHaveText( + "some eager test value", + ); + await expect(page.locator("#prop4")).toHaveText( + "should serialize this", + ); + } + }); + + test("should retry with all loaders if one fails", async ({ + page, + javaScriptEnabled, + }) => { + let loadersRequestCount = 0; + let allLoadersRequestCount = 0; + page.on("request", (request) => { + if (request.url().includes("q-data.json?qloaders")) { + loadersRequestCount++; + } + if (request.url().endsWith("q-data.json")) { + allLoadersRequestCount++; + } + }); + + await page.route( + "*/**/qwikrouter-test/loaders-serialization/q-data.json?qloaders=*", + async (route) => { + await route.fulfill({ status: 404 }); + }, + ); + await page.goto("/qwikrouter-test/loaders-serialization/"); + + if (javaScriptEnabled) { + await page.locator("#toggle-child").click(); + await page.waitForLoadState("networkidle"); + expect(loadersRequestCount).toBe(1); + expect(allLoadersRequestCount).toBe(1); + await expect(page.locator("#prop1")).toHaveText("some test value"); + await expect(page.locator("#prop2")).toHaveText( + "should not serialize this", + ); + await expect(page.locator("#prop3")).toHaveText( + "some eager test value", + ); + await expect(page.locator("#prop4")).toHaveText( + "should serialize this", + ); + } + }); } }); diff --git a/starters/e2e/qwikrouter/nav.e2e.ts b/starters/e2e/qwikrouter/nav.e2e.ts index 5ea8d2a7f2a..427077174c9 100644 --- a/starters/e2e/qwikrouter/nav.e2e.ts +++ b/starters/e2e/qwikrouter/nav.e2e.ts @@ -10,7 +10,7 @@ import { scrollTo, } from "./util.js"; -test.describe("actions", () => { +test.describe("nav", () => { test.describe("mpa", () => { test.use({ javaScriptEnabled: false }); tests();