#Insteresting issue with reactive components.

28 messages · Page 1 of 1 (latest)

signal timber
#

Hey!

TLDR: We are looking for a solution to set the initial value of a nanostore from Astro, which does not break the component reactivity.

I've been dealing with an interesting issue for the past two days. I'm building a shop and trying to render a reactive SolidJS component that uses nanostores. I want to set an initial value from Astro (which fetched on middleware and stored in the context).

The Astro middleware fetches the user from our backend and stores it in context.locals.user. This works fine, we can protect routes by role, etc. The next middleware fetches the user's liked product ids and stores them in the context. We log that value on the server, and it shows the correct values.

I have a Layout.astro file, which is used everywhere. It contains the navigation, slot, etc.

We want to display the count of user-liked products in our navigation bar. This seems straightforward, right? Since we are using nanostore and want to set its initial value, we added the following to the Layout.astro file <body> part:

<SaveStoreLoader value={Astro.locals.saved_ids} client:only="solid-js" />

SaveStoreLoader is a simple Nanostore loader defined as:

export function storeLoader<T = any>(
  store: WritableAtom<T>
): Component<{
  value?: T
}> {
  return (props) => {
    if (!props.value) return;
    store.set(props.value);
    return <></>;
  };
}

export const saveStore = atom<number[]>([]);
export const SaveStoreLoader = storeLoader<number[]>(saveStore);

I'm not sure if this is the best way to set initial values from Astro on the client, but we have tried many different ways.

We are using that store inside a SolidJS component, which should display the liked products count (and update on change):

const NavHeartButton: Component<{}> = () => {
  const savedProducts = useStore(saveStore);

  onMount(() => {
    console.log('saved products:', savedProducts());
  });

  return (
    <a href="/profile?page=saved" class="relative mr-1">
      <FiHeart size="1.5rem" />
      <div class="absolute bottom-[-.1rem] right-[-.4rem] flex h-4 w-4 items-center justify-center rounded-full bg-red-500 pt-[.05rem] text-xs font-bold text-white">
        {savedProducts().length}
      </div>
    </a>
  );
};

The issue is that while it logs the correct value on the client side, the UI does not update to reflect that value. It seems like it's not reacting to the change.
We have also tried using createMemo.

What is the best way to properly use nanostores with initial values? We tried using SolidJS createStore, but we encountered the same problem.

#

of course we have found some fixes for that, but none of them seem like the correct way of doing this.
We tried creating a signal in each component that uses the store. Then onMount, we subscribed to the saveStore changes and modified the signal to reflect that value. This approach works fine and sets the value when the js loads. However, it's impractical to do this for every store inside every component. There should be a better way.

Out of curiosity, we also tried setting the nanostores value in the middleware. To our surprise, it actually worked as expected and rendered the initial page with the correct values. However, the store is shared on the server between different fetches, resulting in each user receiving other users liked products (basically what was set in the saveStore once, will be used on the next fetch, until a request changes that or server restarted)

signal timber
#

Another solution was to pass the liked product ids to the component as an initial value for rendering, but this approach would be very unmanageable for multiple stores, or on a large scale.

#

Maybe I am just overcomplicating this simple issue. If anyone knows the right way to implement something like this, any help would be appreciated.

signal timber
hidden cape
#

Hey!

Ok so this is a fairly in-depth issue and I think I see what you're trying to do! I actually quite like the idea of SaveStoreLoader... Don't know if you got that from somewhere else, but I've never seen that done before. Granted I haven't done much with nanostores before.

Right ok, so I want a quick sanity check before anything else.

So first, just to double check that you are using the nanostores/solid package ?

When you say that your NavHeartButton component isn't reflecting the change in the store, I just want to see if the SaveStoreLoader and the NavHeartButton modules are using the same store or if they are creating new ones.

So the first thing I'd do is: inside your storeLoader, where you set store.set(props.value), store another key in that state something like

store.set({
  count: props.value,
  id: performance.now() // generate a unique id for this module's store
})
console.log(store().id)

Then console log the store in your NavHeartButton component to just check it's the same!

If the two IDs match, then both are using the same store - which is what we want! Then in that case it feels more like an issue with how nanostores and solidjs signals work together. But the solid portion of this feels separate to the Astro side to me. Feels like SolidJS should just be loading normally, your store should get hydrated and then boom you should be in SolidJS land.

Also - you won't want to hear this 😂 - this would be way easier to debug with a stackblitz reproduction or a minimalist repo!

Aside from that, is there any reason you want to use nanostores here instead of just solidjs signals?

GitHub

Global state management in Solid using nanostores. - nanostores/solid

signal timber
#

Thank you for your response!
I have made the changes you have requested, and the issue still exists, but it is easier to debug with the "performance.now()" id.
You can check the results there: https://stackblitz.com/edit/github-n7www3-dmbywe?file=src%2Flayouts%2FLayout.astro

I have left a short comment about what I noticed.
More details:
When you initially request the page, it looks good. You can add products, and the UI reflects that value.

For debugging purposes, you can set the "products" parameter in the URL with numbers separated by commas. This represents our "middleware" which knows the value we should set on the page and uses the StoreLoader to do that.
For example set the this param: "?products=1,2,3". You can see the store sync id on the ui is the value that was being logged in the previous request (on the server). The storeloader updates that value, and the new logs on the server shows a different id (the new correct store with values). When you click on the "Add a product" button, it correctly updates the ui to the value that it should show (3+1 products), and the new sync id.
Now you can go in two directions:

  1. Refresh the page with the "products" parameter unchanged, and you can notice, from now on, every request loads twice (until you restart the server), you can check the logs.
  2. Remove the "products" search param, and refresh the page. You will notice that the requests does not load twice, unlike the previous example. Now you can check the client ui, which renders the previous store id (you can check the previous request's server log, and see that is the id being rendered there). However, in the client console, it shows the correct modified store id, with the correct array values. The component onMount logs a different store id (the clientside "performance.now()" value), but it contains the correct array values.

We have tried using solidjs signals, with a similar approach to the store loader, but the same issue exists.

I dont exactly know why the server can keep the state of the nanostore.

Run official live example code for Astro Framework Solid, created by Withastro on StackBlitz

hidden cape
#

So ok this is sort of what I was thinking, I've only just had a brief look but yes: So when don't use client:only="solid-js" then the server tries to hydrate the solid component on the server, and instantiates a new version of saveStore each page load - which is where the shared state comes from between requests.

Then the client is using a different version which hasn't been hydrated because it's actually a different instance of that store that Astro generated on the server

#

This is a good idea in theory tbh! I love the idea, just have no idea how to actually get that working without the server creating it's own instance

#

Also, gotta say this was one of the best reproductions I've ever seen. Clean, to the point and timely!

#

For a slightly better example of what I mean if you add a console log to the top level of your saveStore.tsx you'll see that it gets called twice, which from experience means that it's generating two instances for the same module https://stackblitz.com/edit/github-n7www3-brckyz?file=src%2FsaveStore.tsx,src%2Fpages%2Findex.astro,src%2Flayouts%2FLayout.astro,src%2FSolidComponent.tsx

StackBlitz

Run official live example code for Astro Framework Solid, created by Withastro on StackBlitz

#

@sonic orbit you wouldn't happen to have any thoughts on this would you?

Trying to instantiate a nanostore on the client with an island, but then another island loading creating it's own version of the store's module? So creating two different versions.

At least, that's what I think is happening.

sonic orbit
# hidden cape <@215273726168662019> you wouldn't happen to have any thoughts on this would you...

They are being loaded from the same island. It is an artifact of how Vite does hot module reloading on the dev server.
The module is loaded using its path and then loaded again using the timestamp of the last change to the file <file>?ts=1319273912. Effectively, as far as JS is concerned, those are two independent modules and both are instantiated independently.
If you run astro build && astro preview you'll see that the store doesn't get instantiated twice.

I might be a bug on @astrojs/solid not handling that correctly, I'm not versed in solid to know if this is a requirement/intentional or not.

signal timber
#

After another 10 hours of debugging I got some results.

signal timber
#

I found two plugins that can help solve this issue:
https://sr.ht/~ayoayco/astro-resume/
https://github.com/florian-lefebvre/astro-als/tree/main/package

Both plugins perform the same function: serializing data from astro to the client.
"astro-als" does this from the middleware context (which might fit our original use case since we fetch the saved ids in the middleware and storing it in the context).
However, for this example I used "astro-resume" because it allows us to still use the search params for debugging.

StackBlitz repro: https://stackblitz.com/edit/github-n7www3-tvbykn?file=src%2Flayouts%2FLayout.astro

Basically, instead of the storeloader, we update the signal 'initial' value in a <script> tag. We can can deserialize the product ids from astro by using "astro-resume" and use that data to update the signal.

The results:

  • When the SolidComponent.tsx has the attribute client:load, it works as expected in dev, but in prod it does not update the ui ('initial' value) for some reason, even if we add a setTimeout.
    The log shows correct results both in dev & prod, but the ui does not update to reflect that.
  • When the SolidComponent.tsx has the attribute client:only, it works as expected both in dev & prod. However this is not a solution, because if we consider the SolidComponent as a reusable component, we cant specify client:load, for example when we have a full page solid component that loads by client:load, and want to use the SolidComponent inside.

I dont exactly know why the hydration makes it unusable in production.

signal timber
sonic orbit
#

I dont exactly know why the hydration makes it unusable in production.
This is probably because the client's initial render is expected to be identical to the server's. Hydration is expected to add interactivity, not change the content. Some (not all) UI framework integrations assume that on their wiring and run the hydration in a way that doesn't change the DOM; instead, it just adds the appropriate listeners to it.
This ensures no FOOC (Flash of Original Content). You have to keep the initial render equal to the server.

It was reported recently on some integrations, and in all cases, it was considered intended behavior.

I found two plugins that can help solve this issue:
https://sr.ht/~ayoayco/astro-resume/
https://github.com/florian-lefebvre/astro-als/tree/main/package
Be aware that both of those rely on Node-specific APIs, so you won't be able to deploy it on non-Node runtimes like Deno or Cloudflare

#

Essentially, if you have a UI state that can only be computed on the client, you shouldn't be rendering that UI on the server since you won't have the state for it

signal timber
#

Thank you for the explanation. You are right, we should not try to render with nanostores on the server. Do you have any idea what is the most normal way to overcome this issue?
Of course we can pass the value in the component props from astro, but on large scale, that will be very unmanageable.

I was thinking about a function that can determine whether it is being executed on the server or on the client and use the value accordingly (on ssr it should know the value, on the client it should use the nanostore useStore hook).
But as far as I know we cant access the middleware context nor the Astro declaration in a non .astro file.

If we can export a variable on the server, that can be modified differently by each render (not shared), we might have a solution. By doing this, it would be possible to modify that value in the Layout.astro, then use that when rendering the component (because we know we are on the server at that moment). Then on the client we can initialize the nanostore with that value, and the ui will reflect that change when updated.
We could also create a function that generates this functionality for each store we specify. For example, if we have a nanostore called saveStore, we could use the generator function to create a getSaveStoreValue function. This function, when called inside a component, like const store = getSaveStoreValue(), would use the initial value on the server and then use the nanostore (useStore hook) on the client. Inside the component only the store variable will be used both on ssr & client.

I don't know if this logic is valid or not. As you guys think about this idea, you might realize why it may not work, since you know astro more deeply, haha.

sonic orbit
# signal timber Thank you for the explanation. You are right, we should not try to render with n...

I was thinking about a function that can determine whether it is being executed on the server or on the client and use the value accordingly
I have made an integration for that: https://inox-tools.vercel.app/astro-when

The logic is valid and it is even a good idea. The tricky part is this one:

Then on the client we can initialize the nanostore with that value
The code for both server and client is generated during build, not when the request is served. This means the code for the client can be changed to have an initial value coming from the server. The could would need to figure that out by itself.

You could use something like my library to detect when to do it, but the how remains:

import { whenAmI, When } from '@it-astro:when';

if (whenAmI === When.Client) {
  // How to get the information from the server here?
} else {
  // How to send the information made here to the client?
}

One way to do it is to have a component on your <head>, shared across all your layouts, that generates the state for the request and then serializes it on a <script id="app-state" type="application/json">{ ... state ...}</script>
That component would import the state file, the same your other components import, set the state for the request and serialize it.
Then, on the client side, you can initialize your state by reading serialized JSON:

import { whenAmI, When } from '@it-astro:when';

if (whenAmI === When.Client) {
  const state = JSON.parse(document.getElementById('app-state').innerText);
  // initialize your stores using that state
} else {
  // generate your state
}
---
// Will run the server branch since this run on the server
import { state } from './that-file.js';
---
<script id="app-state" type="application/json" set:text={JSON.stringify(state)} />

You'll still have one problem: a conflicting state between requests. And for that, there is no pure-JS solution (by that, I mean one that relies purely on the spec). You must rely on the runtime you are running to have ALS (AsyncLocalStorage) or some other form of contextual state.
AFAIK all runtimes that Astro have an official integration have support for ALS, so using something like astro-global to get access to the Astro constant everywhere is an option, in that case you can share per-request state on the locals property.

#

That is quite a bit of work, and for something that you should not do. Generate your state on your front matter and pass it around, which gives you a clear control and data flow.

Global/Shared state is unsafe and evil. It might not be hurting you at one moment, but it will hurt.

signal timber
#

Thank you for those ideas. I have extended them and came up with a solution that seems to be working both in development and production.
Basically, I created a storeHook, which uses astro:global on the server.

import { When, whenAmI } from '@it-astro:when'
import { useStore } from '@nanostores/solid'
import type { WritableAtom } from 'nanostores'
import type { Accessor, Component } from 'solid-js'

let Astro: any
if (import.meta.env.SSR) {
    Astro = (await import('astro:global')).default
}
export default Astro;


export const createHookStore = <T = any>(
    store: WritableAtom<T>,
    ssrValuePathOnAstro: (astro: any) => T,
    ssrValueIfNotFound: T
): Accessor<T> => {
    if (whenAmI === When.DevServer || whenAmI === When.Server) {
        return () => ssrValuePathOnAstro(Astro.locals) ?? ssrValueIfNotFound
    }
    return useStore(store)
}

With that I can define stores:

import { atom } from 'nanostores'
import { createHookStore } from './hook'

export const testStore = atom<number[]>([])
export const testStoreHook = createHookStore<number[]>(
    testStore,
    (locals: any) => locals.saved_ids,
    []
)

Using the store inside a component:

const ExampleComponent: Component<{}> = () => {
  const test = testStoreHook()

  return (
    <>
      Products: {test.length}
      <button onClick={() => {
        testStore.set([...test, 1])
      }}>Add Products</button>
    </>
  )
}

It renders with the correct value on the server, and on the client, it listens to the store changes.
Explanation:
Because we set the saved_ids in the Astro.locals (from middleware or from an .astro file), we can use the saved_ids from the Astro global. When rendering a component on the server that uses that hook, it gets the value from Astro. On the client, it loads the actual nanostore hook (useStore).

This doesn't set the store value on the client, but we can use the previously mentioned storeLoader with client:only which works correctly.

I have built and tested it, and it works in production too.

#

There might be better ways to check when we are on the server.

#

But while it works on my computer, I can't get the astro:global package to work on StackBlitz. Error:

  Stack trace:
    at new NoRequestError (/home/ezyyeah/testnano/node_modules/astro-global/runtime/virtual-module.ts:74:5)
    at Module.eval (/home/ezyyeah/testnano/src/hooks/store.ts:14:48)
    [...] See full stack trace in the browser, or rerun with --verbose.
  Caused by:
  "Attempt to access Astro.locals"```
The exact repro that I can run & build locally: https://stackblitz.com/~/github.com/ezyyeah/testnano
In this example you can set the `?products=1,2,3` search param to test the results. The length of the array should be rendered initially, and clicking on the button should increase that length.

Astro info:

Astro v4.12.2
Node v20.12.1
System macOS (arm64)
Package Manager yarn
Output server
Adapter @astrojs/node
Integrations @astrojs/solid-js
@inox-tools/astro-when
astro-global

#

Any thoughts on why?

#

If you have any idea how else we can import the Astro declaration only on the server, Im all ears!

signal timber
#

I tried to run the exact same repro on our server (ubuntu) and it works as expected.