🔄 2026-06-09 React Native Pivot
背景:user 2026-06-09 決定 Pickup 全面 pivot 到 React Native 棧,覆蓋之前 Capacitor + Phaser + Vite + Web build 路線。下列項目跟 web/DOM/Phaser stack 強綁,在 RN 棧內沒對應實作 — 從 cockpit 主頁搬到此 archive,保留決策歷史。
仍適用的概念(留在 cockpit 但加 ⚠️):Lesson idx 持久化 / Zustand audioSlice 概念 / TS strict mode / Achievements 邏輯 / useSyncExternalStore — 這些只是實作換棧不是 obsolete。
復用可能:若日後 RN pivot 反轉(回 web),可從此 archive cherry-pick 回 cockpit 主頁。
仍適用的概念(留在 cockpit 但加 ⚠️):Lesson idx 持久化 / Zustand audioSlice 概念 / TS strict mode / Achievements 邏輯 / useSyncExternalStore — 這些只是實作換棧不是 obsolete。
復用可能:若日後 RN pivot 反轉(回 web),可從此 archive cherry-pick 回 cockpit 主頁。
Decision Board #1 — unlock listeners 加
{once:true}來源:1236 code-health cron ·
src/audio/tts.ts:534-537 · S · 5 min · ROI ⭐⭐⭐為何 archived:`touchstart/click/pointerdown` event listeners + capture phase 是 Web DOM 概念。RN 用 expo-av 沒有這套 unlock flow — iOS WKWebView 限制不存在,RN AVAudioSession 自動 handle。
看原 prompt
請拉最新 master, 接 P0:
File: src/audio/tts.ts:534-537
Issue: 3 個 module-level unlock listener (touchstart/click/pointerdown) 用匿名 wrapper, 無 { once: true }, ref 丟失無法 removeEventListener — 永久 capture-phase event 浪費 CPU + iOS 久了 event handler 累積
修法: 加 { once: true, capture: true } 到 3 個 addEventListener, listener 首次 fire 後自動 remove
Decision Board #3 — audioBufferCache LRU 上限 80 entries
來源:1236 code-health cron #2 ·
src/audio/tts.ts:168 · M · 1 hr · ROI ⭐⭐為何 archived:`audioBufferCache: Map<string, AudioBuffer>` 是 Web Audio API 的 AudioBuffer 概念,iOS 16 Safari 300MB budget 問題也是 WebView 才會碰到。RN 用 expo-av 的 Sound 物件 + 系統 audio session,記憶體模型完全不同,LRU 改用 expo-av cache option 設定。
看原 prompt
File: src/audio/tts.ts:168 (audioBufferCache: Map) Issue: 無 eviction policy, 8 章 200 MP3 累積 ~20MB RAM 永不釋放. iOS 16 app budget 300MB 修法 (M 1hr): 加 LRU 上限 80 entries - track lastUsed timestamp per entry - set 時若 size > 80, iterate Map 找 oldest lastUsed 刪掉
Decision Board #4 — R5
_showCompletionArticle 死碼來源:cron audit ·
src/scenes/LessonScene.ts:1373 · S · 5 min為何 archived:LessonScene 是 Phaser 3 Scene。RN 不能跑 Phaser,8 個 scene 全部要重寫成 RN component。連帶這個死碼也不存在了。
Decision Board #5 — Phaser/React dual-path analytics drift
來源:cron audit · LessonScene.ts · L · 大 refactor
為何 archived:RN pivot 直接消除這個 drift — Phaser 完全砍掉後沒有 dual-path。「architectural design 自然解決」級別的 deprecation。
ARCH-REC #4 — CSS slide-in + OptionBtn wobble + tap-to-skip
來源:ui-ux-cron 2026-06-08T2107 ·
src/style.css + renderers.tsx + LessonPage.tsx · S · 45 min為何 archived:CSS keyframes + className 切換是 DOM 概念。RN 用
Animated API 或 react-native-reanimated,動畫實作完全不同。pickup-wobble keyframe 那種 class toggle 在 RN 不存在 — 改用 Animated.spring() 或 reanimated 的 shared values。看原 prompt
Task 1 — style.css 加 pickup-slide-in:
@keyframes pickup-slide-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
Task 2 — OptionBtn className={state==='wrong'?'pickup-wobble':''}
Task 3 — NarrationRenderer wrapper onClick={advanceOnce}
P0 — ClozeUI
:343 broad-subscribe → DOM rebuild × 8來源:code-health-cron 2026-06-09T0037 ·
src/ui/ClozeUI.ts:343 · XS · 10 min為何 archived:
ClozeUI 是 vanilla DOM class (this.syncFromState 重建 button DOM nodes)。RN 用 OptionBtn React component,re-render 是 React 自然優化,broad-subscribe 在 Zustand+React 是 selector 解決,完全不同問題。看原 prompt
File: src/ui/ClozeUI.ts:343
this.unsub = useRunStore.subscribe((state) => {
this.syncFromState(state.round);
});
修法: 加 prevRound 物件識別 guard