withastro / astro

The web framework for content-driven websites. ⭐️ Star to support our work!
https://astro.build
Other
45.85k stars 2.41k forks source link

How to persist client component state with View Transition when the component is not present on every page? #8781

Open kevintakeda opened 11 months ago

kevintakeda commented 11 months ago

Astro Info

Astro                    v3.2.3
Node                     v18.18.0
System                   Linux (x64)
Package Manager          unknown
Output                   static
Adapter                  none
Integrations             @astrojs/solid-js

If this issue only occurs in one browser, which browser is a problem?

Chrome

Describe the Bug

When you use a global store or a signal with <ViewTransitions />, the hydration process doesn't reevaluate previous states that were used to determine what should be displayed in the children elements. However, the parent component continues to update its attributes even after you change the state and navigate away.

What's the expected result?

I expected the store to maintain its state across navigations, which it does. However, I also anticipated that every rehydrated component would be re-evaluated according to the current store state. There are potential workarounds, but I suspect it's a hydration issue.

Link to Minimal Reproducible Example

https://stackblitz.com/edit/github-bxb9uz-urtz5p?file=src%2Fpages%2Findex.astro,src%2Fcomponents%2FSolidItem.tsx&on=stackblitz

Participation

kevintakeda commented 11 months ago

After upgrading to Solid 1.8, I've realized that the class attribute no longer gets updated, contrary to what I mentioned earlier. Now the problem is simpler: The component does not recover its modified state when it is unmounted and then remounted navigating using view transitions.

Edit: It looks like that the attribute disabled is updated between navigations, whereas other attributes, such as class, do not get updated.

martrapp commented 11 months ago

Hi @mishimalisa, I'll try to answer, even though I have no practical experience with solid-js. View transitions are a valid approach to preserve the state of loaded JavaScript modules across navigation until the next full page reload. The view you see when you return to the home page is the static view of the page with the initial state. The components are reinserted into the DOM when you navigate to the view, but they don't recognize that they should redraw themselves according to the current state.

The following changes both worked for me:

kevintakeda commented 11 months ago

Hey @martrapp thanks for the comment,

The view you see when you return to the home page is the static view of the page with the initial state.

The weird thing is, I noticed the "disabled" attribute doesn't revert to its initial static view with the initial state when returning to the home page. I updated the reproduction code to illustrate this.

I also tried this using Preact + nanostores. Interestingly, it does the opposite of what SolidJS does; it doesn't update the disabled attribute but does update its children based on the "modified" state after view transition navigation.

Preact + nanostores repro: https://stackblitz.com/edit/github-bxb9uz-gnd2zk?file=src%2Fcomponents%2FItem.tsx

lilnasy commented 11 months ago

I would not expect components to be "remounted" on a navigation. Although the cases brought up make me think there is inconsistency here.

martrapp commented 11 months ago

Yes @mishimalisa, I also noticed that disabled works somehow. And you had mentioned that in your first post. I have no idea how to explain this :).

Technically, the state is not part of the component, but part of the module loader of the browser. The main difference when using View Transitions is that the module loader state is not cleared when navigating, unlike when fully loaded. This is good for maintaining state across pages in your example, but it seems to cause problems when it comes to hydration or other ESM-related things. You can't just run the same module again. There doesn't seem to be a way to interact with the browser's module loader and unload/reload modules. The JavaScript code of the astro-island custom component is executed on hydration, even when view transitions are used, but it does not seem to behave the same way as on reload.

kevintakeda commented 11 months ago

I see, it looks like the issue is that the components don't fully recognize the store changes that happened while unmounted. Perhaps the issue is because the module did not rerun. Thanks for the explanation @martrapp.

Using Solid, a workaround I found is to create a signal inside the component, and update it using createEffect, which runs on mount and whenever the store changes.

Like this:

function ShoppingCart() {
  const [state, setState] = createSignal();
  createEffect(() => setState(store.shoppingCartIsFull));
  return <div>Your shopping cart is {state() ? 'FULL' : 'EMPTY'}</div>;
}

I think anyone attempting to maintain state across navigations will come across this issue. I made another example to illustrate the issue and the workaround in a simple way.

martrapp commented 11 months ago

Hey @mishimalisa, thanks for your concise and helpful example including the workaround! This disproves my theory with incomplete initialization. It seems that Solid just doesn't see the need to refresh the static view according to the shared state on its own during hydration.

Another thought on this: The ShoppingCart component is not mounted when the state of the store changes, so it cannot respond directly to that change. An alternative way to deal with this would be to keep the component alive across pages with transition:persist=<some-name>. The component doesnt necessarily have to be visible on all pages, it could also be styled to be outside the viewport or havedisplay: none`.

kevintakeda commented 11 months ago

Yes, @martrapp. I think, as you said, the server is returning the static view ("rendered using the empty store"), and the hydrate function might be avoiding unnecessary work (such as DOM updates) because it trusts that the server's generated code represents the current correct state.

Solution 1:

I think, using transition:persist is the preferred approach in most cases, but if you have a bunch of components on just one page (e.g.: "Add to shopping cart" button) that use a shared store, you might need those components on every page, plus the logic for showing and hiding the component visibility.

Solution 2:

Using client:only works fine, but unfortunately the component is not visible before it loads. Maybe, one idea is to have a directive that replaces the static rendered component with a brand new client only component, like <Button client:swap />.

Solution 3:

My solution works, but I think would be nice to have a more intuitive approach.

Idea for a new hydration option for the UI frameworks

The combination of View Transitions API + Islands + Global Store is awesome, but might need new hydration options to recover shared states across navigations, so here is one idea: a new hydration directive that hydrates the component by treating the SSR HTML as stale and refreshes/revalidates all its states.

<Button hydrate:revalidate client:load />

So Instead of hydrate(<Button />, node), we use hydrate(<Button />, node, { mode: "revalidate" }).

lilnasy commented 11 months ago

Hydration options for UI frameworks seems like a good solution!

I say that because from what I've seen so far, solid intentionally tries not to change server-rendered elements during hydration. The fact that it changes the disabled attribute seems to be an oversight in their compiler.

I'm not sure about this yet, though. We will be confirming with the Solid team next week.

kevintakeda commented 11 months ago

In addition to that, If possible, Astro could provide options for hydrating (restoring) after swap and before the view transition finishes. Which solves the problem of Flash of components not yet fully hydrated and revalidated.

Note: Instead of the word "hydrate", we can use "restore", because "hydrate" term may conflict with the directive "client".

On visible it hydrates normally. If a swap happens it restores after swap (before visible):

<Button restore:swap client:visible />

It "restores" when visible:

<Button restore:load client:visible />
lilnasy commented 11 months ago

Directives would be a lot of effort because they will involve the compiler and documentation. Also, maybe there isn't enough reason to introduce a new concept, especially if the current use-case can be handled automatically.

For example, pretending all components are client:only would probably be enough. The idea being, to use the normal render function instead of the hydrate function, which solid-js seems to have unsafely optimized intended for initial navigation only.

kevintakeda commented 11 months ago

I don't think solid-js is the only one that optimizes hydration. I ran some tests to compare popular frameworks using their shared state techniques, to see if they can correctly hydrate the component that uses a shared store (mutated on the client only before hydration). I found Lit and Svelte are the only ones that hydrate the mismatch correctly and don't emit errors.

Framework Updates children Updates attributes Emits hydration error
Preact ✅ Yes ❌ No No
Solid ❌ No ❌ No No
React ✅ Yes ✅ Yes Yes (error)
Lit ✅ Yes ✅ Yes No
Svelte ✅ Yes ✅ Yes No
Vue ✅ Yes ❌ No Yes (error)

Code: https://stackblitz.com/edit/github-bxb9uz-cwygvz?file=src%2Fpages%2Findex.astro

Also, maybe there isn't enough reason to introduce a new concept, especially if the current use-case can be handled automatically.

I agree that client:only directive can suffice for most projects already using Astro. However, client:only may lead to challenges reminiscent of "flash of white while using dark theme" issue and FOUC problem, which Astro has already resolved. I think it would be great to consider similar solutions for components (not present in every page) that share states as well.

The idea being, to use the normal render function instead of the hydrate function

Perhaps, Astro could fake a hydration by calling render somewhere and swapping it with the static component inside the startViewTransition function, so the end user doesn't notice the flash?

lilnasy commented 11 months ago

I don't think solid-js is the only one that optimizes hydration.

You're right. This issue is relevant to all frameworks.

While the disabled attribute updating on navigation is still a minor bug for Solid.js, Ryan Carniato confirmed that hydration is not supposed to perform a DOM mutation, and also that hydration mismatches should not exist in the first place. Solid.js discord.

This is a problem for astro islands to solve, and I think it is approachable one for the reason you mentioned: we are free to perform any DOM operation before we show something to the visitor, no flashes necessary.

All this means, this a new feature request: to figure out a story for state-persisting client components with view transitions.

martrapp commented 11 months ago

We are already doing some pre-rendering to determine the stylesheets of client-only components during view transitions (in DEV mode only). I like the idea of updating the static view for non-persisted islands, as this should also eliminate the hydration errors we should expect from Vue, for example.

kevintakeda commented 11 months ago

In detail: Astro can use render function instead of hydrate for each desired island on the next navigated page, and replace the generated static island from the server with the client-rendered island during the pre-rendering phase (inside startViewTransition). The main issue is that every component on the next page must be downloaded and rerendered before loading the next page (which I think is fine, is basically how metaframeworks usually work). The worst case that I can think of is a scenario where most components that want to preserve their state actually did not change their state, which makes the use of render unnecessary extra work. This should be opt-in per component. Also note that the render would happen only after navigation not on first load.

In short when navigation happens:

  1. Fetch the next page
  2. Find any element that may require render
  3. Fetch the components that require render
  4. Meanwhile, pre-render the static page
  5. When the fetched components are ready replace static islands with the rendered islands
  6. Finish the view transition

A more adventurous approach would be caching a reference for each unique island that the end user has seen/loaded, just like transition:persist, but persisting forever somewhere. The issue is that the user might update the shared store before components relying on it are cached, leading to a mismatch later on. The caching might be helpful for simple use cases where the component updates only affect itself.

Regarding hydration, From svelte docs about hydration:

The existing DOM doesn't need to match the component — Svelte will 'repair' the DOM as it goes.

I also tried to break Svelte and Lit hydration, without success. Interestingly seems like Svelte knows how to reconcile any mismatch during hydration; the same goes for Lit.

lilnasy commented 11 months ago

You mention that classic render would be unnecessary extra work. I think that's technically true but I would expect the extra work to be on the order of a millisecond. Do you have special requirements where you know that's not the case?

kevintakeda commented 11 months ago

@lilnasy I agree it should be fast enough for all use cases and I can't think of a best solution than just using classic render. The cost is negligible, but it is still something to be aware of. If some components decide to block for whatever reason it can make the experience worse than a normal navigation. But those edge cases are usually easily addressed by the developer.

kevintakeda commented 11 months ago

I started a proposal about this here: https://github.com/withastro/roadmap/discussions/756

peerreynders commented 9 months ago

While the disabled attribute updating on navigation is still a minor bug for Solid.js

I suspect there is something more significant going on here.

I essentially arrived at what I would consider a reasonable solution:

// file: src/components/SolidItem.tsx
import { createMemo, createSignal, onMount } from 'solid-js';
import { accessor, setter, type Core } from '../store';
import type { Accessor } from 'solid-js';

const toggleById = (cache: Core, id: string) => ({
  ...cache,
  [id]: !Boolean(cache[id]),
});

const nextSync = (state: number) => state + 1;
const derived = (
  accessor: Accessor<Core>,
  id: string,
  sync: Accessor<number>
) =>
  accessor()[id]
    ? {
        class: 'bt bt--disabled active',
        disabled: true,
        content: 'Active ❤️',
        signature: 'active disabled',
        sync: sync(),
      }
    : {
        class: 'bt idle',
        disabled: false,
        content: 'Like button!',
        signature: 'idle enabled',
        sync: sync(),
      };
/* https://css-tricks.com/making-disabled-buttons-more-inclusive */

export default function SolidItem(props: { id: string }) {
  let button: HTMLButtonElement | undefined;
  // sync() only exists to trigger re-evaluation of the memo
  // when server rendered component needs to be synchronized
  // with divergent shared client state.
  const [sync, setSync] = createSignal(Number.MIN_SAFE_INTEGER);
  const state = createMemo(() => derived(accessor, props.id, sync));

  onMount(function syncWithClientSharedState() {
    // Compare the server rendered signature with the
    // transitioned client state signature
    // if (state().signature === button.dataset.signature) return;
    if (state().class === button.className) return;

    // Synchronize if divergent
    // console.log('sync', props.id, button.dataset.signature, state().signature);
    console.log('sync', props.id, button.className, state().class);
    setSync(nextSync);
  });

  const toggle = () => {
    if (state().disabled) return;
    setter((cache) => toggleById(cache, props.id));
  };

  return (
    <button
      ref={button}
      type="button"
      onClick={toggle}
      class={state().class}
    >
      <span class="front">{state().content}</span>
    </button>
  );
}

https://stackblitz.com/edit/github-bxb9uz-rozfzs?file=src%2Fcomponents%2FSolidItem.tsx

However this breaks once I add more attributes to the <button> element:

  return (
    <button
      ref={button}
      type="button"
      onClick={toggle}
      class={state().class}
      aria-disabled={state().disabled}
      data-signature={state().signature}
    >
      <span class="front">{state().content}</span>
    </button>
   );

It seems that those attributes have lost their reactivity with regards to programmatic updates (the content updates just fine).

When I remove the disabled guard, user interaction seems to restore reactivity but before that point any amount of programmatic prodding of the memo doesn't seem to propagate the correct attribute values in the DOM.

danielo515 commented 7 months ago

So shared persistent global state is still a problem to solve except for svelte? If I only use astro and svelte components does this work?

logaretm commented 5 months ago

Encountered this today while working on an audio player that needed to be persisted across MBA navigations. My Vue component cannot take control back of the persisted element after navigation. I think ... understandably so.

The trick for me was to decouple the Vue component from the persisted element and make sure to serialize my component state on the element itself using data attributes and restore them onMounted by reading back from it and re-attaching the listeners on astro page load event.

Some cases required a minor forced re-rendering with key attribute on mount.

HagenMorano commented 2 months ago

First of all, thanks @mishimalisa for describing the problem, providing the table of tests with the different UI libs and creating the proposal.

I am a little surprised on how few attention this topic got though - maybe there is something related already going on which I didn't see? In my case, I want to always return static placeholder loaders from the server for a component which requires external data that needs to be fetched asynchronously on the client. This data either exists or does not exist - depending on the clients store (the approach makes use of TanStack Query and React). I am running into described problem now, e.g. when

  1. fetching query 1 for component 1 (including transition:persist) on the first page
  2. navigating to page 2, containing component 1 and component 2, both consuming query 1
  3. We get hydration errors for component 2 as the data on the server (skeleton loader) is not the same as on the client (which has the data cached already by TanStack Query as it consumes the same data as component 1)

Ignoring the hydration errors (as stated, React seems to work but throws errors), I experienced mentioned approach to be very effective in terms of UX (no / few cumulative layout shift - depending on the skeleton loaders, etc. as discussed above), performance (data from the server is static, we get very fast responses) and DX (requests are being made client side thus easy to debug + TanStack DevTools).

victor-auffret commented 1 month ago

With solidjs, you need juste extract createSignal

import { createSignal } from "solid-js";

const [count, setCount] = createSignal(0);

const Counter= () => {
  const increment = () => setCount((c) => c + 1);
  return <button onclick={increment}> compteur : {count()} </button>;
};

export { Counter};

So, the state is share between all component. If you need a "persist state" you just need state out of component but if you need state per component maybe you need something else