From a0de856b6bcce3d1bc138e44c0c1b48d3142bd97 Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 11 Jun 2026 17:36:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E9=80=9A=E7=9F=A5=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E3=82=92=E3=83=90=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E4=BB=98=E3=81=8D=20envelope=20=E5=8C=96?= =?UTF-8?q?=E3=81=97=E4=B8=8D=E6=AD=A3=E3=83=87=E3=83=BC=E3=82=BF=E3=82=92?= =?UTF-8?q?=E7=A0=B4=E6=A3=84=20(#407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通知カラムの localStorage キャッシュは読込時に無検証だったため、 旧バージョンが保存した形式不整合データが流入し、種別タブの中身が 空になる・カラムが描画不能になる原因と疑われる (#407)。 - キャッシュを { _v, items } envelope で保存し、version 不一致・ 必須フィールド欠落・reactions/users 非配列は全破棄して再フェッチに委ねる - 旧 plain-array 形式は invalid 扱いで自動破棄 (マイグレーションなし) - loadNotificationCache / saveNotificationCache を utils に切り出しテスト追加 Co-Authored-By: Claude Opus 4.6 --- .../deck/DeckNotificationColumn.vue | 15 +-- src/utils/notificationCache.ts | 68 +++++++++++ tests/utils/notificationCache.test.ts | 115 ++++++++++++++++++ 3 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 src/utils/notificationCache.ts create mode 100644 tests/utils/notificationCache.test.ts diff --git a/src/components/deck/DeckNotificationColumn.vue b/src/components/deck/DeckNotificationColumn.vue index abd4631d..55db5174 100644 --- a/src/components/deck/DeckNotificationColumn.vue +++ b/src/components/deck/DeckNotificationColumn.vue @@ -46,7 +46,10 @@ import { ACHIEVEMENT_LABELS } from '@/utils/achievementLabels' import { AppError, AUTH_ERROR_MESSAGE } from '@/utils/errors' import { formatTime } from '@/utils/formatTime' import { proxyUrl } from '@/utils/imageProxy' -import { getStorageJson, STORAGE_KEYS, setStorageJson } from '@/utils/storage' +import { + loadNotificationCache, + saveNotificationCache, +} from '@/utils/notificationCache' import { commands, unwrap } from '@/utils/tauriInvoke' import { char2twemojiUrl } from '@/utils/twemoji' import type { ColumnTabDef } from './ColumnTabs.vue' @@ -278,11 +281,11 @@ function flushCache() { clearTimeout(saveCacheTimer) saveCacheTimer = null } - setStorageJson(getCacheKey(), notifications.value) + saveNotificationCache(cacheAccountKey(), notifications.value) } function loadCache(): NormalizedNotification[] { - return getStorageJson(getCacheKey(), []) + return loadNotificationCache(cacheAccountKey()) } const NOTIFICATION_FILTERS = [ @@ -521,10 +524,8 @@ function notificationLabel(type: string): string { return NOTIFICATION_LABELS[baseType(type)] || type } -function getCacheKey() { - return STORAGE_KEYS.notificationCache( - props.column.accountId ?? 'cross-account', - ) +function cacheAccountKey() { + return props.column.accountId ?? 'cross-account' } // When account loses token (logout with keep-data), switch to cache display diff --git a/src/utils/notificationCache.ts b/src/utils/notificationCache.ts new file mode 100644 index 00000000..e37b541d --- /dev/null +++ b/src/utils/notificationCache.ts @@ -0,0 +1,68 @@ +import type { NormalizedNotification } from '@/adapters/types' +import { + getStorageJson, + removeStorage, + STORAGE_KEYS, + setStorageJson, +} from './storage' + +// 形式変更時にバンプすると旧 entry は破棄され、通常フェッチで再構築される (#407) +const NOTIFICATION_CACHE_VERSION = 1 + +interface CacheEnvelope { + _v: number + items: NormalizedNotification[] +} + +function isValidNotification(v: unknown): v is NormalizedNotification { + if (typeof v !== 'object' || v === null) return false + const n = v as Record + if ( + typeof n.id !== 'string' || + typeof n._accountId !== 'string' || + typeof n._serverHost !== 'string' || + typeof n.createdAt !== 'string' || + typeof n.type !== 'string' + ) { + return false + } + // grouped 通知の表示パスは配列前提で .filter する (visibleReactions 等) + if (n.reactions !== undefined && !Array.isArray(n.reactions)) return false + if (n.users !== undefined && !Array.isArray(n.users)) return false + return true +} + +/** + * 通知キャッシュを読み込む。バージョン不一致・形式不整合は全破棄して + * 空配列を返し、通常フェッチに委ねる (#407)。 + */ +export function loadNotificationCache( + accountKey: string, +): NormalizedNotification[] { + const key = STORAGE_KEYS.notificationCache(accountKey) + const raw = getStorageJson(key, null) + if (raw === null) return [] + const envelope = raw as Partial + if ( + typeof raw === 'object' && + !Array.isArray(raw) && + envelope._v === NOTIFICATION_CACHE_VERSION && + Array.isArray(envelope.items) && + envelope.items.every(isValidNotification) + ) { + return envelope.items + } + removeStorage(key) + return [] +} + +/** 通知キャッシュをバージョン付き envelope で保存する。 */ +export function saveNotificationCache( + accountKey: string, + items: NormalizedNotification[], +): void { + setStorageJson(STORAGE_KEYS.notificationCache(accountKey), { + _v: NOTIFICATION_CACHE_VERSION, + items, + } satisfies CacheEnvelope) +} diff --git a/tests/utils/notificationCache.test.ts b/tests/utils/notificationCache.test.ts new file mode 100644 index 00000000..f875d5a3 --- /dev/null +++ b/tests/utils/notificationCache.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest' +import type { NormalizedNotification } from '@/adapters/types' +import { + loadNotificationCache, + saveNotificationCache, +} from '@/utils/notificationCache' +import { STORAGE_KEYS } from '@/utils/storage' + +const ACCOUNT_KEY = 'test-account' +const KEY = STORAGE_KEYS.notificationCache(ACCOUNT_KEY) + +function makeNotification( + overrides: Partial = {}, +): NormalizedNotification { + return { + id: 'n1', + _accountId: 'test-account', + _serverHost: 'misskey.example.com', + createdAt: '2026-06-11T00:00:00.000Z', + type: 'reaction', + ...overrides, + } +} + +describe('notificationCache', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('save した通知一覧を load すると同じ配列が返る (optional フィールドも保持)', () => { + const items = [ + makeNotification(), + makeNotification({ + id: 'n2', + type: 'reaction:grouped', + reactions: [], + reaction: '👍', + }), + ] + saveNotificationCache(ACCOUNT_KEY, items) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual(items) + }) + + it('旧形式の plain array は破棄され、キーも削除される', () => { + localStorage.setItem(KEY, JSON.stringify([makeNotification()])) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('version が不一致の envelope は破棄される', () => { + localStorage.setItem( + KEY, + JSON.stringify({ _v: 999, items: [makeNotification()] }), + ) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('必須フィールド欠落 item が 1 件でも混入していると全破棄される', () => { + localStorage.setItem( + KEY, + JSON.stringify({ _v: 1, items: [makeNotification(), { id: 'x' }] }), + ) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('必須フィールドが string 以外の item が混入していると全破棄される', () => { + localStorage.setItem( + KEY, + JSON.stringify({ + _v: 1, + items: [makeNotification({ createdAt: 123 as unknown as string })], + }), + ) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + }) + + it('reactions が配列でない item が混入していると全破棄される', () => { + localStorage.setItem( + KEY, + JSON.stringify({ + _v: 1, + items: [makeNotification({ reactions: {} as unknown as [] })], + }), + ) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + }) + + it('users が配列でない item が混入していると全破棄される', () => { + localStorage.setItem( + KEY, + JSON.stringify({ + _v: 1, + items: [makeNotification({ users: 'broken' as unknown as [] })], + }), + ) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + }) + + it('JSON として壊れたデータは空配列にフォールバックする', () => { + localStorage.setItem(KEY, '{broken') + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + }) + + it('キーが存在しないとき load は空配列を返す', () => { + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + }) + + it('item が null のとき全破棄される', () => { + localStorage.setItem(KEY, JSON.stringify({ _v: 1, items: [null] })) + expect(loadNotificationCache(ACCOUNT_KEY)).toEqual([]) + }) +}) From 0fe9e278bdbd969c42ee5db45f27fbe010a0663f Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 11 Jun 2026 18:36:48 +0900 Subject: [PATCH 2/3] chore: bump version to 1.1.4 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e3b9fcfe..2cf725f5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "notedeck", "description": "Misskey IDE — integrated deck environment for browsing, posting, searching, and connecting", "private": true, - "version": "1.1.3", + "version": "1.1.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 15edab7a..0e0da89d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2770,7 +2770,7 @@ dependencies = [ [[package]] name = "notedeck" -version = "1.1.3" +version = "1.1.4" dependencies = [ "async-trait", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 656a5a28..935d17e6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notedeck" -version = "1.1.3" +version = "1.1.4" description = "Misskey IDE — integrated deck environment for browsing, posting, searching, and connecting" edition = "2021" [dependencies] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1ef36d57..aec00d83 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "NoteDeck", - "version": "1.1.3", + "version": "1.1.4", "identifier": "com.notedeck.desktop", "build": { "frontendDist": "../dist", From b3f11ef5db2d57e590b8b5cc1259abfa40a7779a Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 11 Jun 2026 18:37:18 +0900 Subject: [PATCH 3/3] chore: regenerate openapi.json for 1.1.4 Co-Authored-By: Claude Opus 4.6 --- src-tauri/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/openapi.json b/src-tauri/openapi.json index 4a41acd2..fc67ff2c 100644 --- a/src-tauri/openapi.json +++ b/src-tauri/openapi.json @@ -6,7 +6,7 @@ "license": { "name": "MIT" }, - "version": "1.1.3" + "version": "1.1.4" }, "paths": { "/api": {