Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { GenerateMetadataFunction, Metadata } from 'expo-router/server';
import { Text } from 'react-native';

export const generateMetadata: GenerateMetadataFunction = async (request, params) => {
const pathname = new URL(request.url).pathname;

return {
title: `Async Metadata ${params.id}`,
description: `Async metadata for ${pathname}`,
} satisfies Metadata;
};

export default function AsyncMetadataPage() {
return <Text testID="async-metadata-text">Async Metadata</Text>;
}
31 changes: 31 additions & 0 deletions apps/router-e2e/__e2e__/static-rendering/app/metadata.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Head from 'expo-router/head';
import { Text } from 'react-native';
import type { GenerateMetadataFunction, Metadata } from 'expo-router/server';

export const generateMetadata: GenerateMetadataFunction = async () => {
return {
title: 'Metadata Page',
description: 'Page with generateMetadata',
keywords: ['metadata', 'e2e'],
openGraph: {
title: 'Metadata OG Title',
description: 'Metadata OG Description',
},
twitter: {
card: 'summary',
title: 'Metadata Twitter Title',
},
} satisfies Metadata;
};

export default function MetadataPage() {
return (
<>
{/* The <Head> component is here to check that the app doesn't crash when using it with `generateMetadata()` */}
<Head>
<meta name="expo-e2e-metadata-head" content="head" />
</Head>
<Text testID="metadata-text">Metadata</Text>
</>
);
}
2 changes: 2 additions & 0 deletions packages/@expo/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
- Enable parallel CocoaPods code signing to speed up device builds. ([#43529](https://github.com/expo/expo/pull/43529) by [@evanbacon](https://github.com/evanbacon))
- Prompt before clearing native folders when we detect project as a native module ([#44458](https://github.com/expo/expo/pull/44458) by [@kitten](https://github.com/kitten))
- Rewrite @react-navigation/core to expo-router for library compatibility ([#45039](https://github.com/expo/expo/pull/45039) by [@Ubax](https://github.com/Ubax))
- Use stream rendering in SSR ([#43963](https://github.com/expo/expo/pull/43963) by [@hassankhan](https://github.com/hassankhan))
- Add support for metadata in streaming SSR ([#44731](https://github.com/expo/expo/pull/44731) by [@hassankhan](https://github.com/hassankhan))

### 🐛 Bug fixes

Expand Down

Large diffs are not rendered by default.

105 changes: 97 additions & 8 deletions packages/@expo/cli/e2e/__tests__/export/server-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ describe('exports server', () => {
)('$name requests', (config) => {
const server = setupServer(config);

it('sets the `Transfer-Encoding: chunked` header', async () => {
const res = await server.fetchAsync('/');
expect(res.headers.get('Transfer-Encoding')).toEqual('chunked');
});

it(`can serve up index html`, async () => {
const html = getHtml(await server.fetchAsync('/').then((res) => res.text()));
expect(html.querySelector('[data-testid="index-text"]')?.textContent).toEqual('Index');
Expand Down Expand Up @@ -214,7 +219,25 @@ describe('exports server', () => {
it('injects hydration assets into SSR response', async () => {
const html = await server.fetchAsync('/').then((res) => res.text());

expect(html).toMatch(/<script src="\/_expo\/static\/js\/web\/entry-.*\.js" defer><\/script>/);
// Streaming SSR uses bootstrapScripts which emits async scripts (with an id attribute)
expect(html).toMatch(
/<script src="\/_expo\/static\/js\/web\/entry-.*\.js"[^>]*async=""><\/script>/
);
});

it('emits the hydration flag before the bootstrap script', async () => {
const html = await server.fetchAsync('/').then((res) => res.text());

const hydrationFlagIndex = html.indexOf(
'<script type="module">globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>'
);
const bootstrapScriptIndex = html.search(
/<script src="\/_expo\/static\/js\/web\/entry-.*\.js"[^>]*async=""><\/script>/
);

expect(hydrationFlagIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapScriptIndex).toBeGreaterThanOrEqual(0);
expect(hydrationFlagIndex).toBeLessThan(bootstrapScriptIndex);
});

it('SSR styles are injected', async () => {
Expand Down Expand Up @@ -242,7 +265,10 @@ describe('exports server', () => {

const links = indexHtml.querySelectorAll('html > head > link').filter((link) => {
// Fonts are tested elsewhere
return link.attributes.as !== 'font';
if (link.attributes.as === 'font') return false;
// Streaming SSR adds <link rel="preload" as="script"> for bootstrapScripts
if (link.attributes.as === 'script') return false;
return true;
});
expect(links.length).toBe(
// Global CSS, CSS Module
Expand Down Expand Up @@ -284,7 +310,10 @@ describe('exports server', () => {

// CSS Module
expect(
fs.readFileSync(path.join(server.outputDir, 'client', links[2]?.attributes.href ?? ''), 'utf-8')
fs.readFileSync(
path.join(server.outputDir, 'client', links[2]?.attributes.href ?? ''),
'utf-8'
)
).toMatchInlineSnapshot(`".HPV33q_text{color:#1e90ff}"`);

const styledHtml = getHtml(await server.fetchAsync('/styled').then((res) => res.text()));
Expand All @@ -310,7 +339,11 @@ describe('exports server', () => {

expect(
fs.readFileSync(
path.join(server.outputDir, 'client', links[0]?.attributes.href?.replace(/\?.*$/, '') ?? ''),
path.join(
server.outputDir,
'client',
links[0]?.attributes.href?.replace(/\?.*$/, '') ?? ''
),
'utf-8'
)
).toBeDefined();
Expand Down Expand Up @@ -340,10 +373,33 @@ describe('exports server', () => {
// Root element
expect(page).toContain('<div id="root">');

const sanitized = page.replace(
/<script src="\/_expo\/static\/js\/web\/.*" defer>/,
'<script src="/_expo/static/js/web/[mock].js" defer>'
);
const sanitized = page
// Streaming SSR: <script src="..." id="_R_" async="">
.replace(
/<script src="\/_expo\/static\/js\/web\/[^"]*"[^>]*async="">/,
'<script src="/_expo/static/js/web/[mock].js" async="">'
)
// Streaming SSR: <link rel="preload" as="script" fetchPriority="low" href="..."/>
.replace(
/<link rel="preload" as="script"[^>]*href="\/_expo\/static\/js\/web\/[^"]*"[^>]*\/>/,
'<link rel="preload" as="script" href="/_expo/static/js/web/[mock].js"/>'
)
.replace(
/<link rel="preload" href="\/_expo\/static\/css\/global-[^"]*\.css" as="style">/,
'<link rel="preload" href="/_expo/static/css/global-[mock].css" as="style">'
)
.replace(
/<link rel="stylesheet" href="\/_expo\/static\/css\/global-[^"]*\.css">/,
'<link rel="stylesheet" href="/_expo/static/css/global-[mock].css">'
)
.replace(
/<link rel="preload" href="\/_expo\/static\/css\/test\.module-[^"]*\.css" as="style">/,
'<link rel="preload" href="/_expo/static/css/test.module-[mock].css" as="style">'
)
.replace(
/<link rel="stylesheet" href="\/_expo\/static\/css\/test\.module-[^"]*\.css">/,
'<link rel="stylesheet" href="/_expo/static/css/test.module-[mock].css">'
);
expect(sanitized).toMatchSnapshot();

expect(
Expand Down Expand Up @@ -388,5 +444,38 @@ describe('exports server', () => {
).querySelector('html > head > meta[name="expo-nested-layout"]')?.attributes.content
).toBe('TEST_VALUE');
});

it('injects `generateMetadata()` result into the initial server HTML <head>', async () => {
const html = await server.fetchAsync('/metadata').then((res) => res.text());
const page = getHtml(html);
const head = page.querySelector('html > head');

expect(page.querySelector('html > body [data-testid="metadata-text"]')?.innerText).toBe(
'Metadata'
);
expect(head).not.toBeNull();

const metadataHeadNodes = head!.childNodes
.filter(
(node: any) => node.rawTagName && ['title', 'meta'].includes(node.rawTagName as string)
)
.map((node) => node.toString());

expect(metadataHeadNodes).toMatchSnapshot();
});

it('resolves async `generateMetadata()` with request and route params', async () => {
const page = getHtml(
await server.fetchAsync('/metadata-async/123').then((res) => res.text())
);

expect(page.querySelector('html > body [data-testid="async-metadata-text"]')?.innerText).toBe(
'Async Metadata'
);
expect(page.querySelector('html > head > title')?.innerText).toBe('Async Metadata 123');
expect(page.querySelector('html > head > meta[name="description"]')?.attributes.content).toBe(
'Async metadata for /metadata-async/123'
);
});
});
});
15 changes: 9 additions & 6 deletions packages/@expo/cli/e2e/__tests__/export/static-splitting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ describe('exports static with bundle splitting', () => {
'asset',
'links',
'styled',
'metadata',
'\\[id\\]',
]
.sort()
.map((file) =>
Expand All @@ -114,8 +116,8 @@ describe('exports static with bundle splitting', () => {
expect(sourceMap.version).toBe(3);

// Common chunk
if (file!.match(/head/)) {
expect(sourceMap.sources.length).toEqual(29);
if (file!.match(/__common/)) {
expect(sourceMap.sources.length).toEqual(43);
} else {
// expect(sourceMap.sources).toEqual(
// expect.arrayContaining([
Expand Down Expand Up @@ -155,9 +157,7 @@ describe('exports static with bundle splitting', () => {
// non-public env vars are injected during SSG
expect(queryMeta('expo-e2e-private-env-var-client')).toEqual('not-public-value');

const script = indexHtml
.querySelectorAll('script')
.find((script) => !!script.attributes.src);
const script = indexHtml.querySelectorAll('script').find((script) => !!script.attributes.src);
const jsBundle = fs.readFileSync(path.join(outputDir, script?.attributes.src ?? ''), 'utf8');

// Ensure the bundle is valid
Expand Down Expand Up @@ -255,7 +255,10 @@ describe('exports static with bundle splitting', () => {
);

expect(
fs.readFileSync(path.join(outputDir, links[0]?.attributes.href?.replace(/\?.*$/, '') ?? ''), 'utf-8')
fs.readFileSync(
path.join(outputDir, links[0]?.attributes.href?.replace(/\?.*$/, '') ?? ''),
'utf-8'
)
).toBeDefined();

// Ensure the font is used
Expand Down
74 changes: 74 additions & 0 deletions packages/@expo/cli/e2e/playwright/prod/server-rendering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';

import { clearEnv, restoreEnv } from '../../__tests__/export/export-side-effects';
import { getRouterE2ERoot } from '../../__tests__/utils';
import { createExpoServe, executeExpoAsync } from '../../utils/expo';
import { pageCollectErrors } from '../page';

test.beforeAll(() => clearEnv());
test.afterAll(() => restoreEnv());

const projectRoot = getRouterE2ERoot();
const outputDir = 'dist-server-rendering-async-playwright';

test.describe('server rendering in production', () => {
const expoServe = createExpoServe({
cwd: projectRoot,
env: {
NODE_ENV: 'production',
TEST_SECRET_KEY: 'test-secret-key',
},
});

test.beforeAll(async () => {
console.time('expo export');
await executeExpoAsync(projectRoot, ['export', '-p', 'web', '--output-dir', outputDir], {
env: {
NODE_ENV: 'production',
EXPO_USE_STATIC: 'server',
E2E_ROUTER_SRC: 'static-rendering',
E2E_ROUTER_SERVER_RENDERING: 'true',
},
});
console.timeEnd('expo export');

console.time('expo serve');
await expoServe.startAsync([outputDir]);
console.timeEnd('expo serve');
});
test.afterAll(async () => {
await expoServe.stopAsync();
});

test('loads page without JavaScript errors', async ({ page }) => {
const pageErrors = pageCollectErrors(page);

await page.goto(expoServe.url.href);
await page.waitForSelector('[data-testid="index-text"]');

expect(pageErrors.errors).toEqual([]);
});

test('hydrates and performs client-side navigation from the links page', async ({ page }) => {
const pageErrors = pageCollectErrors(page);

await page.goto(new URL('/links', expoServe.url).href);
await page.waitForSelector('[data-testid="links-one"]');

await page.evaluate(() => {
(window as any).__e2eMarker = 'alive';
});

await page.locator('[data-testid="links-one"]').click();

await expect(page).toHaveURL(new URL('/about', expoServe.url).href);
await expect(page.locator('[data-testid="content"]')).toHaveText('About');

expect(
await page.evaluate(() => {
return (window as any).__e2eMarker;
})
).toBe('alive');
expect(pageErrors.all).toEqual([]);
});
});
3 changes: 3 additions & 0 deletions packages/@expo/router-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

### 🎉 New features

- Use stream rendering in SSR ([#43963](https://github.com/expo/expo/pull/43963) by [@hassankhan](https://github.com/hassankhan))
- Add support for metadata in streaming SSR ([#44731](https://github.com/expo/expo/pull/44731) by [@hassankhan](https://github.com/hassankhan))

### 🐛 Bug fixes

### 💡 Others
Expand Down
16 changes: 16 additions & 0 deletions packages/@expo/router-server/build/server/metadata.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions packages/@expo/router-server/build/server/metadata.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/@expo/router-server/build/server/metadata.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading