Skip to content

Commit

Permalink
♻️ Migration to app router, Upgrade to Next 15/React 19 (#450)
Browse files Browse the repository at this point in the history
* βœ… 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]>
  • Loading branch information
3 people authored Dec 19, 2024
1 parent 811ccd2 commit d73c1be
Show file tree
Hide file tree
Showing 80 changed files with 1,230 additions and 778 deletions.
7 changes: 7 additions & 0 deletions .changeset/sour-beds-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"socialify": minor
---

Full migration from page router to app router.

Upgraded to nextjs15/react19 via official codemod and applied type fixes.
63 changes: 63 additions & 0 deletions .playwright/imageAPIEndpoints.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { type Page, expect, test } from '@playwright/test'

const customTimeout = { timeout: 30000 }

const defaultImageURL: string =
'/wei/socialify/image?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'
const svgImageURL: string =
'/wei/socialify/svg?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'
const pngImageURL: string =
'/wei/socialify/png?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'
// Backward compatibility route.
const jpgImageURL: string =
'/wei/socialify/jpg?description=1&font=Raleway&language=1&name=1&owner=1&pattern=Diagonal%20Stripes&theme=Dark'

test.describe('Socialify image api', () => {
test('respond consistently for default endpoint', async ({
page,
}: { page: Page }): Promise<void> => {
await page.goto(defaultImageURL, customTimeout)

// Wait for the page to load/hydrate completely.
await page.waitForLoadState('networkidle', customTimeout)

const image = await page.screenshot()
expect(image).toMatchSnapshot()
})

test('respond consistently for svg endpoint', async ({
page,
}: { page: Page }): Promise<void> => {
await page.goto(svgImageURL, customTimeout)

// Wait for the page to load/hydrate completely.
await page.waitForLoadState('networkidle', customTimeout)

const image = await page.screenshot()
expect(image).toMatchSnapshot()
})

test('respond consistently for png endpoint', async ({
page,
}: { page: Page }): Promise<void> => {
await page.goto(pngImageURL, customTimeout)

// Wait for the page to load/hydrate completely.
await page.waitForLoadState('networkidle', customTimeout)

const image = await page.screenshot()
expect(image).toMatchSnapshot()
})

test('respond consistently for backwards-compatible jpg endpoint', async ({
page,
}: { page: Page }): Promise<void> => {
await page.goto(jpgImageURL, customTimeout)

// Wait for the page to load/hydrate completely.
await page.waitForLoadState('networkidle', customTimeout)

const image = await page.screenshot()
expect(image).toMatchSnapshot()
})
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion .playwright/simpleUserStory.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { type Page, expect, test } from '@playwright/test'

const customTimeout = { timeout: 30000 }
const componentUpdateTimeout = 1000

// Testing constants.
const repo: string = 'wei/socialify'
const expectedConfigURL: string =
'/wei/socialify?language=1&owner=1&name=1&stargazers=1&theme=Light'
const expectedImageURLRegExp: RegExp =
/\/wei\/socialify\/image\?description=1&language=1&name=1&owner=1&theme=Light$/
/\/wei\/socialify\/image\?description=1&font=Source\+Code\+Pro&language=1&name=1&owner=1&theme=Light$/

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

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

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

// Obtain the consistent preview image URL.
await page.click('button:has-text("URL")')
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

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

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
9 changes: 9 additions & 0 deletions app/[_owner]/[_name]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client'

import { JSX } from 'react'

import MainRenderer from '@/src/components/mainRenderer'

export default function PreviewConfigPage(): JSX.Element {
return <MainRenderer />
}
7 changes: 3 additions & 4 deletions pages/api/font.ts β†’ app/api/font/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import type { NextRequest } from 'next/server'

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

export const config = {
runtime: 'edge',
}
export const runtime = 'edge'

const detector = new FontDetector()

Expand Down Expand Up @@ -37,7 +35,8 @@ function encodeFontInfoAsArrayBuffer(code: string, fontData: ArrayBuffer) {
return buffer
}

export default async function loadGoogleFont(req: NextRequest) {
// export default async function loadGoogleFont(req: NextRequest) {
export async function GET(req: NextRequest) {
if (req.nextUrl.pathname !== '/api/font') return

const { searchParams } = new URL(req.url)
Expand Down
24 changes: 7 additions & 17 deletions pages/api/graphql.ts β†’ app/api/graphql/route.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import 'server-only'
import type { NextRequest } from 'next/server'

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

const graphQLEndpoint = async (req: NextRequest) => {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', {
status: 405,
headers: {
'cache-control': 'max-age=0, public',
},
})
}
export const runtime = 'edge'

const response = await fetch(API_ENDPOINT, {
export async function POST(req: NextRequest) {
const response = await fetch(GITHUB_GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `bearer ${process.env.GITHUB_TOKEN}`,
'content-type': 'application/json',
},
body: req.body,
// @ts-expect-error: 'duplex' is not part of the RequestInit type but required by GraphQL.
duplex: 'half',
})

if (!response.ok) {
Expand All @@ -41,9 +37,3 @@ const graphQLEndpoint = async (req: NextRequest) => {
},
})
}

export const config = {
runtime: 'edge',
}

export default graphQLEndpoint
15 changes: 15 additions & 0 deletions app/api/image/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { isBot } from 'next/dist/server/web/spec-extension/user-agent'
import type { NextRequest, NextResponse } from 'next/server'

import { GET as GETPng } from '@/app/api/png/route'
import { GET as GETSvg } from '@/app/api/svg/route'

export const runtime = 'edge'

export async function GET(req: NextRequest): Promise<NextResponse> {
if (isBot(req.headers.get('user-agent') ?? '')) {
return GETPng(req)
} else {
return GETSvg(req)
}
}
16 changes: 6 additions & 10 deletions pages/api/png.ts β†’ app/api/png/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { NextRequest } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'

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

const pngEndpoint = async (req: NextRequest) => {
export const runtime = 'edge'

export async function GET(req: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(req.url)
const query = Object.fromEntries(searchParams) as QueryType

try {
return new Response(await renderCardPNG(query), {
return new NextResponse(await renderCardPNG(query), {
status: 200,
headers: {
'content-type': 'image/png',
Expand All @@ -27,7 +29,7 @@ const pngEndpoint = async (req: NextRequest) => {
}
console.error(errorJSON)

return new Response(JSON.stringify(errorJSON), {
return new NextResponse(JSON.stringify(errorJSON), {
status: 400,
headers: {
'content-type': 'application/json',
Expand All @@ -36,9 +38,3 @@ const pngEndpoint = async (req: NextRequest) => {
})
}
}

export const config = {
runtime: 'edge',
}

export default pngEndpoint
18 changes: 7 additions & 11 deletions pages/api/stats.svg.ts β†’ app/api/stats.svg/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { badgen } from 'badgen'
import type { NextRequest } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'

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

const statsSvgEndpoint = async (req: NextRequest) => {
export const runtime = 'edge'

export async function GET(req: NextRequest): Promise<NextResponse> {
let totalCount = 0

try {
const apiResponse = await (await statsEndpoint(req)).json()
const apiResponse = await (await GETStats(req)).json()
if (apiResponse.total_count) {
totalCount = apiResponse.total_count
}
Expand All @@ -33,7 +35,7 @@ const statsSvgEndpoint = async (req: NextRequest) => {
style: 'flat',
})

return new Response(svg, {
return new NextResponse(svg, {
status: 200,
headers: {
'content-type': 'image/svg+xml',
Expand All @@ -43,9 +45,3 @@ const statsSvgEndpoint = async (req: NextRequest) => {
},
})
}

export const config = {
runtime: 'edge',
}

export default statsSvgEndpoint
24 changes: 11 additions & 13 deletions pages/api/stats.ts β†’ app/api/stats/route.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import type { NextRequest } from 'next/server'
import 'server-only'
import { GITHUB_API_ENDPOINT } from '@/common/constants'
import { type NextRequest, NextResponse } from 'next/server'

const statsEndpoint = async (_req: NextRequest) => {
export const runtime = 'edge'

export async function GET(_req: NextRequest): Promise<NextResponse> {
const response = await fetch(
`https://api.github.com/search/code?per_page=1&q=${encodeURIComponent(
`${GITHUB_API_ENDPOINT}/search/code?per_page=1&q=${encodeURIComponent(
'socialify.git.ci'
)}`,
{
method: 'GET',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `bearer ${process.env.GITHUB_TOKEN}`,
accept: 'application/vnd.github.v3+json',
authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'content-type': 'application/json',
},
}
)

if (!response.ok) {
return new Response(await response.text(), {
return new NextResponse(await response.text(), {
status: response.status,
headers: {
'cache-control': 'public, max-age=0',
Expand All @@ -25,7 +29,7 @@ const statsEndpoint = async (_req: NextRequest) => {
}

const json = await response.json()
return new Response(JSON.stringify({ total_count: json.total_count }), {
return new NextResponse(JSON.stringify({ total_count: json.total_count }), {
status: 200,
headers: {
'content-type': 'application/json',
Expand All @@ -35,9 +39,3 @@ const statsEndpoint = async (_req: NextRequest) => {
},
})
}

export const config = {
runtime: 'edge',
}

export default statsEndpoint
16 changes: 6 additions & 10 deletions pages/api/svg.ts β†’ app/api/svg/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { NextRequest } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'

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

const svgEndpoint = async (req: NextRequest) => {
export const runtime = 'edge'

export async function GET(req: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(req.url)
const query = Object.fromEntries(searchParams) as QueryType

try {
const svg = await renderCardSVG(query)

return new Response(svg, {
return new NextResponse(svg, {
status: 200,
headers: {
'content-type': 'image/svg+xml',
Expand All @@ -29,7 +31,7 @@ const svgEndpoint = async (req: NextRequest) => {
}
console.error(errorJSON)

return new Response(JSON.stringify(errorJSON), {
return new NextResponse(JSON.stringify(errorJSON), {
status: 400,
headers: {
'content-type': 'application/json',
Expand All @@ -38,9 +40,3 @@ const svgEndpoint = async (req: NextRequest) => {
})
}
}

export const config = {
runtime: 'edge',
}

export default svgEndpoint
File renamed without changes
File renamed without changes.
Loading

0 comments on commit d73c1be

Please sign in to comment.