Skip to content

feat: keep learner answers through a reload or crash - #628

Open
NoahFreelove wants to merge 3 commits into
masterfrom
feat/learner-draft-autosave
Open

NoahFreelove wants to merge 3 commits into
masterfrom
feat/learner-draft-autosave

Conversation

@NoahFreelove

Copy link
Copy Markdown
Contributor

PR Description

Overview:

Learner answers only existed in the Zustand store until Submit, so a refresh, crash, closed tab, or expired session lost everything typed since the attempt began. Answers are now mirrored into localStorage (debounced, keyed by attempt id) and merged back when the attempt reopens. The merge is one-directional — the server's copy wins every field it already holds, so a stale draft can never overwrite a graded answer. Also removes the never-shipped per-question autosave hook (its only importer was its own test).

Post-review hardening in the final commit:

  • The draft cleanup was dead code — page hydration set the attempt id through the generic setLearnerStore, bypassing the named setter that pruned old drafts. Attempt-change side effects now live in setLearnerStore itself (cancel the pending debounced write, prune other drafts), the named setter routes through it, and hydration prunes directly for the same-id reload case.
  • True/false answers now round-trip; they were the one answer type the draft skipped.
  • Restored drafts are validated field by field on read — localStorage is user-editable input, and an object where a string belongs would otherwise break the attempt page on every visit until the TTL expired it.

Type of Issue:

  • Feature (feat): New functionality or feature added.
  • Bug Fix (bug): Issue or bug resolved.
  • Chore (chore): Maintenance, refactoring, or non-functional changes.
  • Documentation Update (doc): Documentation improvements or additions.

Change Type:

  • Major: Significant changes that introduce new features, large refactoring, or breaking changes. Requires thorough review and testing.
  • Minor: Small to medium changes, such as adding new functionality that is backward-compatible or minor refactoring. Moderate review needed.
  • Patch: Bug fixes, small tweaks, or documentation updates. Light review is sufficient.

Test Coverage

  • Unit tests updated
  • Manual verification done

Evidence:

  • 24 tests across the draft module and a new store-level wiring suite that drives the real store the way the page does (the unit tests alone had proven the functions correct while nothing called the cleanup — the wiring suite exists so that class of gap can't recur). Learner-area run: 242 tests green across 31 suites. tsc --noEmit adds no new errors (3 pre-existing in author-store tests), eslint clean.
  • Hand-crafted storage content is covered: wrong-type fields, oversized values, non-question-id keys (including a raw-JSON __proto__ entry), and future timestamps are all dropped on read.

Impact / Risk

  • Learner attempt page only; client-only — no request, schema, or API change. Storage failures (quota, Safari private mode, SSR) all fail closed.
  • Storage is bounded to one draft: every attempt change and every hydration prunes all other entries; submit clears the active one and cancels any pending write; entries expire after 7 days. Uploads are excluded (a File cannot be revived from localStorage).
  • Known limitations, deliberately deferred: two tabs on the same attempt are last-writer-wins for the draft (submitted answers unaffected); restored answers keep status "unedited" until touched; no flush on pagehide, so up to ~800ms of typing can miss the draft on a crash. The pre-existing zustand persist of all answers (no TTL) is a separate follow-up candidate.

Rollback

git revert e933d82a 3b337075 349c9817 — client-only; stale mark:draft:v1:* entries left in browsers expire via their own TTL check or sit inert.

Reviewer Focus

  • One-directionality of mergeDraftIntoQuestions, especially the type-not-truthiness check for false true/false answers.
  • The attempt-change hook in setLearnerStore (stores/learner.ts) — it also runs on the timer-expiry path, where activeAttemptId goes to null and every draft is dropped.
  • Sanitization caps in lib/learner-draft.ts (MAX_DRAFT_FIELD_CHARS, MAX_DRAFT_CHOICES).

Answers only existed in the Zustand store until the learner pressed
Submit, so a refresh, closed tab, crash, or expired session lost
everything typed since the attempt began. submitAssignment is called from
exactly two places — the Submit button and timer expiry — and nothing else
ever wrote the answers anywhere.

Mirror them into localStorage instead. Edits schedule a debounced write
keyed by attempt id; reopening the attempt merges the draft back over the
questions fetched from the server.

The merge is one-directional: the server's copy wins every field it
already holds, and the draft only fills gaps. A stale local value can
therefore never overwrite a graded answer, which is the failure that would
matter most.

Storage stays bounded without a cleanup job. Starting or resuming an
attempt drops every draft but that one, so a learner accumulates at most a
single entry no matter how many assignments they open, and a fresh attempt
cannot inherit a previous one's answers. Submitting clears the entry and
cancels any pending write, so a debounced save cannot land afterwards and
resurrect what was just submitted. Entries also carry a timestamp and are
discarded after a week.

Every storage access is wrapped: quota limits, Safari private mode, and
server rendering all fail closed, because losing crash-recovery must never
break the page the learner is using.

Uploads are excluded — a File cannot be revived from localStorage, and
storing its name would promise a recovery that never happens.
The hook was introduced already commented out and never ran in
production. Its only importer was its own test file, and the four
question components carried nothing but a commented-out call, so it
contributed no behaviour and shipped in no bundle.

Answers are now kept through a reload by the local draft store, which
needs neither this hook nor a request per keystroke, so keeping a second
unreachable implementation around only invites someone to wire up the
wrong one.

`submitQuestion` and the server route it calls are left in place. They are
a working per-question save endpoint and the natural foundation if this
ever becomes a server-side feature; only the dead client hook goes.
Review of this branch found the storage-bounding claim was not true in
the shipped flow: hydration sets the attempt id through the generic
setLearnerStore, so the named setter carrying clearOtherDrafts was dead
code and a draft accumulated for every attempt ever opened. The
attempt-change side effects now live in setLearnerStore itself — cancel
the debounced write captured for the previous attempt, then prune every
other draft — and the named setter routes through it. Hydration also
prunes directly, because a reload resumes the same id and skips the
store update entirely. A wiring test drives the real store the way the
page does; the unit suite alone proved the functions correct while
nothing called them.

True/false answers were the one answer type the draft skipped:
DraftAnswer had no boolean field and setAnswerChoice never scheduled a
save, so exactly the recovery this feature promises was missing for
those questions. The field round-trips now, checked by type rather than
truthiness everywhere, because false is a chosen answer and must neither
vanish from the draft nor beat a server-held false in the merge.

Restored drafts are now validated field by field. loadDraft trusted the
envelope and returned entries as-is, so a hand-edited object where a
string belongs would flow into the store and break rendering on every
visit for up to a week — localStorage is user-editable input like any
other. Non-string fields, non-boolean choices, oversized values, and
keys that are not question ids are dropped on read; a timestamp from
the future is discarded like an expired one.

Erasing every answer now actually removes the draft: pickAnswer stored
empty strings, so the documented clear-on-erase behavior only worked in
a test that fed it a state the store never produces. The test now uses
the real erased shape, and the module header no longer overclaims
multi-tab safety — two tabs are last-writer-wins, which is acceptable
for crash recovery and now said plainly.
@skills-network-bot

Copy link
Copy Markdown

This pull request has had no activity for 30 days, so it has been marked stale. It will be closed in 14 days unless it becomes active. To keep it open, push a commit, leave a comment, or remove the stale label. If it is blocked, a short note on what it is waiting for is enough.

@skills-network-bot skills-network-bot Bot added the stale No activity for 30 days. Closes after 14 more unless something changes. label Oct 10, 2026

This branch has not been deployed

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

Labels

stale No activity for 30 days. Closes after 14 more unless something changes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant