Skip to content

Warm the full level cache in the background for offline play - #96

Merged
min0625 merged 1 commit into
mainfrom
warm-level-cache
Aug 3, 2026
Merged

min0625 merged 1 commit into
mainfrom
warm-level-cache

Conversation

@min0625

@min0625 min0625 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • Previously only the current + next level were prefetched offline; a player who kept going past that hit a load-failure screen on the third unplayed level.
  • sw.js now backfills all 500 level files into the SHELL cache in batches of 20 via a new warmLevels(), triggered by postMessage from main.js (on load and controllerchange, deduped behind a single shared promise), not activate — filling ~2MB there would queue fetch events and freeze the app.
  • Docs updated: CLAUDE.md architecture section, README, design doc §2 and the §17 acceptance checklist (new unchecked item for offline play past the first 2 levels).

Test plan

  • bun run check (lint + unit tests) passes
  • Manual: stay online until warmLevels() completes (DevTools → Application → Cache Storage shows 500 level entries) → airplane mode → play 3+ unplayed levels in a row

Previously only the current + next level were prefetched, so offline
players hit a load failure on the third unplayed level. sw.js now
backfills all 500 level files into the SHELL cache in batches of 20,
triggered by postMessage from main.js (not activate, which would
queue fetch events and freeze the app for ~2MB of downloads).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Warm all level JSON into the SW cache in background for offline play

✨ Enhancement 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Add service-worker background warming to cache all level JSON files in batches.
• Trigger warming from main.js via postMessage on load and controllerchange, deduped.
• Update docs and acceptance checklist for offline play beyond the first two levels.
Diagram

graph TD
  A["main.js (page)"] -->|"postMessage: warm-levels"| B["sw.js (service worker)"] --> C["warmLevels() batcher"] --> D[("Cache Storage (SHELL)")]
  C --> E["data/levels/index.json"] --> D
  C --> F["data/levels/*.json (x500)"] --> D
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a separate LEVELS cache (not SHELL)
  • ➕ Avoids deleting warmed levels when CACHE name changes for shell invalidation
  • ➕ Makes eviction/size management more explicit (levels vs app shell)
  • ➖ More moving parts (two caches) and more complexity in fetch routing
  • ➖ Requires decisions about when/how to invalidate level cache vs shell
2. Warm in activate (status quo PWA pattern)
  • ➕ Simple lifecycle trigger; no page-side messaging needed
  • ➕ Guarantees the warm-up starts immediately on new SW activation
  • ➖ As noted in the PR, long activate blocks fetch events and can freeze the app
  • ➖ Worse UX for the exact offline-first scenarios this change targets
3. On-demand only (keep current+next prefetch)
  • ➕ Lowest bandwidth usage
  • ➕ Minimal additional SW logic
  • ➖ Does not satisfy offline play past the first 2 levels
  • ➖ Players hit load-failure on deeper progression without connectivity

Recommendation: The chosen approach (page-triggered SW message + batched warm-up + deduped shared promise) is the best UX tradeoff for GitHub Pages/PWA constraints because it avoids blocking fetch during activation while still converging to full offline availability. Consider splitting levels into a separate cache only if cache invalidation/eviction becomes a recurring pain point (e.g., frequent CACHE bumps for non-level assets).

Files changed (5) +69 / -8

Enhancement (2) +65 / -5
main.jsTrigger SW warmLevels via postMessage on load and controllerchange +11/-1

Trigger SW warmLevels via postMessage on load and controllerchange

• Introduces requestLevelWarm() to post a warm-levels message to the active service worker controller. Calls it on window load (for returning visits) and on controllerchange (for first install / SW upgrades) alongside the existing current+next prefetch backfill.

src/main.js

sw.jsAdd warmLevels() batched backfill + message handler with run dedupe +54/-4

Add warmLevels() batched backfill + message handler with run dedupe

• Implements warmLevels() to read the level count from data/levels/index.json and cache all level JSON files in batches of 20, skipping existing entries and swallowing per-file failures. Adds a message listener that starts warming on "warm-levels", dedupes concurrent triggers behind a shared promise, and uses event.waitUntil without blocking activate.

sw.js

Documentation (3) +4 / -3
CLAUDE.mdDocument warmLevels design and multi-tab dedupe rationale +1/-1

Document warmLevels design and multi-tab dedupe rationale

• Expands the service worker architecture section to explain why level files are excluded from SHELL precache and how warmLevels backfills them safely. Documents message-based triggering, shared-promise deduping, batching behavior, and why warming must not run in activate.

CLAUDE.md

README.mdUpdate PWA/SW summary to mention full level cache warming +1/-1

Update PWA/SW summary to mention full level cache warming

• Adjusts the sw.js description to include background caching of all levels, aligning the README with the new offline behavior.

README.md

word-puzzle-game-design.mdDesign doc: formalize background level warming + add offline acceptance item +2/-1

Design doc: formalize background level warming + add offline acceptance item

• Updates the caching strategy narrative to describe warmLevels batching, failure tolerance, deduping, and message-based triggering (vs activate). Adds a new checklist item validating offline play for 3+ unplayed levels after warming completes.

docs/word-puzzle-game-design.md

@min0625
min0625 merged commit 6dcb241 into main Aug 3, 2026
1 check passed
@min0625
min0625 deleted the warm-level-cache branch August 3, 2026 08:37
@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 101 rules
✅ Skills: frontend-design

Grey Divider


Remediation recommended

1. requestLevelWarm bypasses bridge 📘 Rule violation ⌂ Architecture
Description
The new requestLevelWarm directly calls navigator.serviceWorker?.controller?.postMessage(...)
from src/main.js instead of routing platform/native API access through bridge.js. This violates
the platform abstraction requirement and makes future non-web platform swaps harder.
Code

src/main.js[R976-977]

+const requestLevelWarm = () =>
+	navigator.serviceWorker?.controller?.postMessage("warm-levels");
Evidence
PR Compliance ID 2064912 requires that platform/native APIs (including navigator) are only used
via bridge.js in src/ files. The added requestLevelWarm function calls
navigator.serviceWorker directly on the new lines shown.

Rule 2064912: Game code must call localStorage and native APIs only via bridge.js
src/main.js[976-977]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/main.js` introduces a new direct `navigator.serviceWorker` usage (`postMessage("warm-levels")`). Per the compliance rule, game/runtime code under `src/` must not call platform/native APIs directly and must route them via `bridge.js`.

## Issue Context
`src/main.js` already imports `bridge`, so the new service-worker warm trigger can be exposed as a `bridge` method (e.g., `bridge.requestLevelWarm()`), which encapsulates `navigator.serviceWorker` usage.

## Fix Focus Areas
- src/main.js[972-988]
- src/bridge.js[32-85]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Unvalidated level count 🐞 Bug ☼ Reliability
Description
In sw.js warmLevels(), the cached index.json count is used directly to size `Array.from({ length:
count })` with no validation, so a malformed/non-integer value can result in warming 0 levels
(silent no-op) and an unexpectedly large value can cause excessive work or a RangeError. Because the
message handler swallows errors, this can silently prevent the cache warm from completing and leave
offline progression dependent only on the normal current+next prefetch.
Code

sw.js[R80-83]

+	const index = await cache.match("data/levels/index.json");
+	if (!index) return; // install 沒成功,別瞎猜關卡數;下一次 activate 會再來
+	const { count } = await index.json();
+	const ids = Array.from({ length: count }, (_, i) => i + 1);
Evidence
sw.js reads { count } from the cached index response and immediately uses it to create an array of
IDs; there is no Number.isInteger/range guard. In contrast, main.js validates count before using
it for gameplay, demonstrating the expected schema checks.

sw.js[78-95]
src/main.js[947-952]
data/levels/index.json[1-1]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`warmLevels()` trusts `index.json`'s `count` and uses it to size an array and drive fetching without validating that it is a finite, reasonable integer. This can make warming silently do nothing (e.g., non-integer / missing count coerces to length 0) or do excessive work / throw (e.g., huge count), and the failure is swallowed.

### Issue Context
- `src/main.js` already validates `count` when reading `data/levels/index.json` for gameplay.
- `sw.js` should apply similar validation before allocating/looping.

### Fix Focus Areas
- sw.js[78-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/main.js
Comment on lines +976 to +977
const requestLevelWarm = () =>
navigator.serviceWorker?.controller?.postMessage("warm-levels");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. requestlevelwarm bypasses bridge 📘 Rule violation ⌂ Architecture

The new requestLevelWarm directly calls navigator.serviceWorker?.controller?.postMessage(...)
from src/main.js instead of routing platform/native API access through bridge.js. This violates
the platform abstraction requirement and makes future non-web platform swaps harder.
Agent Prompt
## Issue description
`src/main.js` introduces a new direct `navigator.serviceWorker` usage (`postMessage("warm-levels")`). Per the compliance rule, game/runtime code under `src/` must not call platform/native APIs directly and must route them via `bridge.js`.

## Issue Context
`src/main.js` already imports `bridge`, so the new service-worker warm trigger can be exposed as a `bridge` method (e.g., `bridge.requestLevelWarm()`), which encapsulates `navigator.serviceWorker` usage.

## Fix Focus Areas
- src/main.js[972-988]
- src/bridge.js[32-85]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread sw.js
Comment on lines +80 to +83
const index = await cache.match("data/levels/index.json");
if (!index) return; // install 沒成功,別瞎猜關卡數;下一次 activate 會再來
const { count } = await index.json();
const ids = Array.from({ length: count }, (_, i) => i + 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Unvalidated level count 🐞 Bug ☼ Reliability

In sw.js warmLevels(), the cached index.json count is used directly to size `Array.from({ length:
count })` with no validation, so a malformed/non-integer value can result in warming 0 levels
(silent no-op) and an unexpectedly large value can cause excessive work or a RangeError. Because the
message handler swallows errors, this can silently prevent the cache warm from completing and leave
offline progression dependent only on the normal current+next prefetch.
Agent Prompt
### Issue description
`warmLevels()` trusts `index.json`'s `count` and uses it to size an array and drive fetching without validating that it is a finite, reasonable integer. This can make warming silently do nothing (e.g., non-integer / missing count coerces to length 0) or do excessive work / throw (e.g., huge count), and the failure is swallowed.

### Issue Context
- `src/main.js` already validates `count` when reading `data/levels/index.json` for gameplay.
- `sw.js` should apply similar validation before allocating/looping.

### Fix Focus Areas
- sw.js[78-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant