See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix dark mode not persisting across cached page navigations

The inline theme script in layout.edge only acted on first visit (no
cookie yet) and was a no-op when the cookie already existed. Pages
served from the browser cache (Cache-Control: public, max-age=60)
kept their stale server-rendered class, so toggling dark mode and
navigating back to a previously-visited tab lost the setting.

Now the script always reads the cookie and sets/removes the dark class
to match, running in <head> before paint so even cached responses are
corrected instantly.

Adds Playwright-based browser E2E tests (via @japa/browser-client)
that reproduce the bug: toggle dark on one profile tab, navigate to
another, navigate back, and assert the class survives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+218 -2
+1
package.json
··· 64 64 "execa": "^9.6.1", 65 65 "hot-hook": "^1.0.0", 66 66 "pino-pretty": "^13.1.3", 67 + "playwright": "^1.59.1", 67 68 "prettier": "^3.8.1", 68 69 "typescript": "~6.0.2", 69 70 "vite": "^7.3.1",
+3
pnpm-lock.yaml
··· 114 114 pino-pretty: 115 115 specifier: ^13.1.3 116 116 version: 13.1.3 117 + playwright: 118 + specifier: ^1.59.1 119 + version: 1.59.1 117 120 prettier: 118 121 specifier: ^3.8.1 119 122 version: 3.8.2
+1 -1
resources/views/components/layout.edge
··· 5 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 6 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>" /> 7 7 <script nonce="{{ cspNonce }}"> 8 - (function(){if(document.cookie.indexOf('theme=')!==-1)return;var d=window.matchMedia('(prefers-color-scheme: dark)').matches;var v=d?'dark':'light';document.cookie='theme='+v+';path=/;max-age=31536000;SameSite=Lax';if(d)document.documentElement.classList.add('dark')})() 8 + (function(){var m=document.cookie.match(/(?:^|;\s*)theme=(dark|light)/);if(m){if(m[1]==='dark')document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark');return}var d=window.matchMedia('(prefers-color-scheme: dark)').matches;var v=d?'dark':'light';document.cookie='theme='+v+';path=/;max-age=31536000;SameSite=Lax';if(d)document.documentElement.classList.add('dark')})() 9 9 </script> 10 10 @vite(['resources/css/app.css', 'resources/js/app.js'], { nonce: cspNonce }) 11 11 <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.2/src/fill/style.css" />
+5 -1
tests/bootstrap.ts
··· 1 1 import { assert } from '@japa/assert' 2 2 import { apiClient } from '@japa/api-client' 3 + import { browserClient } from '@japa/browser-client' 3 4 import app from '@adonisjs/core/services/app' 4 5 import type { Config } from '@japa/runner/types' 5 6 import { pluginAdonisJS } from '@japa/plugin-adonisjs' ··· 19 20 apiClient(), 20 21 pluginAdonisJS(app), 21 22 dbAssertions(app), 23 + browserClient({ 24 + runInSuites: ['browser'], 25 + }), 22 26 ] 23 27 24 28 /** ··· 38 42 * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks 39 43 */ 40 44 export const configureSuite: Config['configureSuite'] = (suite) => { 41 - if (['functional', 'e2e'].includes(suite.name)) { 45 + if (['functional', 'e2e', 'browser'].includes(suite.name)) { 42 46 return suite.setup(() => testUtils.httpServer().start()) 43 47 } 44 48 }
+208
tests/browser/dark_mode.spec.ts
··· 1 + import { test } from '@japa/runner' 2 + import { createClient } from '@clickhouse/client' 3 + import { readdir, readFile } from 'node:fs/promises' 4 + import { randomBytes } from 'node:crypto' 5 + import { fileURLToPath } from 'node:url' 6 + import { join } from 'node:path' 7 + import testUtils from '@adonisjs/core/services/test_utils' 8 + import { ClickHouseStore } from '#lib/clickhouse/index' 9 + import { AtprotoClient } from '#lib/atproto/index' 10 + import User from '#models/user' 11 + 12 + // --------------------------------------------------------------------------- 13 + // ClickHouse helpers (same pattern as profile_controller.spec.ts) 14 + // --------------------------------------------------------------------------- 15 + 16 + const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123' 17 + const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER ?? 'default' 18 + const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD ?? '' 19 + 20 + const MIGRATIONS_DIR = join( 21 + fileURLToPath(new URL('.', import.meta.url)), 22 + '../../database/clickhouse' 23 + ) 24 + 25 + async function isClickHouseAvailable(): Promise<boolean> { 26 + try { 27 + const client = createClient({ 28 + url: CLICKHOUSE_URL, 29 + username: CLICKHOUSE_USER, 30 + password: CLICKHOUSE_PASSWORD, 31 + }) 32 + const result = await client.ping() 33 + await client.close() 34 + return result.success 35 + } catch { 36 + return false 37 + } 38 + } 39 + 40 + async function applyMigrations(dbName: string): Promise<void> { 41 + const adminRoot = createClient({ 42 + url: CLICKHOUSE_URL, 43 + username: CLICKHOUSE_USER, 44 + password: CLICKHOUSE_PASSWORD, 45 + }) 46 + await adminRoot.command({ query: `CREATE DATABASE IF NOT EXISTS \`${dbName}\`` }) 47 + await adminRoot.close() 48 + 49 + const adminDb = createClient({ 50 + url: CLICKHOUSE_URL, 51 + username: CLICKHOUSE_USER, 52 + password: CLICKHOUSE_PASSWORD, 53 + database: dbName, 54 + }) 55 + const entries = await readdir(MIGRATIONS_DIR) 56 + const migrationFiles = entries.filter((f) => f.endsWith('.sql')).sort() 57 + for (const filename of migrationFiles) { 58 + const sql = await readFile(join(MIGRATIONS_DIR, filename), 'utf8') 59 + await adminDb.command({ query: sql }) 60 + } 61 + await adminDb.close() 62 + } 63 + 64 + async function dropDatabase(dbName: string): Promise<void> { 65 + const admin = createClient({ 66 + url: CLICKHOUSE_URL, 67 + username: CLICKHOUSE_USER, 68 + password: CLICKHOUSE_PASSWORD, 69 + }) 70 + await admin.command({ query: `DROP DATABASE IF EXISTS \`${dbName}\`` }) 71 + await admin.close() 72 + } 73 + 74 + // --------------------------------------------------------------------------- 75 + // Fake AtprotoClient (avoids network calls to Bluesky) 76 + // --------------------------------------------------------------------------- 77 + 78 + function makeFakeAtprotoClient(): AtprotoClient { 79 + return { 80 + async getProfile(_did: string) { 81 + return { displayName: 'Test User', avatarUrl: null, postsCount: 3 } 82 + }, 83 + async resolveHandle(_handle: string) { 84 + return 'did:plc:e2etest001' 85 + }, 86 + } as unknown as AtprotoClient 87 + } 88 + 89 + // --------------------------------------------------------------------------- 90 + // Seed data 91 + // --------------------------------------------------------------------------- 92 + 93 + const TEST_DID = 'did:plc:e2etest001' 94 + const TEST_HANDLE = 'testuser.bsky.social' 95 + 96 + function seedPosts() { 97 + return [ 98 + { 99 + postUri: `at://${TEST_DID}/app.bsky.feed.post/post1`, 100 + postAuthorDid: TEST_DID, 101 + postText: 'This is my most liked post!', 102 + postCreatedAt: new Date('2024-06-01T12:00:00Z'), 103 + snapshotLikes: 100, 104 + snapshotReposts: 20, 105 + snapshotQuotes: 0, 106 + snapshotTakenAt: new Date('2024-06-01T12:00:00Z'), 107 + embed: null, 108 + facets: [], 109 + replyParentUri: null, 110 + replyParentAuthorHandle: null, 111 + }, 112 + { 113 + postUri: `at://${TEST_DID}/app.bsky.feed.post/post2`, 114 + postAuthorDid: TEST_DID, 115 + postText: 'Another great post', 116 + postCreatedAt: new Date('2024-06-02T12:00:00Z'), 117 + snapshotLikes: 50, 118 + snapshotReposts: 80, 119 + snapshotQuotes: 0, 120 + snapshotTakenAt: new Date('2024-06-02T12:00:00Z'), 121 + embed: null, 122 + facets: [], 123 + replyParentUri: null, 124 + replyParentAuthorHandle: null, 125 + }, 126 + ] 127 + } 128 + 129 + // --------------------------------------------------------------------------- 130 + // Tests 131 + // --------------------------------------------------------------------------- 132 + 133 + test.group('Dark mode persistence across profile tabs', (group) => { 134 + let testDb: string 135 + let store: ClickHouseStore 136 + 137 + group.setup(async () => { 138 + const available = await isClickHouseAvailable() 139 + if (!available) return 140 + 141 + testDb = `test_browser_dark_${randomBytes(4).toString('hex')}` 142 + await applyMigrations(testDb) 143 + 144 + store = new ClickHouseStore({ 145 + url: CLICKHOUSE_URL, 146 + database: testDb, 147 + username: CLICKHOUSE_USER, 148 + password: CLICKHOUSE_PASSWORD, 149 + }) 150 + }) 151 + 152 + group.teardown(async () => { 153 + if (!testDb) return 154 + await store?.close() 155 + await dropDatabase(testDb) 156 + }) 157 + 158 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 159 + 160 + group.each.teardown(async () => { 161 + if (!store) return 162 + await store.client.command({ query: 'TRUNCATE TABLE post_snapshots' }) 163 + await store.client.command({ query: 'TRUNCATE TABLE engagement_events' }) 164 + }) 165 + 166 + test('dark mode survives navigating between profile tabs', async ({ 167 + visit, 168 + assert, 169 + swap, 170 + }) => { 171 + // Wire up test dependencies 172 + swap(ClickHouseStore, store) 173 + swap(AtprotoClient, makeFakeAtprotoClient()) 174 + 175 + // Seed data 176 + await User.create({ 177 + did: TEST_DID, 178 + handle: TEST_HANDLE, 179 + displayName: 'Test User', 180 + firstSeenAt: Date.now(), 181 + backfilledAt: Date.now(), 182 + }) 183 + await store.insertPostSnapshots(seedPosts()) 184 + 185 + // 1. Visit the likes tab (light mode) 186 + const page = await visit(`/profile/${TEST_HANDLE}/likes`) 187 + let hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark')) 188 + assert.isFalse(hasDark, 'expected light mode initially') 189 + 190 + // 2. Toggle dark mode 191 + await page.click('button[aria-label="Toggle dark mode"]') 192 + hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark')) 193 + assert.isTrue(hasDark, 'expected dark mode after toggle') 194 + 195 + // 3. Navigate to reposts tab 196 + await page.click(`a[href="/profile/${TEST_HANDLE}/reposts"]`) 197 + await page.waitForURL(`**/profile/${TEST_HANDLE}/reposts`) 198 + hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark')) 199 + assert.isTrue(hasDark, 'expected dark mode to persist on reposts tab') 200 + 201 + // 4. Navigate back to likes tab — this is the bug: 202 + // The browser may serve a cached response from step 1 (light mode) 203 + await page.click(`a[href="/profile/${TEST_HANDLE}/likes"]`) 204 + await page.waitForURL(`**/profile/${TEST_HANDLE}/likes`) 205 + hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark')) 206 + assert.isTrue(hasDark, 'expected dark mode to persist when returning to likes tab') 207 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 208 + })