Skip to content

Commit 01805e5

Browse files
JoeyMckenzietnyleataylorotwell
authored
Fix Inertia SSR errors (#53)
* fix: check for window when running ssr * chore: remove ssr files * fix: add ziggy to inertia middleware for ssr * chore: remove bootstrap ssr * chore: add script for ssr * chore: update composer script * Adding solution for Dark Mode flicker with SSR * Adding condition for system appearance and detecting via client-side immediately * feat: add inline style to prevent flash during CSR * formatting * package update --------- Co-authored-by: Tony Lea <[email protected]> Co-authored-by: Taylor Otwell <[email protected]>
1 parent f106eeb commit 01805e5

18 files changed

+532
-393
lines changed
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\View;
8+
use Symfony\Component\HttpFoundation\Response;
9+
10+
class HandleAppearance
11+
{
12+
/**
13+
* Handle an incoming request.
14+
*
15+
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
16+
*/
17+
public function handle(Request $request, Closure $next): Response
18+
{
19+
View::share('appearance', $request->cookie('appearance') ?? 'system');
20+
21+
return $next($request);
22+
}
23+
}

app/Http/Middleware/HandleInertiaRequests.php

+5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Illuminate\Foundation\Inspiring;
66
use Illuminate\Http\Request;
77
use Inertia\Middleware;
8+
use Tighten\Ziggy\Ziggy;
89

910
class HandleInertiaRequests extends Middleware
1011
{
@@ -45,6 +46,10 @@ public function share(Request $request): array
4546
'auth' => [
4647
'user' => $request->user(),
4748
],
49+
'ziggy' => fn (): array => [
50+
...(new Ziggy)->toArray(),
51+
'location' => $request->url(),
52+
]
4853
];
4954
}
5055
}

bootstrap/app.php

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Middleware\HandleAppearance;
34
use App\Http\Middleware\HandleInertiaRequests;
45
use Illuminate\Foundation\Application;
56
use Illuminate\Foundation\Configuration\Exceptions;
@@ -13,7 +14,10 @@
1314
health: '/up',
1415
)
1516
->withMiddleware(function (Middleware $middleware) {
17+
$middleware->encryptCookies(except: ['appearance']);
18+
1619
$middleware->web(append: [
20+
HandleAppearance::class,
1721
HandleInertiaRequests::class,
1822
AddLinkHeadersForPreloadedAssets::class,
1923
]);

composer.json

+5
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
"dev": [
5656
"Composer\\Config::disableProcessTimeout",
5757
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
58+
],
59+
"dev:ssr": [
60+
"npm run build:ssr",
61+
"Composer\\Config::disableProcessTimeout",
62+
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr"
5863
]
5964
},
6065
"extra": {

package-lock.json

+371-353
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"devDependencies": {
1414
"@eslint/js": "^9.19.0",
15+
"@types/node": "^22.13.5",
1516
"eslint": "^9.17.0",
1617
"eslint-config-prettier": "^10.0.1",
1718
"eslint-plugin-react": "^7.37.3",

resources/js/app.tsx

-5
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,8 @@ import '../css/app.css';
33
import { createInertiaApp } from '@inertiajs/react';
44
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
55
import { createRoot } from 'react-dom/client';
6-
import { route as routeFn } from 'ziggy-js';
76
import { initializeTheme } from './hooks/use-appearance';
87

9-
declare global {
10-
const route: typeof routeFn;
11-
}
12-
138
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
149

1510
createInertiaApp({

resources/js/components/app-header.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
6363
<SheetHeader className="flex justify-start text-left">
6464
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
6565
</SheetHeader>
66-
<div className="p-4 flex h-full flex-1 flex-col space-y-4">
66+
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
6767
<div className="flex h-full flex-col justify-between text-sm">
6868
<div className="flex flex-col space-y-4">
6969
{mainNavItems.map((item) => (

resources/js/hooks/use-appearance.tsx

+31-4
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,36 @@ import { useCallback, useEffect, useState } from 'react';
22

33
export type Appearance = 'light' | 'dark' | 'system';
44

5-
const prefersDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
5+
const prefersDark = () => {
6+
if (typeof window === 'undefined') {
7+
return false;
8+
}
9+
10+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
11+
};
12+
13+
const setCookie = (name: string, value: string, days = 365) => {
14+
if (typeof document === 'undefined') {
15+
return;
16+
}
17+
18+
const maxAge = days * 24 * 60 * 60;
19+
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
20+
};
621

722
const applyTheme = (appearance: Appearance) => {
823
const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark());
924

1025
document.documentElement.classList.toggle('dark', isDark);
1126
};
1227

13-
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
28+
const mediaQuery = () => {
29+
if (typeof window === 'undefined') {
30+
return null;
31+
}
32+
33+
return window.matchMedia('(prefers-color-scheme: dark)');
34+
};
1435

1536
const handleSystemThemeChange = () => {
1637
const currentAppearance = localStorage.getItem('appearance') as Appearance;
@@ -23,23 +44,29 @@ export function initializeTheme() {
2344
applyTheme(savedAppearance);
2445

2546
// Add the event listener for system theme changes...
26-
mediaQuery.addEventListener('change', handleSystemThemeChange);
47+
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
2748
}
2849

2950
export function useAppearance() {
3051
const [appearance, setAppearance] = useState<Appearance>('system');
3152

3253
const updateAppearance = useCallback((mode: Appearance) => {
3354
setAppearance(mode);
55+
56+
// Store in localStorage for client-side persistence...
3457
localStorage.setItem('appearance', mode);
58+
59+
// Store in cookie for SSR...
60+
setCookie('appearance', mode);
61+
3562
applyTheme(mode);
3663
}, []);
3764

3865
useEffect(() => {
3966
const savedAppearance = localStorage.getItem('appearance') as Appearance | null;
4067
updateAppearance(savedAppearance || 'system');
4168

42-
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange);
69+
return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange);
4370
}, [updateAppearance]);
4471

4572
return { appearance, updateAppearance } as const;

resources/js/layouts/settings/layout.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ const sidebarNavItems: NavItem[] = [
2525
];
2626

2727
export default function SettingsLayout({ children }: PropsWithChildren) {
28+
// When server-side rendering, we only render the layout on the client...
29+
if (typeof window === 'undefined') {
30+
return null;
31+
}
32+
2833
const currentPath = window.location.pathname;
2934

3035
return (

resources/js/pages/auth/login.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,13 @@ export default function Login({ status, canResetPassword }: LoginProps) {
8080
</div>
8181

8282
<div className="flex items-center space-x-3">
83-
<Checkbox id="remember" name="remember" checked={data.remember} onClick={() => setData('remember', !data.remember)} tabIndex={3} />
83+
<Checkbox
84+
id="remember"
85+
name="remember"
86+
checked={data.remember}
87+
onClick={() => setData('remember', !data.remember)}
88+
tabIndex={3}
89+
/>
8490
<Label htmlFor="remember">Remember me</Label>
8591
</div>
8692

resources/js/ssr.jsx

-21
This file was deleted.

resources/js/ssr.tsx

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { createInertiaApp } from '@inertiajs/react';
2+
import createServer from '@inertiajs/react/server';
3+
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
4+
import ReactDOMServer from 'react-dom/server';
5+
import { type RouteName, route } from 'ziggy-js';
6+
7+
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
8+
9+
createServer((page) =>
10+
createInertiaApp({
11+
page,
12+
render: ReactDOMServer.renderToString,
13+
title: (title) => `${title} - ${appName}`,
14+
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
15+
setup: ({ App, props }) => {
16+
/* eslint-disable */
17+
// @ts-expect-error
18+
global.route<RouteName> = (name, params, absolute) =>
19+
route(name, params as any, absolute, {
20+
// @ts-expect-error
21+
...page.props.ziggy,
22+
// @ts-expect-error
23+
location: new URL(page.props.ziggy.location),
24+
});
25+
/* eslint-enable */
26+
27+
return <App {...props} />;
28+
},
29+
}),
30+
);

resources/js/types/global.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { route as routeFn } from 'ziggy-js';
2+
3+
declare global {
4+
const route: typeof routeFn;
5+
}

resources/js/types/index.ts resources/js/types/index.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LucideIcon } from 'lucide-react';
2+
import type { Config } from 'ziggy-js';
23

34
export interface Auth {
45
user: User;
@@ -25,6 +26,7 @@ export interface SharedData {
2526
name: string;
2627
quote: { message: string; author: string };
2728
auth: Auth;
29+
ziggy: Config & { location: string };
2830
[key: string]: unknown;
2931
}
3032

resources/views/app.blade.php

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
<!DOCTYPE html>
2-
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
2+
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
33
<head>
44
<meta charset="utf-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66

7+
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
8+
<script>
9+
(function() {
10+
const appearance = '{{ $appearance ?? "system" }}';
11+
12+
if (appearance === 'system') {
13+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
14+
15+
if (prefersDark) {
16+
document.documentElement.classList.add('dark');
17+
}
18+
}
19+
})();
20+
</script>
21+
22+
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
23+
<style>
24+
html {
25+
background-color: oklch(1 0 0);
26+
}
27+
28+
html.dark {
29+
background-color: oklch(0.145 0 0);
30+
}
31+
</style>
32+
733
<title inertia>{{ config('app.name', 'Laravel') }}</title>
834

935
<link rel="preconnect" href="https://fonts.bunny.net">

tsconfig.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -114,5 +114,9 @@
114114
},
115115
"jsx": "react-jsx"
116116
},
117-
"include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"]
117+
"include": [
118+
"resources/js/**/*.ts",
119+
"resources/js/**/*.d.ts",
120+
"resources/js/**/*.tsx",
121+
]
118122
}

vite.config.js vite.config.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1+
import tailwindcss from '@tailwindcss/vite';
12
import react from '@vitejs/plugin-react';
23
import laravel from 'laravel-vite-plugin';
3-
import {
4-
defineConfig
5-
} from 'vite';
6-
import tailwindcss from "@tailwindcss/vite";
4+
import { resolve } from 'node:path';
5+
import { defineConfig } from 'vite';
76

87
export default defineConfig({
98
plugins: [
109
laravel({
1110
input: ['resources/css/app.css', 'resources/js/app.tsx'],
12-
ssr: 'resources/js/ssr.jsx',
11+
ssr: 'resources/js/ssr.tsx',
1312
refresh: true,
1413
}),
1514
react(),
@@ -18,4 +17,9 @@ export default defineConfig({
1817
esbuild: {
1918
jsx: 'automatic',
2019
},
21-
});
20+
resolve: {
21+
alias: {
22+
'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'),
23+
},
24+
},
25+
});

0 commit comments

Comments
 (0)