QwikDev / qwik

Instant-loading web apps, without effort
https://qwik.dev
MIT License
20.64k stars 1.28k forks source link

[🐞] Qwik doesnt check `if-statements` before running child useComputed$ or useTask$ #4332

Open DustinJSilk opened 1 year ago

DustinJSilk commented 1 year ago

Which component is affected?

Qwik Runtime

Describe the bug

The following code causes Qwik to crash when the button is clicked. Conditionals aren't checked before child component tasks are run when using Signals.

import { component$, useSignal, useTask$ } from "@builder.io/qwik";

// A useTask$ in a child components circumvents the parent components conditional checks
const Child = component$((props: { val: string }) => {
  useTask$(({ track }) => {
    track(() => props.val)
  });
  return <>{props.val}</>;
});

export default component$(() => {
  const sig = useSignal<{ data: string } | undefined>({ data: '' });

  return (
    <>
      <button onClick$={() => (sig.value = sig.value ? undefined : { data: '' })}>Toggle</button>

      {/* ERROR: cannot read data of undefined */}
      {sig.value && <Child val={sig.value.data} />}
    </>
  );
});

Reproduction

https://github.com/DustinJSilk/qwik-issue-rendering/blob/main/src/routes/index.tsx

Steps to reproduce

Run the app Click the button

System Info

System:
    OS: macOS 12.0.1
    CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
    Memory: 116.35 MB / 16.00 GB
    Shell: 5.8 - /bin/zsh
  Binaries:
    Node: 18.12.1 - ~/.nvm/versions/node/v18.12.1/bin/node
    Yarn: 1.22.18 - /usr/local/bin/yarn
    npm: 7.13.0 - /usr/local/bin/npm
  Browsers:
    Chrome: 113.0.5672.126
    Firefox: 113.0.1
    Safari: 15.1
  npmPackages:
    @builder.io/qwik: ^1.1.4 => 1.1.4 
    @builder.io/qwik-city: ^1.1.4 => 1.1.4 
    undici: 5.22.1 => 5.22.1 
    vite: 4.3.5 => 4.3.5

Additional Information

No response

DustinJSilk commented 1 year ago

If we update the child component to this, we see that the error is fixed but the useComputed$ still runs when it shouldnt and we go against the type system as someValue should not be undefined:

const Child = component$((props: { someValue: number[] }) => {
  const max = useComputed$(() => {
    console.log("Computing");
    return Math.max(...(props.someValue ?? [1, 2]));
  });
  return <div>{max.value}</div>;
});
DustinJSilk commented 1 year ago

@manucorporat sorry to be a pain, any chance you could take a look at this or know a work around? 🙏

Unfortunately i dont know qwik well enough to fix it myself (tho i have tried!) and I haven't found a way around it yet either.

DustinJSilk commented 1 year ago

@manucorporat Ive added an e2e test to the reproduction repository here and here, hopefully that helps simulating and testing a fix for this issue.

Let me know if there's anything I can do to help here!

DustinJSilk commented 1 year ago

The issue also occurs when using a useTask$. You can see the console.log prints undefined when it shouldnt run at all. Swapping undefined for null doesn't help and I havent found a way around it yet.

import { component$, useSignal, useTask$ } from "@builder.io/qwik";

const Child = component$((props: { someValue: number[] }) => {
  const data = useSignal(props.someValue);
  useTask$(({ track }) => {
    track(() => props.someValue);
    data.value = props.someValue;
    console.log(props.someValue);
  });
  return <div>{data.value}</div>;
});

export default component$(() => {
  const data = useSignal<number[] | undefined>([1, 2, 3, 4, 5]);

  return (
    <>
      <div>{data.value && <Child someValue={data.value} />}</div>
      <button onClick$={() => (data.value = undefined)}>Click me</button>
    </>
  );
});
DustinJSilk commented 10 months ago

I keep running into this issue, am I doing something wrong here?

cmbartschat commented 10 months ago

I wish this bug had more attention!

Here's a playground example just to show how bananas this behavior is 🙈

https://qwik.builder.io/playground/#v=1.2.13&f=7Va7CoNAEOzzFRubUwiBFIEgnqn8iJQaErTR4AMC4r9ndvfUK1LYBqIgKnd7t3uzM%2BOB5nxZ0vFBc1jVV16F17YgSTNX4rRewGTk3hke8ewJpjQULzHXZhHl8N6%2Fl4oR4esokusQKZOOEiuSAdWTNJL%2BJGstmSKvcXdGT5qvvmzB2awRWds2bWhuzUAdBBIbrXFOZQ66fcHVQCjd7L3RFSY8p2jntQK7IhVBcFgwestPgXom9Ma27tKCua2vVQ8NFvcg49CSlKc0Y3mnwG0yoJ7dAewVpwVwnFKeh6GV%2BKECPBNLeDvqDhkfMkK%2Fv5WNriotMSV6lH4AlyjCcGGAq%2BhPBD9KBB8

wmertens commented 10 months ago

@cmbartschat I don't understand, what are you demostrating? You're throwing on purpose there?

The "problem" is that the {sig.value && runs at a different time than the updating of the passed signal prop function sig.value.data.

I don't consider this a bug per se, it's more of an unexpected result of splitting your app in tiny parts that update via signals.

DustinJSilk commented 10 months ago

Why wouldn’t it be a bug? Signals are unavoidable and so is splitting an app into smaller parts. So surely it’s a bug if the result is unexpected? Please let me know if I’m missing something fundamental here :)

wmertens commented 10 months ago

Hmm.

So a solution would be that the optimizer keeps track of statements that cause a signal to be forwarded, converting then to a filter function, right? And it should first call the filter function and only then the signal function?

I wonder how deep this rabbit hole goes, what statements should be converted etc.

DustinJSilk commented 10 months ago

To be honest, I don’t know what a solution would look like, I don’t know the inner workings of qwik or what the various trade offs would be.

Do you know if any other framework has solved or suffers from the same issue?

wmertens commented 10 months ago

I don't know of any other framework that converts values into signals and re-renders asynchronously thanks to that.

The workaround is to add a ? after signal.value, which the linter doesn't complain about because it doesn't know that it will become a separate function called separately.

For me this feels like a P1 issue, maybe P2 because it's unintuitive.

DustinJSilk commented 10 months ago

Yep so the only real work around is to avoid the conditional completely, it kind of is bananas lol

Is there any way we can get this issue escalated?

cmbartschat commented 10 months ago

Sorry for the cryptic example!

What I was trying to show is that based on the contract of the Child component, as long as you don't pass "bananas", everything is fine. And naively, it does seem like we're following that contract in that example.

Here's another example to show the sorts of things a real world app might try to do.

I would describe the problem simply that type narrowing does not work on signals.

No matter what we write in the render logic, at runtime, the signal is not type narrowed, because it gets flushed through before the narrowing logic can run.

This can be a source of bugs that Typescript cannot detect.

wmertens commented 10 months ago

Ok your real-world example is convincing. It's easy to see that this could lead to frustrating debug sessions.

So indeed I think the proper solution is that the optimizer supports filter functions before updating prop signals. But I don't think that's trivial to add.

DustinJSilk commented 10 months ago

@manucorporat @mhevery is there anything we can do about this issue?

mhevery commented 10 months ago

Yeah, this is a hard one. I need to dig deeper into this to see what a fix could be.

The issue is that the order in which we process events is not preserved.

wmertens commented 10 months ago

Does this happen in other situations than when signals are proxied in props by the optimizer? If not, I believe a filter function should work

mhevery commented 10 months ago

Does this happen in other situations than when signals are proxied in props by the optimizer? If not, I believe a filter function should work

I have seen other issues on this topic, but I can't find them now.

The issue is that the effects should run in the correct order and then prune its children. I think the code should work as written, but we don't process the effects in the correct order which results in this issue. Happy to have a chat if you want to dig deeper into this.

DustinJSilk commented 10 months ago

Thanks @mhevery and @wmertens i appreciate you guys taking the time to think about this one!

brandonpittman commented 8 months ago

I'm getting a similar issue where due to conditional rendering I'm getting two of the same component in the DOM—one hidden and one not hidden. Both components' useTask$ calls are being fired when the signal value they're tracking changes. Since I'm updating a store value in that task, a double store value change happens.

wmertens commented 8 months ago

@brandonpittman that's by design, because children and parents render independently.

So if you render items in slots, they will always render and update even when the slot is not visible.

brandonpittman commented 8 months ago

@wmertens

So if you render items in slots, they will always render and update even when the slot is not visible.

That was the conclusion I came to. I created a <Show> component similar to Solid.js' but it's causing more trouble than it's worth. I wanted to embed the show/hide logic inside child components, but it seems like I can't do that without unintended consequences.


If the projected content isn't displayed at render, it'll go into a q:template. And if that project content is subject to dynamic updates that change the structure of the slotted HTML, it's possible the initial server q:template can exist in the DOM next to a dynamically created element that gets placed in the slot?

wmertens commented 7 months ago

If the projected content isn't displayed at render, it'll go into a q:template. And if that project content is subject to dynamic updates that change the structure of the slotted HTML, it's possible the initial server q:template can exist in the DOM next to a dynamically created element that gets placed in the slot?

Uhm, yes? Not quite sure what you mean, a playground repro would be nice.

DustinJSilk commented 3 months ago

Looks like this will be fixed in V2 🫨❤️ Is there a roadmap for V2 at all?

tri2820 commented 2 weeks ago

This simple example shows how this bug breaks code that appears to work in Qwik. TypeScript does not show any warnings or errors either.

For useTask$, when I switch to useVisibleTask$ it works (because all input signals are correctly their type at that point). For useComputed$, could the solution be to allow it to run after rendering, like useVisibleTask$?

const _  = useVisibleComputed$(() => {

})

This way the computation is not async anymore, but at least it's a safe alternative to circumvent situation like this.