fresh icon indicating copy to clipboard operation
fresh copied to clipboard

Preact Context returns initial value instead of provided value

Open wobsoriano opened this issue 3 months ago • 6 comments

Problem

Preact Context consistently returns the initial context value instead of the provided value during server-side rendering in Fresh, even when the provider is properly set up with a value.

Reproduction Steps

  1. Create a Fresh app with Vite
  2. Create a provider component:
import { ComponentChildren, createContext } from "preact";
import { useContext } from "preact/hooks";

export const SomeContext = createContext<User | null>(null);

export default function SomeProvider({ children }: { children: ComponentChildren }) {
  return (
    <SomeContext.Provider value={{ userId: "user_123" }}>  {/* PROVIDED VALUE */}
      {children}
    </SomeContext.Provider>
  );
}

export function useUser() {
  const user = useContext(SomeContext);  // Returns null instead of provided value
  if (!user) {
    throw new Error("User context not found");
  }
  return user;
}
  1. Use in _app.tsx:
<SomeProvider>
  <Component />
</SomeProvider>
  1. Use in page component:
export default define.page(function Home(ctx) {
  const user = useUser(); // Throws error - gets null instead of provided value
  return <p>User: {user?.userId}</p>;
});
  1. Run deno task dev and visit the page

Error output

Error: User context not found
    at useUser (/path/to/components/SomeProvider.tsx:9:293)
    at Object.Home (/path/to/routes/index.tsx:10:547)

Repro https://github.com/wobsoriano/fresh-preact-context-bug

wobsoriano avatar Sep 10 '25 18:09 wobsoriano

Looks like useContext() indeed clashes with Fresh island rehydration somehow.

FWIW I use a similar pattern without useContext() that works fine for me, see these two files:

  • https://github.com/fry69/orw/blob/fresh-vite/orw-deno/routes/(main)/_layout.tsx (DataInitializer enclosure)
    • that is the equivalent to your _app.tsx (it was before in my _app.tsx before I refactored
  • https://github.com/fry69/orw/blob/fresh-vite/orw-deno/islands/DataInitializer.tsx (actual implementation)

fry69 avatar Sep 10 '25 19:09 fry69

Interesting!

Need useContext() here as I'm building a Fresh SDK for Clerk:

The main provider - https://github.com/wobsoriano/fresh-clerk/blob/main/src/islands/ClerkProvider.tsx

and all the hooks, components and islands depend on it

wobsoriano avatar Sep 10 '25 19:09 wobsoriano

I spend a bit more time with that while burning my Anthropic credits before they expire.

This seems to be a fundamental issue with how Fresh works.

Here is an analysis from Claude Code after toying with your repro repo for ~30 mins:


Generated:

Now I understand the core issue. Looking at the Fresh code, islands are isolated render boundaries. When Fresh encounters an island component, it:

  1. Wraps the island with markers (wrapWithMarker)
  2. Serializes props separately (islandProps.push)
  3. Renders islands independently during client hydration

This means context providers in the main render tree cannot be accessed by islands because islands are rendered in isolation.

The Real Answer: How useContext() Should Work in Fresh 2

Context providers cannot cross island boundaries in Fresh 2. Here are the correct patterns:

Working Pattern 1: Context WITHIN Islands

Put both provider and consumer in the same island:

// islands/UserIsland.tsx
import { createContext, useContext } from "preact";
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";

const UserContext = createContext(null);

function UserProvider({ children }) {
  const user = useSignal(null);

  useEffect(() => {
    setTimeout(() => user.value = { userId: "user_123" }, 1500);
  }, []);

  return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}

function UserDisplay() {
  const user = useContext(UserContext);
  return <p>User: {user?.value?.userId ?? "Loading..."}</p>;
}

export default function UserIsland() {
  return (
    <UserProvider>
      <UserDisplay />
    </UserProvider>
  );
}

Working Pattern 2: Use Props Instead of Context

Pass data through props from route handlers:

// routes/index.tsx
import { page } from "fresh";

export const handler = {
  async GET() {
    const user = { userId: "user_123" };
    return page({ user });
  },
};

export default define.page<typeof handler>(({ data }) => (
  <div>
    <UserDisplay user={data.user} />
  </div>
));

// islands/UserDisplay.tsx
export default function UserDisplay({ user }) {
  return <p>User: {user.userId}</p>;
}

Working Pattern 3: Global State Management

Use a global signal that islands can subscribe to:

// signals/user.ts
import { signal } from "@preact/signals";

export const userSignal = signal(null);

// Auto-load user data
setTimeout(() => userSignal.value = { userId: "user_123" }, 1500);

// islands/UserDisplay.tsx
import { userSignal } from "../signals/user.ts";
// signals/user.ts
import { signal } from "@preact/signals";

export const userSignal = signal(null);

// Auto-load user data
setTimeout(() => userSignal.value = { userId: "user_123" }, 1500);

// islands/UserDisplay.tsx
import { userSignal } from "../signals/user.ts";

export default function UserDisplay() {
  return <p>User: {userSignal.value?.userId ?? "Loading..."}</p>;
}

Summary

The bug you encountered reveals a fundamental Fresh 2.0 architectural limitation: Context providers in layouts/routes cannot provide context to islands because islands are isolated render boundaries.

Your original code was architecturally impossible in Fresh 2.0. The correct patterns are:

  1. Self-contained islands with internal context
  2. Props from route handlers for server data
  3. Global signals for shared client state

The island architecture prioritizes performance and hydration reliability over context sharing across boundaries. This is by design, not a bug - it ensures islands can hydrate independently without depending on the broader page context.

fry69 avatar Sep 11 '25 16:09 fry69

Thanks for the detailed research here @fry69 ! Note that it happens on non-island provider component too

wobsoriano avatar Sep 11 '25 17:09 wobsoriano

Note that it happens on non-island provider component too

Yes. Claude played through possible combinations to get useContext() working until it finally seemed to have found the right place in the Fresh code base and surrendered.

fry69 avatar Sep 11 '25 17:09 fry69

I just found out that I still have the terminal session open, so here is the whole shebang for curious minds:

https://gist.github.com/fry69/a12447a00535c9303f7ca59a2e2ca5a0

fry69 avatar Sep 11 '25 18:09 fry69