Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom serialization #7223

Merged
merged 5 commits into from
Mar 18, 2025
Merged

feat: custom serialization #7223

merged 5 commits into from
Mar 18, 2025

Conversation

wmertens
Copy link
Member

@wmertens wmertens commented Jan 2, 2025

This PR adds symbols to mark objects as (no/yes)serializable via symbols instead of via a Set, adds a symbol prop that will get called on serialization for custom serialization, and provides a new Signal type that lazily manages a non-serializable value.

Note that this is basically the same as useComputed$, except that it is invalidated during SSR so that when the value gets read on the client it will always run the compute function.

Also note that useComputed$ now passes the previous value to the compute function, to keep the implementation compact. This seems like it might be useful, should we document this?

Everything works, looking for comments on implementation, naming etc.

TODO

  • update eslint to accept serialized signals and the new serializer symbols
  • check crash that was reported

Copy link

changeset-bot bot commented Jan 2, 2025

🦋 Changeset detected

Latest commit: 54acbc0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@qwik.dev/core Major
eslint-plugin-qwik Major
@qwik.dev/react Major
@qwik.dev/router Major
create-qwik Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Varixo
Copy link
Member

Varixo commented Jan 2, 2025

Looks great, but what about deserialization those objects?

@wmertens
Copy link
Member Author

wmertens commented Jan 2, 2025

Looks great, but what about deserialization those objects?

That's what the createSerialized$ does

@thejackshelton
Copy link
Member

Does this mean we might be able to serialize functions from other libraries, like vanilla js?

@wmertens
Copy link
Member Author

wmertens commented Jan 3, 2025

@Varixo @thejackshelton gaah I had forgotten to push the actual commit 😅

@wmertens
Copy link
Member Author

wmertens commented Jan 3, 2025

Does this mean we might be able to serialize functions from other libraries, like vanilla js?

Yes exactly. You need to add a symbol prop that serializes the value and then provide a deserializer that will get called lazily.

Copy link

pkg-pr-new bot commented Jan 3, 2025

Open in Stackblitz

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@7223
npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@7223
npm i https://pkg.pr.new/QwikDev/qwik/eslint-plugin-qwik@7223
npm i https://pkg.pr.new/QwikDev/qwik/create-qwik@7223

commit: 54acbc0

Copy link
Contributor

github-actions bot commented Jan 3, 2025

built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
qwik-docs ✅ Ready (View Log) Visit Preview 54acbc0

@wmertens wmertens force-pushed the custom-serialize branch 2 times, most recently from 165ac6d to 804f01d Compare January 27, 2025 23:32
@wmertens wmertens marked this pull request as ready for review February 6, 2025 15:50
@wmertens wmertens requested review from a team as code owners February 6, 2025 15:50
Comment on lines 849 to 872
: obj instanceof ComputedSignal &&
!(obj instanceof SerializerSignal) &&
(obj.$invalid$ || fastSkipSerialize(obj))
? NEEDS_COMPUTATION
: obj.$untrackedValue$;
if (v !== NEEDS_COMPUTATION) {
discoveredValues.push(v);
if (obj instanceof SerializerSignal) {
promises.push(
(obj.$computeQrl$ as any as QRLInternal<SerializerArg<any, any>>)
.resolve()
.then((arg) => {
let data;
if ((arg as any).serialize) {
data = (arg as any).serialize(v);
}
if (data === undefined) {
data = NEEDS_COMPUTATION;
}
serializationResults.set(obj, data);
discoveredValues.push(data);
})
);
} else {
discoveredValues.push(v);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could organise it somehow? It is very complicated right now.
What about doing just

if (obj instanceof WrappedSignal) {

} else if (obj instanceof ComputedSignal) {

} else if ...

Copy link
Member

@thejackshelton thejackshelton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very thorough PR. Awesome work ⚡

*
* This function must not return a promise.
*/
deserialize: (data: S | undefined, previous: T | undefined) => T;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So on first render, the previous param is -> initial data / inital (3rd argument param of useSerializer$)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on first run, the data is initial|undef. After serialization, data is what was serialized.

If the function uses reactive scope, it will rerun and then current will be defined and data will be undefined.

*
* If you do not provide it, the object will be serialized as `undefined`.
*/
serialize?: (customObject: T) => S | Promise<S>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's an example of when you may need the deserialize function but not the serialize function?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you're just constructing an object from scope data and you don't have any state to serialize.

Copy link
Contributor

@thejackshelton-kunaico thejackshelton-kunaico left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrong account 😓

@ianlet
Copy link
Contributor

ianlet commented Feb 13, 2025

I tried the changes locally and I'm having the following issue when using props as initial data:

image

Plus a linting issue when using the SerializerSignal in a useTask$.

ESLint: 
When referencing "mapbox" inside a different scope (useTask$), Qwik needs to serialize the value, however "mapbox. force" is a function, which is not serializable. Check out https:// qwik. dev/ docs/ advanced/ dollar/ for more details.
(qwik/ valid-lexical-scope)

Link to MRE: https://github.com/ianlet/qwik-custom-serder-scope-issues/blob/main/src/components/qwik-mapbox.tsx

Edit: actually we won't be able to useSerializer for mapbox as it needs a window/document. Could have been nice though to render a map without having to wake up Qwik. In any case, it was an interesting exercise to try this new feature on a more complex object that we don't control (external lib).

@ianlet
Copy link
Contributor

ianlet commented Feb 13, 2025

Also, after playing with it a little bit, I think the deserialize function should not handle both the actual deserialization and the update when tracked values change. It's conceptually 2 different things and will require 2 different implementations anyway for developers who will use it. And the fact that we had to document 2 examples on how to use it (without tracking and with tracking) reinforces this idea.

So, IMO it should be 2 different callbacks as it is 2 different use cases, triggered after 2 different events.

@wmertens
Copy link
Member Author

I addressed all the comments I think.

@wmertens
Copy link
Member Author

@ianlet BTW even when only using mapbox on the client, this is still useful. Just make the deserialize isBrowser ? makeMapbox() : undefined and it will just work on the client when you need it.

@ianlet
Copy link
Contributor

ianlet commented Feb 14, 2025

@ianlet BTW even when only using mapbox on the client, this is still useful. Just make the deserialize isBrowser ? makeMapbox() : undefined and it will just work on the client when you need it.

I tried to use the new syntax with update and I changed it to apply your isServer suggestion but if I do so, the deserialize function is never called on the client so the value never gets initialized.

Also, having to handle the undefined everywhere in that case makes it a bit heavy, but it's a detail.

    const mapbox = useSerializer$<
      mapboxgl.Map | undefined,
      SerializationState | undefined
    >(() => ({
      initial: {
        token: token.value,
        mapStyle: mapStyle.value,
        center: center.value,
      },
      serialize: (map: mapboxgl.Map | undefined) => {
        if (!map) return;

        return {
          token,
          mapStyle: map.getStyle() || undefined,
          center: map.getCenter().toArray(),
        };
      },
      deserialize: (state: SerializationState | undefined) => {
        if (isServer) return;

        const s = state || { token: token.value, mapStyle: mapStyle.value, center: center.value };

        return new mapboxgl.Map({
          container: ref.value!,
          style: s.mapStyle,
          center: s.center,
        });
      },
      update: (map: mapboxgl.Map | undefined) => {
        if (!map) return;

        map.setStyle(mapStyle.value);
        map.setCenter(center.value);
      },
    }));

Note: ESLint is still not happy about tracking the SerializerSignal:

    useTask$(({ track }) => {
      const map = track(mapbox);

      map?.on("load", () => console.log("MAP LOADED"));
    });
5:28:38 PM [vite] warning: When referencing "mapbox" inside a different scope (useTask$), Qwik needs to serialize the value, however "mapbox.force" is a function, which is not serializable.
Check out https://qwik.dev/docs/advanced/dollar/ for more details.
  Plugin: vite-plugin-qwik
  File: qwik-custom-serder/src/components/qwik-mapbox.tsx:65:25
  63 |
  64 |      useTask$(({ track }) => {
  65 |        const map = track(mapbox);
     |                           ^
  66 |
  67 |        map?.on("load", () => console.log("MAP LOADED"));

@ianlet
Copy link
Contributor

ianlet commented Feb 14, 2025

I played more with it and if you change a value that is tracked more than once, the serialized value is only updated the first time: Playground #1

So I tried to change it to directly use the signal in the deserialize function (feels weird to me though), and this time it crashes if updated more than once: Playground #2

Uncaught (in promise) TypeError: p0.value is undefined
    qFuncs_za6h4ekne3o (index):1
    untrackedValue signal.js:405
    invokeApply use-core.js:62
    invoke use-core.js:54
    trackSignal use-core.js:130
    $computeIfNeeded$ signal.js:405
    returnValue scheduler.js:349
    retryOnPromise promises.js:88
    executeChore scheduler.js:348
    drainUpTo scheduler.js:212
    schedule scheduler.js:179
    queueQRL queue-qrl.js:21
    dispatch (index):140
    processDocumentEvent (index):162
    addEventListener (index):194
    processEventOrNode (index):202
    processEventOrNode (index):202
    <anonymous> (index):225

@Varixo Varixo requested a review from Copilot March 2, 2025 20:45

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Overview

This PR introduces custom serialization support by adding two new symbols (NoSerializeSymbol and SerializerSymbol) to control object serialization and by providing a new SerializerSignal type that enables custom serialization logic. Key changes include updates to the serialization and signal modules, added unit tests demonstrating custom serialization, and comprehensive documentation updates.

Reviewed Changes

File Description
packages/qwik/src/core/shared/shared-serialization.unit.ts Adds unit tests for NoSerializeSymbol and SerializerSymbol, as well as SerializerSignal behavior.
packages/qwik/src/core/signal/signal.ts Introduces type alias and minor refactoring for computed and wrapped signals, including read-only value enforcement.
packages/qwik/src/core/shared/shared-serialization.ts Updates to support SerializerSignal in both allocation and serialization flows.
packages/qwik/src/core/signal/signal.public.ts & signal-api.ts Exposes new createSerializer$ and createSerializerQrl APIs with improved type definitions and internal casting.
Documentation and changeset files Updates to document the two new symbols and the custom serialization API.

Copilot reviewed 32 out of 32 changed files in this pull request and generated no comments.

Comments suppressed due to low confidence (2)

packages/qwik/src/core/shared/shared-serialization.ts:312

  • [nitpick] Consider expanding this comment to further clarify why SerializerSignal is always marked as invalid, ensuring future maintainers understand the rationale behind recreating the custom object during deserialization.
        // The serialized signal is always invalid so it can recreate the custom object

packages/qwik/src/core/signal/signal-api.ts:32

  • [nitpick] Consider refining the type casting here to reduce the double 'any' cast and make the conversion to QRLInternal<SerializerArg<T, S>> more type-safe.
return new SerializerSignal<T, S>(null, arg as any as QRLInternal<SerializerArg<T, S>>);
mhevery
mhevery previously requested changes Mar 5, 2025
Copy link
Contributor

@mhevery mhevery left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very excited about this change. Left few clarifing questions.

Copy link
Member

@Varixo Varixo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@wmertens
Copy link
Member Author

@ianlet Thank you for your playgrounds, the problem is that you didn't use foo.value in the deserializer, and so there's no subscription being created.

I added an eslint rule (yey vibe coding) that warns about this.

@wmertens wmertens dismissed mhevery’s stale review March 18, 2025 07:43

all points addressed

@wmertens wmertens merged commit 4d5a0c5 into build/v2 Mar 18, 2025
19 checks passed
@wmertens wmertens deleted the custom-serialize branch March 18, 2025 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

6 participants