Skip to content

feat: two-phase write-through persist for Chrome 146+#434

Open
franky47 wants to merge 7 commits intonextfrom
feat/two-phase-persist
Open

feat: two-phase write-through persist for Chrome 146+#434
franky47 wants to merge 7 commits intonextfrom
feat/two-phase-persist

Conversation

@franky47
Copy link
Copy Markdown
Member

@franky47 franky47 commented Mar 25, 2026

Tasks

  • Remove the debounce semantics (comments, function naming & sequencing) that was removed from the initial implementation.

Summary

  • Replace unload-based persistence with a two-phase write-through approach, inspired by ProtonMail's Nov 2025 secureSessionStorage update
  • Phase 1 (eager): On every mutation, debounce-write share1 to window.name — because writing to window.name at pagehide is too late in Chrome/Safari (the browsing context may be frozen)
  • Phase 2 (finalize): On pagehide (with unload fallback), write share2 to sessionStorage
  • Public persist() API unchanged for backwards compatibility

Closes #433

Context

Chrome 146+ (March 2026) is deprecating the unload event. The library relied on unload to persist both XOR shares. ProtonMail discovered that even pagehide is too late for window.name writes — the browsing context may be frozen and modifications aren't committed. Their solution: write window.name eagerly on every change, defer only the sessionStorage write to pagehide.

Test plan

  • 22 unit tests pass (Jest) — including 7 new tests for two-phase mechanics
  • 4 e2e tests pass (Playwright + Chromium) — real page refresh persistence
  • TypeScript build passes
  • Code review clean

🤖 Generated with Claude Code

Chrome 146+ (March 2026) deprecates the unload event, which this library
relied on to persist XOR-split shares. Inspired by ProtonMail's Nov 2025
secureSessionStorage update, this switches to a two-phase approach:

Phase 1 (eager): On every mutation (set/delete), debounce-write share1
to window.name immediately — because writing to window.name at pagehide
is too late in Chrome/Safari (the browsing context may be frozen).

Phase 2 (finalize): On pagehide (with unload fallback), write share2 to
sessionStorage, which still works reliably during page teardown.

Also adds Playwright e2e tests that verify persistence across real
Chromium page refreshes.
actions/cache v1/v2 has been shut down by GitHub, breaking CI.
Replaced with built-in caching in actions/setup-node@v4.
Also bumped Node from 14.x (EOL) to 22.x and updated
coverallsapp/github-action to v2.
@coveralls
Copy link
Copy Markdown

coveralls commented Mar 25, 2026

Pull Request Test Coverage Report for Build 23556665186

Details

  • 24 of 25 (96.0%) changed or added relevant lines in 1 file are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-0.2%) to 94.196%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/index.ts 24 25 96.0%
Totals Coverage Status
Change from base Build 11435983015: -0.2%
Covered Lines: 142
Relevant Lines: 147

💛 - Coveralls

Install Chromium and run e2e tests after unit tests.
Playwright config now explicitly targets only Chromium.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the library’s browser persistence strategy to handle Chrome 146+ unload deprecation by introducing a two-phase write-through mechanism (eager window.name updates + finalize to sessionStorage on pagehide/unload), and adds Playwright-based e2e coverage for refresh persistence.

Changes:

  • Implement two-phase persistence in SessionKeystore (debounced eager share1 to window.name, finalize share2 to sessionStorage on pagehide with unload fallback).
  • Add unit tests for two-phase mechanics and new Playwright e2e tests + config/CI wiring.
  • Add tooling/scripts for e2e fixture bundling (esbuild) and running the e2e suite.

Reviewed changes

Copilot reviewed 9 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/index.ts Reworks persistence to two-phase (eager + finalize) and updates lifecycle event handling.
src/index.test.ts Adds unit tests validating the two-phase persistence behavior.
e2e/persistence.spec.ts Adds Playwright tests for persistence across refresh and session boundaries.
e2e/fixture/index.html Adds a simple browser fixture page for Playwright to exercise persistence.
playwright.config.ts Introduces Playwright config and a local webServer for the fixture.
package.json Adds e2e scripts and dev dependencies (Playwright, esbuild, serve) + ignores e2e in Jest.
tsconfig.json Excludes e2e + Playwright config from TS compilation.
.github/workflows/ci.yml Updates CI to Node 22, adds Playwright install and e2e test execution.
.github/workflows/cd.yml Updates CD to Node 22 with Yarn cache enabled.
.github/dependabot.yml Adds a cooldown for Dependabot update PRs.
.gitignore Ignores e2e fixture output and Playwright test results.
yarn.lock Locks new dev dependencies (Playwright, esbuild, serve and transitive deps).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/index.ts Outdated
Comment on lines +204 to +210
private _finalize() {
// Flush any pending debounced save that hasn't fired yet
if (this.#debounceTimer !== null) {
clearTimeout(this.#debounceTimer)
this.#debounceTimer = null
this.#pendingFinalize = this._save()
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

_finalize() calls _save() when a debounce is still pending, which writes window.name during pagehide/unload. This contradicts the stated motivation (writes to window.name at pagehide may not commit when the browsing context is frozen) and can still drop share1 on fast refreshes. A safer approach is to ensure share1 is written before teardown (e.g., leading-edge write on mutation, or a much shorter flush that runs immediately after mutation), and keep _finalize() limited to writing share2 only.

Copilot uses AI. Check for mistakes.
Comment thread src/index.ts Outdated
Comment on lines +77 to +81
// Phase 2: finalize on pagehide (preferred) with unload fallback.
// Both may fire — _finalize() is idempotent.
const finalizeHandler = this._finalize.bind(this)
window.addEventListener('pagehide', finalizeHandler)
window.addEventListener('unload', finalizeHandler)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Each SessionKeystore instance adds pagehide and unload listeners but never removes them. In long-lived apps (or tests that create many instances), this retains instances in memory and can trigger multiple _finalize() runs (and competing writes) for the same storage key. Consider using a shared/static listener per #storageKey, or adding a dispose()/close() API (optionally via AbortController) to remove listeners when a store is no longer used.

Copilot uses AI. Check for mistakes.
Comment thread e2e/persistence.spec.ts Outdated
Comment on lines +15 to +16
// Wait for debounce to flush window.name (50ms + buffer)
await page.waitForTimeout(200)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Using a fixed waitForTimeout(200) makes the e2e test timing-dependent and potentially flaky under CI load. Prefer waiting on an observable condition (e.g., page.waitForFunction(() => window.name !== '') or a DOM signal) so the test only proceeds once the debounced write has actually happened.

Copilot uses AI. Check for mistakes.
Comment thread e2e/persistence.spec.ts Outdated
Comment on lines +40 to +41
await page.waitForTimeout(200)
await page.reload()
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This waitForTimeout(200) is timing-based and can be flaky. Consider replacing it with a condition-based wait that asserts the debounced persistence has completed (e.g., waiting until window.name becomes non-empty).

Copilot uses AI. Check for mistakes.
Comment thread e2e/persistence.spec.ts Outdated
Comment on lines +93 to +95
// Wait for debounce
await page.waitForTimeout(200)

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This test relies on waitForTimeout(200) for the debounce. To reduce flakes, prefer waiting for window.name to change (phase 1) rather than sleeping for an assumed duration.

Copilot uses AI. Check for mistakes.
Comment thread src/index.ts
@franky47 franky47 marked this pull request as ready for review March 25, 2026 17:28
The debounce behaviour was removed but function names, comments,
and test descriptions still referenced scheduling and debouncing.
Renamed _scheduleEagerSave to _writeShare1 and cleaned up all
related comments to describe the current synchronous behaviour.
@MaxLiebsch
Copy link
Copy Markdown

Where do we stand with this?

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.

Stored key is lost on Brave browser when hitting F5

4 participants