sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
77.94k stars 4.07k forks source link

Svelte 5: `{@html someVar}` Does not hydrate after SSR #12333

Closed happycollision closed 1 month ago

happycollision commented 1 month ago

Describe the bug

Using {@html someVar} in Svelte 5, I noticed that hydration does not overwrite the SSR value. For example (and this is in the reproduction repo), consider this component in a SvelteKit app:

<script>
    import { browser } from "$app/environment"
    const renderLocation = browser ? "In browser" : "On server"
</script>

<h1>Welcome to SvelteKit</h1>

Standard: {renderLocation}
<br />
@html: {@html renderLocation}

I would expect that after hydration, the page would say:

<h1>Welcome to SvelteKit</h1>

Standard: In browser
<br />
@html: In browser

But instead, it says:

<h1>Welcome to SvelteKit</h1>

Standard: In browser
<br />
@html: On server

Reproduction

https://github.com/happycollision/svelte-hydration-issue

  1. Clone the repo.
  2. There are two folders, svelte5 and svelte4. In both, you should pnpm i.
  3. Do npm run dev in one, check the output of the root route.
  4. Do the same in the other and see that it is different.

In the svelte 4 version, you'll see this:

CleanShot 2024-07-07 at 17 03 51@2x

In the svelte 5 version, you'll see this:

CleanShot 2024-07-07 at 17 03 23@2x

NOTE: There are no runes in use in the Svelte 5 version.

Logs

No response

System Info (svelte4)

System:
    OS: macOS 14.4.1
    CPU: (8) arm64 Apple M1 Pro
    Memory: 1.93 GB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.10.0 - ~/.asdf/installs/nodejs/20.10.0/bin/node
    npm: 10.2.3 - ~/.asdf/plugins/nodejs/shims/npm
    pnpm: 9.0.6 - ~/.asdf/installs/nodejs/20.10.0/.npm/bin/pnpm
    bun: 1.0.3 - ~/.bun/bin/bun
  Browsers:
    Brave Browser: 126.1.67.123
    Safari: 17.4.1
  npmPackages:
    svelte: ^4.2.7 => 4.2.18

System Info (svelte5)

  System:
    OS: macOS 14.4.1
    CPU: (8) arm64 Apple M1 Pro
    Memory: 1.11 GB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.10.0 - ~/.asdf/installs/nodejs/20.10.0/bin/node
    npm: 10.2.3 - ~/.asdf/plugins/nodejs/shims/npm
    pnpm: 9.0.6 - ~/.asdf/installs/nodejs/20.10.0/.npm/bin/pnpm
    bun: 1.0.3 - ~/.bun/bin/bun
  Browsers:
    Brave Browser: 126.1.67.123
    Safari: 17.4.1
  npmPackages:
    svelte: ^5.0.0-next.1 => 5.0.0-next.175 

Severity

blocking an upgrade

dummdidumm commented 1 month ago

A while back we decided to not hydrate @html comments to check them for mismatches because it's a slow untertaking and very rare in general that someone would want this. If we maintainers still agree on that change then we

As a workaround you can reassign the HTML in onMount, like

<script>
    import { onMount } from "svelte"

    let renderLocation = "On server"
    onMount(() => { renderLocation = "In browser"; });
</script>

<h1>Welcome to SvelteKit</h1>

Standard: {renderLocation}
<br />
@html: {@html renderLocation}

What's your real-life use case for this behavior?

happycollision commented 1 month ago

In my case, I suppose the onMount functionality would allow me to do what I need.

I am running a site (postplayhouse.com) that is statically generated, but some of that information needs to change based on what today’s date is. We had a problem the other day where there was a mismatched title against a description, because I used Markdown (hence the @html) for the description. It was a very surprising thing to discover and I couldn’t figure it out for a while, so I just regenerated the whole site so the SSR version was actually no longer out of date.

I think adding documentation in both the upgrade guide and also in the section of the docs that discuss @html would have prevented me from shipping this bug. Seems like a good plan.

happycollision commented 1 month ago

Here's some more context to describe my use case a little more clearly, in case there is something in it that is compelling for a behavior change.

<-- routes/(app)/+page.svelte -->
<script>
import Markdown from "$components/Markdown.svelte"
import {daysUntil} from "$utils"
import messages from "some_data_connection"

const today = new Date()

const daysLeft = daysUntil(today, "2024-07-12")

const msg = daysLeft < 5 ? messages.endingSoon : messages.nowRunning
</script>

<Markdown source={msg}/>

It is inside the Markdown component that we use the {@html source} business. ~So I'm guessing I'd need to do this:~ (Edit: this actually does NOT work. See my comment further down. I found a working solution there)

<script lang="ts" context="module">
    import { marked } from "marked"
+   import { onMount } from "svelte"
    marked.setOptions({ smartypants: true })
</script>

<script lang="ts">
    let { source }: { source?: string } = $props()
+   let hydratedSource = $state(source)
+
+   onMount(() => {
+       hydratedSource = source
+   })
</script>

<div class="via-markdown">
    <!-- eslint-disable-next-line svelte/no-at-html-tags -->
-   {@html marked.parse(source || "")}
+   {@html marked.parse(hydratedSource || "")}
</div>
happycollision commented 1 month ago

I guess in this case, it is kind of weird, because the props going into the component change, which you'd think would do the trick, so maybe call out in the docs as well that if you are using {@html props.something}, you'll still not see an update on hydration unless your props actually are changed once after hydration is complete.

Maybe that'd be obvious to others. In any case, I am guessing a Markdown component is common enough a use case that you'd perhaps see this pop up frequently in that kind of scenario. 🤷

happycollision commented 1 month ago

Oh. Silly. Even my diff above doesn't work (because the state doesn't change after hydration... it's just the first render still). This actually works:

<script lang="ts" context="module">
    import { marked } from "marked"
    import { onMount } from "svelte"
    marked.setOptions({ smartypants: true })
</script>

<script lang="ts">
    let { source }: { source?: string } = $props()
    let markdown = $derived(marked.parse(source || ""))
    let hydrated = $state(false)

    onMount(() => {
        hydrated = true
    })
</script>

<div class="via-markdown">
    {#if hydrated}
        {@html markdown}
    {:else}
        {@html markdown}
    {/if}
</div>

If you are curious and want to dig in to the scenario, here's the branch where I am replicating what we saw in production:

https://github.com/postplayhouse/postplayhouse.com/tree/test-brokenness

The site was SSG'd on June 23 (which I am mocking using mockdate inside the vite config), then the site was rendered on July 4th (which I am mocking using timemachine in app.html). Inside the Markdown component, I was trying to understand what was happening, so I modified it to spit out multiple strings instead of just the rendered HTML. You'll see on render, they don't match.

Since Post Playhouse is a non-profit, I try to keep spending on anything to a minimum. Hence the fact that I rely on SSG for nearly everything, and just replace the content with JS in the client instead of on the server. I use it as a replacement for all kinds of date-based announcements that'd often be handled server-side. Mildly bad for SEO, but good for costs and still being able to set-and-forget.

Anyway. I hope you all have some more insight into this use case. Love the changes in Svelte 5!

dummdidumm commented 1 month ago

There's various ways to approach this and the best solution depends on your use case - your solution is valid!

If you know the HTML stays mostly the same and it's only a small fraction that is dynamic, you could also try to split that out.