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:
Example URL:
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:
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.
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
#418during hydration.Observed production browser error:
React error
#418means 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 ResponsiveProvidercrash 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:
Example URL:
No React hydration error is observed.
Fails
From Ankh Studio:
The infrastructure-hosted application is opened through the generated app port-forward, for example:
The application appears to render, but the browser console reports React error
#418.Important runtime difference
These are not equivalent rendering paths.
Generated Ankhorage apps currently configure Expo web with:
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.
ResponsiveProvidercurrently derives its initial runtime value directly from React Native'suseWindowDimensions()during render:This creates a possible static-render/hydration sequence like:
This is especially important because the responsive runtime is used during React render, not only after mount.
Examples:
ContainerContainerresolves responsivemaxWidthand horizontal padding immediately from the current breakpoint.Conceptually:
Even when this only changes styles, build-time and client-side render output may differ.
ShowShowcan change the actual React tree based on the responsive breakpoint:This is directly hydration-sensitive because static render and first browser render can produce different children.
Normal ZORA
ScreenA standard ZORA screen already reaches Surface responsive primitives:
ScrollArearesolves responsive box styles using the current breakpoint, whileContainerresolves 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:
That ownership problem has now been fixed:
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 webThen serve the generated
distdirectory with a static server and open the same route in a browser.Expected investigation result:
#418.#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:
Confirm whether the static render and hydration render disagree.
2. Determine whether mismatch is style-only or structural
Test representative Surface primitives:
ContainerScrollAreaGridShowShowis especially important because it can change the rendered tree.3. Verify Expo Router static rendering behavior
Use a minimal Expo Router app with:
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:
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:
The final solution should be general for Surface and not special-cased for Ankh Studio.
Non-goals / constraints
Do not fix this by:
ResponsiveProvider;suppressHydrationWarningas the architectural solution;Keep strict TypeScript.
No
any.No
ts-ignore.No eslint-disable.
Acceptance criteria
web.output = 'static'and Surface responsive primitives can be exported and hydrated without React error#418.Showdo not cause server/client tree mismatches.Screenusage throughScrollAreaandContainerhydrates without mismatch.suppressHydrationWarningworkaround is used as the primary fix.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:
Treat this as a Surface responsive-runtime hydration bug until the reproduction disproves that hypothesis.