Skip to content

Commit d73c1be

Browse files
KemingHewei
andauthored
♻️ Migration to app router, Upgrade to Next 15/React 19 (#450)
* ✅ Added Playwright behavior tests to image api endpoints Screenshot all 4 GET image api endpoints with complex config per viewport type (Chrome and Mobile Chrome) in preparation for migration. Also updated TailwindCSS config from .js to .ts. * 🎨 Strongly typed preview card and card theme wrapper, separeted exports. * ♻️ Full (verified) api migration from page to app router. * 🎨 (Header) Moved GitHub corner styling to global.css, removed styled-jsx dep. * ♻️ Full front+backend migration from page router to app router. Followed nextjs official docs and best practices, backed by Playwright behavior regression test passing. Upated unit tests to reflect the new framework. * 📝 Added changeset reflecting page to app router migration. * ⬆️ Upgraded successfully to Next15/React19 with minor fixes. Upgraded using the official NextJS codemod. No file modded by the codemod. Updated types and deps. Fully compatible with exising behavior tests, all passed. * ✅ Added component update timeout to prevent flaky tests. * 🎨 Polished code based on comments and added server-only dep Regressively tested all the requested changes, and determined safe to keep them. Added server-only prod dep to explicitly limit code involving GITHUB_TOKEN to only run server-side. * ⬆️ Upgraded depedencies to latest patch/minor verisons w/out breakage. * 📝 Updated favicon, web app icons, and manifest per nextjs standard. * ✅ Updated user story playwright test and snapshots Replaced next <Head> with html <head> in preview to resolve font not updating issue. Updated Playwright user story regressing test and snapshot to cover this weak point. Updated select wrapper component to be more accessible and compatible with Playwright. Not sure why config panel width changed after editing component, thus had to update one UI snapshot as well. Further polished pnpm scripts to better match actual flag name, i.e. pnpm test:e2e:update-snapshots to match playwright test --update-snapshots (with an 's') * 🐛 Fix preview font, update linting and snapshots --------- Co-authored-by: Wei He <[email protected]> Co-authored-by: Wei He <[email protected]>
1 parent 811ccd2 commit d73c1be

File tree

80 files changed

+1230
-778
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+1230
-778
lines changed

.changeset/sour-beds-tap.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"socialify": minor
3+
---
4+
5+
Full migration from page router to app router.
6+
7+
Upgraded to nextjs15/react19 via official codemod and applied type fixes.

.playwright/imageAPIEndpoints.spec.ts

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { type Page, expect, test } from '@playwright/test'
2+
3+
const customTimeout = { timeout: 30000 }
4+
5+
const defaultImageURL: string =
6+
'/wei/socialify/image?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'
7+
const svgImageURL: string =
8+
'/wei/socialify/svg?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'
9+
const pngImageURL: string =
10+
'/wei/socialify/png?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'
11+
// Backward compatibility route.
12+
const jpgImageURL: string =
13+
'/wei/socialify/jpg?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'
14+
15+
test.describe('Socialify image api', () => {
16+
test('respond consistently for default endpoint', async ({
17+
page,
18+
}: { page: Page }): Promise<void> => {
19+
await page.goto(defaultImageURL, customTimeout)
20+
21+
// Wait for the page to load/hydrate completely.
22+
await page.waitForLoadState('networkidle', customTimeout)
23+
24+
const image = await page.screenshot()
25+
expect(image).toMatchSnapshot()
26+
})
27+
28+
test('respond consistently for svg endpoint', async ({
29+
page,
30+
}: { page: Page }): Promise<void> => {
31+
await page.goto(svgImageURL, customTimeout)
32+
33+
// Wait for the page to load/hydrate completely.
34+
await page.waitForLoadState('networkidle', customTimeout)
35+
36+
const image = await page.screenshot()
37+
expect(image).toMatchSnapshot()
38+
})
39+
40+
test('respond consistently for png endpoint', async ({
41+
page,
42+
}: { page: Page }): Promise<void> => {
43+
await page.goto(pngImageURL, customTimeout)
44+
45+
// Wait for the page to load/hydrate completely.
46+
await page.waitForLoadState('networkidle', customTimeout)
47+
48+
const image = await page.screenshot()
49+
expect(image).toMatchSnapshot()
50+
})
51+
52+
test('respond consistently for backwards-compatible jpg endpoint', async ({
53+
page,
54+
}: { page: Page }): Promise<void> => {
55+
await page.goto(jpgImageURL, customTimeout)
56+
57+
// Wait for the page to load/hydrate completely.
58+
await page.waitForLoadState('networkidle', customTimeout)
59+
60+
const image = await page.screenshot()
61+
expect(image).toMatchSnapshot()
62+
})
63+
})

.playwright/simpleUserStory.spec.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { type Page, expect, test } from '@playwright/test'
22

33
const customTimeout = { timeout: 30000 }
4+
const componentUpdateTimeout = 1000
45

56
// Testing constants.
67
const repo: string = 'wei/socialify'
78
const expectedConfigURL: string =
89
'/wei/socialify?language=1&owner=1&name=1&stargazers=1&theme=Light'
910
const expectedImageURLRegExp: RegExp =
10-
/\/wei\/socialify\/image\?description=1&language=1&name=1&owner=1&theme=Light$/
11+
/\/wei\/socialify\/image\?description=1&font=Source\+Code\+Pro&language=1&name=1&owner=1&theme=Light$/
1112

1213
async function getClipboardText(page: Page): Promise<string> {
1314
return await page.evaluate(async () => {
@@ -28,6 +29,7 @@ test.describe('A simple user story:', () => {
2829
}: { page: Page }): Promise<void> => {
2930
// Input and submit the repo following accessibility best practices.
3031
await page.fill('input[name="repo-input"]', repo)
32+
await page.waitForTimeout(componentUpdateTimeout)
3133
await page.click('button[type="submit"]')
3234

3335
// Wait for navigation to the preview config page.
@@ -38,7 +40,12 @@ test.describe('A simple user story:', () => {
3840
await expect(page).toHaveURL(expectedConfigURL)
3941

4042
await page.click('input[name="stargazers"]')
43+
await page.waitForTimeout(componentUpdateTimeout)
4144
await page.click('input[name="description"]')
45+
await page.waitForTimeout(componentUpdateTimeout)
46+
// Select the "Source Code Pro" option for max diff from default.
47+
await page.selectOption('select[name="font"]', { label: 'Source Code Pro' })
48+
await page.waitForTimeout(componentUpdateTimeout)
4249

4350
// Obtain the consistent preview image URL.
4451
await page.click('button:has-text("URL")')

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2020 Wei He
3+
Copyright (c) 2024 Wei He <https://wei.mit-license.org>
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

app/[_owner]/[_name]/page.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use client'
2+
3+
import { JSX } from 'react'
4+
5+
import MainRenderer from '@/src/components/mainRenderer'
6+
7+
export default function PreviewConfigPage(): JSX.Element {
8+
return <MainRenderer />
9+
}

pages/api/font.ts app/api/font/route.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import type { NextRequest } from 'next/server'
44

55
import { FontDetector, languageFontMap } from '@/common/font'
66

7-
export const config = {
8-
runtime: 'edge',
9-
}
7+
export const runtime = 'edge'
108

119
const detector = new FontDetector()
1210

@@ -37,7 +35,8 @@ function encodeFontInfoAsArrayBuffer(code: string, fontData: ArrayBuffer) {
3735
return buffer
3836
}
3937

40-
export default async function loadGoogleFont(req: NextRequest) {
38+
// export default async function loadGoogleFont(req: NextRequest) {
39+
export async function GET(req: NextRequest) {
4140
if (req.nextUrl.pathname !== '/api/font') return
4241

4342
const { searchParams } = new URL(req.url)
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
1+
import 'server-only'
12
import type { NextRequest } from 'next/server'
23

3-
const API_ENDPOINT: string = 'https://api.github.com/graphql'
4+
import { GITHUB_GRAPHQL_ENDPOINT } from '@/common/constants'
45

5-
const graphQLEndpoint = async (req: NextRequest) => {
6-
if (req.method !== 'POST') {
7-
return new Response('Method Not Allowed', {
8-
status: 405,
9-
headers: {
10-
'cache-control': 'max-age=0, public',
11-
},
12-
})
13-
}
6+
export const runtime = 'edge'
147

15-
const response = await fetch(API_ENDPOINT, {
8+
export async function POST(req: NextRequest) {
9+
const response = await fetch(GITHUB_GRAPHQL_ENDPOINT, {
1610
method: 'POST',
1711
headers: {
1812
Accept: 'application/json',
1913
Authorization: `bearer ${process.env.GITHUB_TOKEN}`,
2014
'content-type': 'application/json',
2115
},
2216
body: req.body,
17+
// @ts-expect-error: 'duplex' is not part of the RequestInit type but required by GraphQL.
18+
duplex: 'half',
2319
})
2420

2521
if (!response.ok) {
@@ -41,9 +37,3 @@ const graphQLEndpoint = async (req: NextRequest) => {
4137
},
4238
})
4339
}
44-
45-
export const config = {
46-
runtime: 'edge',
47-
}
48-
49-
export default graphQLEndpoint

app/api/image/route.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { isBot } from 'next/dist/server/web/spec-extension/user-agent'
2+
import type { NextRequest, NextResponse } from 'next/server'
3+
4+
import { GET as GETPng } from '@/app/api/png/route'
5+
import { GET as GETSvg } from '@/app/api/svg/route'
6+
7+
export const runtime = 'edge'
8+
9+
export async function GET(req: NextRequest): Promise<NextResponse> {
10+
if (isBot(req.headers.get('user-agent') ?? '')) {
11+
return GETPng(req)
12+
} else {
13+
return GETSvg(req)
14+
}
15+
}

pages/api/png.ts app/api/png/route.ts

+6-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import type { NextRequest } from 'next/server'
1+
import { type NextRequest, NextResponse } from 'next/server'
22

33
import renderCardPNG from '@/common/renderPNG'
44
import type QueryType from '@/common/types/queryType'
55

6-
const pngEndpoint = async (req: NextRequest) => {
6+
export const runtime = 'edge'
7+
8+
export async function GET(req: NextRequest): Promise<NextResponse> {
79
const { searchParams } = new URL(req.url)
810
const query = Object.fromEntries(searchParams) as QueryType
911

1012
try {
11-
return new Response(await renderCardPNG(query), {
13+
return new NextResponse(await renderCardPNG(query), {
1214
status: 200,
1315
headers: {
1416
'content-type': 'image/png',
@@ -27,7 +29,7 @@ const pngEndpoint = async (req: NextRequest) => {
2729
}
2830
console.error(errorJSON)
2931

30-
return new Response(JSON.stringify(errorJSON), {
32+
return new NextResponse(JSON.stringify(errorJSON), {
3133
status: 400,
3234
headers: {
3335
'content-type': 'application/json',
@@ -36,9 +38,3 @@ const pngEndpoint = async (req: NextRequest) => {
3638
})
3739
}
3840
}
39-
40-
export const config = {
41-
runtime: 'edge',
42-
}
43-
44-
export default pngEndpoint

pages/api/stats.svg.ts app/api/stats.svg/route.ts

+7-11
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { badgen } from 'badgen'
2-
import type { NextRequest } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
33

4-
import statsEndpoint from '@/pages/api/stats'
4+
import { GET as GETStats } from '@/app/api/stats/route'
55

6-
const statsSvgEndpoint = async (req: NextRequest) => {
6+
export const runtime = 'edge'
7+
8+
export async function GET(req: NextRequest): Promise<NextResponse> {
79
let totalCount = 0
810

911
try {
10-
const apiResponse = await (await statsEndpoint(req)).json()
12+
const apiResponse = await (await GETStats(req)).json()
1113
if (apiResponse.total_count) {
1214
totalCount = apiResponse.total_count
1315
}
@@ -33,7 +35,7 @@ const statsSvgEndpoint = async (req: NextRequest) => {
3335
style: 'flat',
3436
})
3537

36-
return new Response(svg, {
38+
return new NextResponse(svg, {
3739
status: 200,
3840
headers: {
3941
'content-type': 'image/svg+xml',
@@ -43,9 +45,3 @@ const statsSvgEndpoint = async (req: NextRequest) => {
4345
},
4446
})
4547
}
46-
47-
export const config = {
48-
runtime: 'edge',
49-
}
50-
51-
export default statsSvgEndpoint
+11-13
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
import type { NextRequest } from 'next/server'
1+
import 'server-only'
2+
import { GITHUB_API_ENDPOINT } from '@/common/constants'
3+
import { type NextRequest, NextResponse } from 'next/server'
24

3-
const statsEndpoint = async (_req: NextRequest) => {
5+
export const runtime = 'edge'
6+
7+
export async function GET(_req: NextRequest): Promise<NextResponse> {
48
const response = await fetch(
5-
`https://api.github.com/search/code?per_page=1&q=${encodeURIComponent(
9+
`${GITHUB_API_ENDPOINT}/search/code?per_page=1&q=${encodeURIComponent(
610
'socialify.git.ci'
711
)}`,
812
{
913
method: 'GET',
1014
headers: {
11-
Accept: 'application/vnd.github.v3+json',
12-
Authorization: `bearer ${process.env.GITHUB_TOKEN}`,
15+
accept: 'application/vnd.github.v3+json',
16+
authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
1317
'content-type': 'application/json',
1418
},
1519
}
1620
)
1721

1822
if (!response.ok) {
19-
return new Response(await response.text(), {
23+
return new NextResponse(await response.text(), {
2024
status: response.status,
2125
headers: {
2226
'cache-control': 'public, max-age=0',
@@ -25,7 +29,7 @@ const statsEndpoint = async (_req: NextRequest) => {
2529
}
2630

2731
const json = await response.json()
28-
return new Response(JSON.stringify({ total_count: json.total_count }), {
32+
return new NextResponse(JSON.stringify({ total_count: json.total_count }), {
2933
status: 200,
3034
headers: {
3135
'content-type': 'application/json',
@@ -35,9 +39,3 @@ const statsEndpoint = async (_req: NextRequest) => {
3539
},
3640
})
3741
}
38-
39-
export const config = {
40-
runtime: 'edge',
41-
}
42-
43-
export default statsEndpoint

pages/api/svg.ts app/api/svg/route.ts

+6-10
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import type { NextRequest } from 'next/server'
1+
import { type NextRequest, NextResponse } from 'next/server'
22

33
import renderCardSVG from '@/common/renderSVG'
44
import type QueryType from '@/common/types/queryType'
55

6-
const svgEndpoint = async (req: NextRequest) => {
6+
export const runtime = 'edge'
7+
8+
export async function GET(req: NextRequest): Promise<NextResponse> {
79
const { searchParams } = new URL(req.url)
810
const query = Object.fromEntries(searchParams) as QueryType
911

1012
try {
1113
const svg = await renderCardSVG(query)
1214

13-
return new Response(svg, {
15+
return new NextResponse(svg, {
1416
status: 200,
1517
headers: {
1618
'content-type': 'image/svg+xml',
@@ -29,7 +31,7 @@ const svgEndpoint = async (req: NextRequest) => {
2931
}
3032
console.error(errorJSON)
3133

32-
return new Response(JSON.stringify(errorJSON), {
34+
return new NextResponse(JSON.stringify(errorJSON), {
3335
status: 400,
3436
headers: {
3537
'content-type': 'application/json',
@@ -38,9 +40,3 @@ const svgEndpoint = async (req: NextRequest) => {
3840
})
3941
}
4042
}
41-
42-
export const config = {
43-
runtime: 'edge',
44-
}
45-
46-
export default svgEndpoint
File renamed without changes.

public/favicon.ico app/favicon.ico

File renamed without changes.

0 commit comments

Comments
 (0)