Skip to content

{#await} with a null-ish expression renders fullfiled branch during SSR #9323

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

Closed
HoldYourWaffle opened this issue Oct 17, 2023 · 3 comments · Fixed by #9324
Closed

{#await} with a null-ish expression renders fullfiled branch during SSR #9323

HoldYourWaffle opened this issue Oct 17, 2023 · 3 comments · Fixed by #9324

Comments

@HoldYourWaffle
Copy link
Contributor

Describe the bug

Unfortunately some libraries (in my case monaco-editor) do not play well with SSR. I followed the advice from the FAQ on How do I use a client-side only library?, opting to wrap using the client-only shenanigans in an {#await}-block to support a nice loading indicator:

<script lang="ts">
    import { browser } from "$app/environment"
    import { doClientOnlyShenanigans } from '$lib/client-only'

    let clientOnlyPromise: Promise<string>
    if (browser) {
        clientOnlyPromise = doClientOnlyShenanigans()
    }
</script>

{#await clientOnlyPromise}
    <p>Loading...</p>
{:then clientOnlyValue}
    <p>Loaded!</p>
    {clientOnlyValue}
{/await}

Note that the real-world doClientOnlyShenanigans involves a variety of nasty things, like dynamically importing modules with side-effects.

This approach works, but has one subtle problem: during SSR clientOnlyPromise will remain undefined.
For some reason this short-circuits the {#await}-block into the fulfilled branch, even though the documentation on {#await} explicitly states that only the pending state will be rendered during SSR.

In this basic example this causes a flash of "undefined" on the page before hydration, but in real world applications this can cause much more severe issues like nauseating layout shifts.

Reproduction

Minimal reproduction

Logs

No response

System Info

System:
    OS: Windows 10 10.0.19045
    CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz   
    Memory: 8.23 GB / 15.85 GB
  Binaries:
    Node: 20.8.1 - C:\Program Files\nodejs\node.EXE
    npm: 10.1.0 - C:\Program Files\nodejs\npm.CMD
  Browsers:
    Edge: Spartan (44.19041.3570.0), Chromium (118.0.2088.46)
    Internet Explorer: 11.0.19041.3570
  npmPackages:
    @sveltejs/adapter-auto: ^2.0.0 => 2.1.0 
    @sveltejs/kit: ^1.20.4 => 1.25.2 
    svelte: ^4.0.5 => 4.2.1 
    vite: ^4.4.2 => 4.4.11

Severity

serious, but I can work around it

Additional Information

I have found two workarounds for this issue:

  1. Wrap the {#await} block in an {#if} that checks if clientOnlyPromise is truthy.
{#if clientOnlyPromise}
    {#await clientOnlyPromise}
        <p>Loading...</p>
    {:then clientOnlyValue}
        <p>Loaded!</p>
        {clientOnlyValue}
    {/await}
{/if}

This is already strange, less-readable and unintuitive markup, but this also renders nothing during SSR. Instead of a friendly loading indicator the client will be welcomed with layout shifts during hydration.
You could duplicate the loading indicator in an {:else} branch, but this would lead to even hairier markup with an extra spinkle of code duplication.

  1. Assign an empty never resolving promise on the server:
if (browser) {
    clientOnlyPromise = doClientOnlyShenanigans()
} else {
    clientOnlyPromise = new Promise(() => {})
}

This is a little odd, but with a nice explanatory comment above this doesn't cause too much further headache.

@dummdidumm
Copy link
Member

This works as intended. Svelte (not SvelteKit) short-circuits to the resolved state if it detects that the value is not a promise. Your second workaround is what I would use here (you could also do Promise.resolve())

@dummdidumm dummdidumm closed this as not planned Won't fix, can't repro, duplicate, stale Oct 17, 2023
@HoldYourWaffle
Copy link
Contributor Author

Hmm, I had a hunch this might be intentional, given that this behavior makes perfect sense in CSR-mode.
However, the documentation on {#await} clearly states that SSR always renders the pending-branch, which also makes perfect sense because it avoids these exact layout shifting issues.

I personally feel like always rendering the pending-branch during SSR (even if the value is not a promise) would be a better default behavior with less footguns.
Nonetheless, I also see how changing the semantics of {#await} during SSR for these special cases could potentially lead to other issues.

I'll look into adjusting the documentation to be less misleading :)

Side-note: Promise.resolve() returns a Promise<void>, which (understandably) angers the TypeScript compiler :(

@dummdidumm
Copy link
Member

This is a documentation issue then

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
2 participants