Skip to content

Fix hydration mismatch in Expo static web builds using responsive runtime #63

Description

@artiphishle

Summary

Investigate and fix a hydration mismatch that occurs in generated Expo web applications when they are served from the production/static infrastructure path.

The same generated app works correctly when started through the Expo development server, but the infrastructure-hosted static production artifact logs React error #418 during hydration.

Observed production browser error:

Uncaught Error: Minified React error #418
    at bl (...)
    at fi (...)
    ...

React error #418 means the server/static-rendered HTML does not match the first client render closely enough for hydration.

This appears to be a separate issue from the previously fixed duplicate ZORA/Surface module-instance problem. The previous useResponsiveRuntime must be used within a ResponsiveProvider crash is gone and generated apps render again. The remaining failure only appears on the static production path.

Observed behavior

Works

Starting the generated application locally through Expo development mode:

bun web --clear

Example URL:

http://localhost:8082

No React hydration error is observed.

Fails

From Ankh Studio:

Project Detail
→ Infrastructure Up
→ Open running app

The infrastructure-hosted application is opened through the generated app port-forward, for example:

http://127.0.0.1:33960

The application appears to render, but the browser console reports React error #418.

Important runtime difference

These are not equivalent rendering paths.

bun web --clear
→ Expo development server
→ client/dev rendering path

Open running app
→ generated infrastructure-hosted production artifact
→ Expo web static export
→ pre-rendered route HTML
→ browser hydration

Generated Ankhorage apps currently configure Expo web with:

web: {
  ...config.web,
  output: 'static',
  favicon: './assets/favicon.png',
}

Therefore routes are statically rendered during export and later hydrated in the browser.

Leading hypothesis

The strongest current hypothesis is that Surface's responsive runtime is not hydration-stable for SSR/SSG/static rendering.

ResponsiveProvider currently derives its initial runtime value directly from React Native's useWindowDimensions() during render:

export function ResponsiveProvider({ children }: { children: React.ReactNode }) {
  const { width } = useWindowDimensions();

  const value = useMemo<ResponsiveRuntime>(
    () => ({
      breakpoint: getBreakpointFromWidth(width),
      width,
    }),
    [width],
  );

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

This creates a possible static-render/hydration sequence like:

Static export / build-time render
→ no real browser viewport
→ useWindowDimensions() resolves build-time/default dimensions
→ breakpoint A
→ HTML is generated for breakpoint A

Browser hydration
→ useWindowDimensions() sees the real viewport
→ breakpoint B
→ first client render differs from generated HTML
→ React hydration mismatch (#418)

This is especially important because the responsive runtime is used during React render, not only after mount.

Examples:

Container

Container resolves responsive maxWidth and horizontal padding immediately from the current breakpoint.

Conceptually:

const { breakpoint } = useResponsiveRuntime();
const activeMaxWidth = resolveResponsive(maxWidth, breakpoint);
const activePx = resolveSpacing(theme, resolveResponsive(px, breakpoint));

Even when this only changes styles, build-time and client-side render output may differ.

Show

Show can change the actual React tree based on the responsive breakpoint:

const { breakpoint } = useResponsiveRuntime();
const visible = resolveResponsive(when, breakpoint) ?? false;
return <>{visible ? children : fallback}</>;

This is directly hydration-sensitive because static render and first browser render can produce different children.

Normal ZORA Screen

A standard ZORA screen already reaches Surface responsive primitives:

ZORA Screen
→ Surface ScrollArea
→ Surface Container
→ useResponsiveRuntime()

ScrollArea resolves responsive box styles using the current breakpoint, while Container resolves responsive layout values.

So this issue can affect ordinary generated screens even without explicitly using Show.

Why this became visible now

A previous architecture bug allowed provider-side and renderer-side ZORA/Surface components to resolve through different package/module instances.

That caused:

useResponsiveRuntime must be used within a ResponsiveProvider

That ownership problem has now been fixed:

ZORA owns ZORA_COMPONENT_REGISTRY
Runtime is headless
Generated apps source ZoraProvider and concrete ZORA components from the same package instance

After that correction, the responsive runtime now executes correctly through the provider chain, which may have exposed this separate static-render hydration issue.

This issue should therefore not reopen or undo the registry ownership fix.

First reproduction to add

Before changing architecture, reproduce the production rendering path locally using the generated application itself.

Conceptually:

bunx expo export --platform web

Then serve the generated dist directory with a static server and open the same route in a browser.

Expected investigation result:

  • Expo dev server path does not produce #418.
  • Static export path reproduces #418.

If this reproduces locally, the infrastructure port-forward is not the cause; it is only exposing the static artifact.

Investigation areas

1. Determine the exact first-render dimension values

Instrument or test:

static/build-time width + breakpoint
first browser render width + breakpoint
post-mount browser width + breakpoint

Confirm whether the static render and hydration render disagree.

2. Determine whether mismatch is style-only or structural

Test representative Surface primitives:

  • Container
  • ScrollArea
  • Grid
  • Show
  • any responsive navigation/layout primitive

Show is especially important because it can change the rendered tree.

3. Verify Expo Router static rendering behavior

Use a minimal Expo Router app with:

web.output = static
Surface ThemeProvider / ResponsiveProvider
one responsive component

The fix should not depend on Ankh Studio or generated infrastructure.

4. Define the SSR/SSG responsive contract

Surface needs an explicit rule for the first render.

Conceptually:

static/server render
       │
       │ deterministic initial responsive state
       ▼
generated HTML
       │
       │ first client render uses the same state
       ▼
hydration succeeds
       │
       │ after mount
       ▼
real browser viewport is measured
       │
       ▼
responsive runtime updates normally

The implementation must ensure that build-time/static render and the browser's first hydration render agree.

Possible strategies should be evaluated carefully rather than chosen blindly, for example:

  • deterministic initial width/breakpoint shared by static render and first client render;
  • defer real viewport-dependent React-tree changes until after hydration;
  • move purely visual responsive behavior to CSS/media-query behavior where appropriate;
  • distinguish structural responsive rendering from style-only responsiveness;
  • another architecture that guarantees hydration-stable initial output.

The final solution should be general for Surface and not special-cased for Ankh Studio.

Non-goals / constraints

Do not fix this by:

  • adding another ResponsiveProvider;
  • reintroducing duplicate ZORA/Surface ownership;
  • moving concrete registry ownership back into Runtime or Studio;
  • using suppressHydrationWarning as the architectural solution;
  • disabling static output merely to hide the mismatch without first understanding the cause;
  • adding app-specific hydration hacks in generated Studio templates when the underlying issue belongs to Surface;
  • adding legacy/compatibility/fallback paths.

Keep strict TypeScript.

No any.
No ts-ignore.
No eslint-disable.

Acceptance criteria

  • A minimal Expo Router app using web.output = 'static' and Surface responsive primitives can be exported and hydrated without React error #418.
  • The generated/static HTML and the first client render use a hydration-compatible responsive state.
  • After hydration/mount, Surface still updates correctly to the real browser viewport and breakpoint.
  • Structural responsive components such as Show do not cause server/client tree mismatches.
  • Normal ZORA Screen usage through ScrollArea and Container hydrates without mismatch.
  • Expo development behavior remains correct.
  • Native React Native behavior remains correct.
  • No redundant provider workaround is introduced.
  • No suppressHydrationWarning workaround is used as the primary fix.
  • Regression coverage exists for the static-export + hydration boundary, not only client/dev rendering.

Related context

This was observed immediately after fixing the generated-app registry/provider ownership architecture. The provider-context crash is resolved; this issue concerns only the remaining production/static hydration mismatch.

The relevant consumer path is:

Ankh Studio
→ generated app
→ Infrastructure Up
→ Open running app
→ infrastructure-hosted static Expo web artifact
→ React hydration
→ error #418

Treat this as a Surface responsive-runtime hydration bug until the reproduction disproves that hypothesis.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions